Compare commits

..

No commits in common. "dd56dc0df61115577c4e6bd04b49fbae8f629d1b" and "b7bda052e96f4fa4e992287bbbe59b57e925a155" have entirely different histories.

6 changed files with 88 additions and 262 deletions

View File

@ -28,7 +28,7 @@
[x] `toes logs <app>`
[x] `toes logs -f <app>`
[x] `toes info <app>`
[x] `toes new`
[ ] `toes new`
[x] `toes pull`
[x] `toes push`
[ ] `toes sync`

View File

@ -1,7 +1,6 @@
#!/usr/bin/env bun
import type { App, LogLine } from '@types'
import { loadGitignore } from '@gitignore'
import { generateTemplates } from '@templates'
import { program } from 'commander'
import { createHash } from 'crypto'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs'
@ -157,10 +156,7 @@ function generateLocalManifest(appPath: string, appName: string): Manifest {
}
}
async function infoApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
async function infoApp(name: string) {
const app: App | undefined = await get(`/api/apps/${name}`)
if (!app) {
console.error(`App not found: ${name}`)
@ -197,31 +193,22 @@ async function listApps() {
}
}
async function startApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/start`)
const startApp = async (app: string) => {
await post(`/api/apps/${app}/start`)
}
async function stopApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/stop`)
const stopApp = async (app: string) => {
await post(`/api/apps/${app}/stop`)
}
async function restartApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/restart`)
const restartApp = async (app: string) => {
await post(`/api/apps/${app}/restart`)
}
const printLog = (line: LogLine) =>
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
async function logApp(arg: string | undefined, options: { follow?: boolean }) {
const name = resolveAppName(arg)
if (!name) return
async function logApp(name: string, options: { follow?: boolean }) {
if (options.follow) {
await tailLogs(name)
return
@ -271,10 +258,7 @@ async function tailLogs(name: string) {
}
}
async function openApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
async function openApp(name: string) {
const app: App | undefined = await get(`/api/apps/${name}`)
if (!app) {
console.error(`App not found: ${name}`)
@ -338,13 +322,6 @@ function isApp(): boolean {
}
}
function resolveAppName(name?: string): string | undefined {
if (name) return name
if (isApp()) return basename(process.cwd())
console.error('No app specified and current directory is not a toes app')
return undefined
}
async function pushApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
@ -370,6 +347,8 @@ async function pushApp() {
if (!ok) return
}
console.log(`Pushing ${color.bold(appName)} to server...`)
const localFiles = new Set(Object.keys(localManifest.files))
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
@ -396,8 +375,6 @@ async function pushApp() {
return
}
console.log(`Pushing ${color.bold(appName)} to server...`)
if (toUpload.length > 0) {
console.log(`Uploading ${toUpload.length} files...`)
for (const file of toUpload) {
@ -459,20 +436,77 @@ async function newApp(name?: string) {
const ok = await confirm(`Create ${color.bold(appName)} in ${appPath}?`)
if (!ok) return
mkdirSync(join(appPath, 'src', 'pages'), { recursive: true })
const templates = generateTemplates(appName)
for (const [filename, content] of Object.entries(templates)) {
writeFileSync(join(appPath, filename), content)
if (name) {
mkdirSync(appPath, { recursive: true })
}
const packageJson = `{
"name": "${appName}",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.2"
},
"dependencies": {
"hype": "git+https://git.nose.space/defunkt/hype",
"forge": "git+https://git.nose.space/defunkt/forge"
}
}
`
const indexTsx = `import { Hype } from 'hype'
const app = new Hype()
app.get('/', (c) => c.html(<h1>${appName}</h1>))
export default app.defaults
`
const tsconfig = `{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}
`
writeFileSync(join(appPath, 'package.json'), packageJson)
writeFileSync(join(appPath, 'index.tsx'), indexTsx)
writeFileSync(join(appPath, 'tsconfig.json'), tsconfig)
console.log(color.green(`✓ Created ${appName}`))
console.log()
console.log('Next steps:')
if (name) {
console.log(` cd ${name}`)
}
console.log(' toes push')
console.log(' bun install')
console.log(' bun dev')
}
@ -489,6 +523,8 @@ async function pullApp() {
return
}
console.log(`Pulling ${color.bold(appName)} from server...`)
const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`)
if (!remoteManifest) {
console.error('App not found on server')
@ -523,8 +559,6 @@ async function pullApp() {
return
}
console.log(`Pulling ${color.bold(appName)} from server...`)
if (toDownload.length > 0) {
console.log(`Downloading ${toDownload.length} files...`)
for (const file of toDownload) {
@ -565,7 +599,7 @@ program
program
.command('info')
.description('Show info for an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.action(infoApp)
program
@ -576,38 +610,38 @@ program
program
.command('start')
.description('Start an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.action(startApp)
program
.command('stop')
.description('Stop an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.action(stopApp)
program
.command('restart')
.description('Restart an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.action(restartApp)
program
.command('logs')
.description('Show logs for an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.option('-f, --follow', 'follow log output')
.action(logApp)
program
.command('log', { hidden: true })
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.option('-f, --follow', 'follow log output')
.action(logApp)
program
.command('open')
.description('Open an app in browser')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<name>', 'app name')
.action(openApp)
program

View File

@ -1,9 +1,8 @@
import { render as renderApp } from 'hono/jsx/dom'
import { define, Styles } from '@because/forge'
import type { App, AppState } from '../shared/types'
import { generateTemplates } from '../shared/templates'
import { theme } from './themes'
import { closeModal, initModal, Modal, openModal, rerenderModal } from './tags/modal'
import { Modal, initModal } from './tags/modal'
import { initUpdate } from './update'
import { openEmojiPicker } from './tags/emoji-picker'
@ -347,143 +346,6 @@ const stateLabels: Record<AppState, string> = {
stopping: 'Stopping',
}
// Form styles for modal
const Form = define('Form', {
base: 'form',
display: 'flex',
flexDirection: 'column',
gap: 16,
})
const FormField = define('FormField', {
display: 'flex',
flexDirection: 'column',
gap: 6,
})
const FormLabel = define('FormLabel', {
base: 'label',
fontSize: 13,
fontWeight: 500,
color: theme('colors-text'),
})
const FormInput = define('FormInput', {
base: 'input',
padding: '8px 12px',
background: theme('colors-bgSubtle'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-text'),
fontSize: 14,
selectors: {
'&:focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
'&::placeholder': {
color: theme('colors-textFaint'),
},
},
})
const FormError = define('FormError', {
fontSize: 13,
color: theme('colors-error'),
})
const FormActions = define('FormActions', {
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 8,
})
// New App creation
let newAppError = ''
let newAppCreating = false
async function createNewApp(input: HTMLInputElement) {
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
if (!name) {
newAppError = 'App name is required'
rerenderModal()
return
}
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
rerenderModal()
return
}
if (apps.some(a => a.name === name)) {
newAppError = 'An app with this name already exists'
rerenderModal()
return
}
newAppCreating = true
newAppError = ''
rerenderModal()
try {
const templates = generateTemplates(name)
for (const [filename, content] of Object.entries(templates)) {
const res = await fetch(`/api/sync/apps/${name}/files/${filename}`, {
method: 'PUT',
body: content,
})
if (!res.ok) {
throw new Error(`Failed to create ${filename}`)
}
}
// Success - close modal and select the new app
selectedApp = name
localStorage.setItem('selectedApp', name)
closeModal()
} catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false
rerenderModal()
}
}
function openNewAppModal() {
newAppError = ''
newAppCreating = false
openModal('New App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
createNewApp(input)
}}>
<FormField>
<FormLabel for="app-name">App Name</FormLabel>
<FormInput
id="app-name"
type="text"
placeholder="my-app"
autofocus
/>
{newAppError && <FormError>{newAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={newAppCreating}>
{newAppCreating ? 'Creating...' : 'Create App'}
</Button>
</FormActions>
</Form>
))
}
// Actions - call API then let SSE update the state
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
@ -660,7 +522,7 @@ const Dashboard = () => {
</AppList>
{!sidebarCollapsed && (
<SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
<NewAppButton>+ New App</NewAppButton>
</SidebarFooter>
)}
</Sidebar>

View File

@ -1,12 +1,13 @@
import type { App as SharedApp, AppState, LogLine } from '@types'
import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types'
import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs'
import { join } from 'path'
export type { AppState } from '@types'
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
const DEFAULT_EMOJI = '🖥️'
const MAX_LOGS = 100
const _apps = new Map<string, App>()
const _listeners = new Set<() => void>()

View File

@ -1,69 +0,0 @@
import { DEFAULT_EMOJI } from './types'
export interface AppTemplates {
'index.tsx': string
'package.json': string
'tsconfig.json': string
'.npmrc': string
'src/pages/index.tsx': string
}
const tsconfig = {
compilerOptions: {
lib: ['ESNext'],
target: 'ESNext',
module: 'Preserve',
moduleDetection: 'force',
jsx: 'react-jsx',
jsxImportSource: 'hono/jsx',
allowJs: true,
moduleResolution: 'bundler',
allowImportingTsExtensions: true,
verbatimModuleSyntax: true,
noEmit: true,
strict: true,
skipLibCheck: true,
noFallthroughCasesInSwitch: true,
noUncheckedIndexedAccess: true,
noImplicitOverride: true,
noUnusedLocals: false,
noUnusedParameters: false,
noPropertyAccessFromIndexSignature: false,
},
}
export function generateTemplates(appName: string): AppTemplates {
const packageJson = {
name: appName,
module: 'index.tsx',
type: 'module',
private: true,
scripts: {
toes: 'bun run --watch index.tsx',
start: 'bun toes',
dev: 'bun run --hot index.tsx',
},
toes: {
icon: DEFAULT_EMOJI,
},
devDependencies: {
'@types/bun': 'latest',
},
peerDependencies: {
typescript: '^5.9.2',
},
dependencies: {
'@because/hype': '*',
'@because/forge': '*',
'@because/howl': '*',
},
}
return {
'.npmrc': 'registry=https://npm.nose.space',
'package.json': JSON.stringify(packageJson, null, 2) + '\n',
'src/pages/index.tsx': `export default () => <h1>${appName}</h1>`,
'index.tsx': `import { Hype } from '@because/hype'\nconst app = new Hype\nexport default app.defaults`,
'tsconfig.json': JSON.stringify(tsconfig, null, 2) + '\n',
}
}

View File

@ -1,5 +1,3 @@
export const DEFAULT_EMOJI = '🖥️'
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
export type LogLine = {