From b7bda052e96f4fa4e992287bbbe59b57e925a155 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 29 Jan 2026 19:52:42 -0800 Subject: [PATCH] new and create (push) --- apps/clock/index.tsx | 2 +- apps/clock2/bun.lock | 38 +++++++ package.json | 5 +- src/cli/index.ts | 166 ++++++++++++++++++++++++++++--- src/client/index.tsx | 2 +- src/client/tags/emoji-picker.tsx | 2 +- src/client/tags/modal.tsx | 2 +- src/client/themes/index.ts | 2 +- src/server/api/apps.ts | 107 ++++++-------------- src/server/api/sync.ts | 4 +- src/server/index.tsx | 2 +- 11 files changed, 235 insertions(+), 97 deletions(-) create mode 100644 apps/clock2/bun.lock diff --git a/apps/clock/index.tsx b/apps/clock/index.tsx index fe75314..1f941a0 100644 --- a/apps/clock/index.tsx +++ b/apps/clock/index.tsx @@ -1,5 +1,5 @@ import { Hype } from 'hype' -import { define, stylesToCSS } from 'forge' +import { define, stylesToCSS } from '@because/forge' const app = new Hype() diff --git a/apps/clock2/bun.lock b/apps/clock2/bun.lock new file mode 100644 index 0000000..ee899ce --- /dev/null +++ b/apps/clock2/bun.lock @@ -0,0 +1,38 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "clock2", + "dependencies": { + "forge": "git+https://git.nose.space/defunkt/forge", + "hype": "git+https://git.nose.space/defunkt/hype", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.9.2", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "forge": ["@because/forge@git+https://git.nose.space/defunkt/forge#67180bb4f3f13b6eacba020e60592d4a76e2deda", { "peerDependencies": { "typescript": "^5" } }, "67180bb4f3f13b6eacba020e60592d4a76e2deda"], + + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + + "hype": ["@because/hype@git+https://git.nose.space/defunkt/hype#5ed8673274187958e3bee569aba0d33baf605bb1", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "5ed8673274187958e3bee569aba0d33baf605bb1"], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/package.json b/package.json index b9bafd1..268175f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test": "bun test", "cli:build": "bun run scripts/build.ts", "cli:build:all": "bun run scripts/build.ts --all", - "cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin" + "cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin", + "cli:uninstall": "sudo rm /usr/local/bin" }, "devDependencies": { "@types/bun": "latest" @@ -26,4 +27,4 @@ "@because/hype": "^0.0.1", "kleur": "^4.1.5" } -} \ No newline at end of file +} diff --git a/src/cli/index.ts b/src/cli/index.ts index beea7b6..05c53f5 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,7 +5,8 @@ import { program } from 'commander' import { createHash } from 'crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs' import color from 'kleur' -import { dirname, join, relative } from 'path' +import { basename, dirname, join, relative } from 'path' +import * as readline from 'readline' const HOST = `http://localhost:${process.env.PORT ?? 3000}` @@ -31,13 +32,33 @@ function makeUrl(path: string): string { return `${HOST}${path}` } +function handleError(error: unknown): void { + if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') { + console.error(`🐾 Can't connect to toes server at ${HOST}`) + return + } + console.error(error) +} + async function get(url: string): Promise { try { const res = await fetch(makeUrl(url)) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return await res.json() } catch (error) { - console.error(error) + handleError(error) + } +} + +async function getManifest(appName: string): Promise<{ exists: boolean; manifest?: Manifest } | null> { + try { + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`)) + if (res.status === 404) return { exists: false } + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return { exists: true, manifest: await res.json() } + } catch (error) { + handleError(error) + return null } } @@ -51,7 +72,7 @@ async function post(url: string, body?: B): Promise { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return true } catch (error) { - console.error(error) + handleError(error) return false } } @@ -76,7 +97,7 @@ async function download(url: string): Promise { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return Buffer.from(await res.arrayBuffer()) } catch (error) { - console.error(error) + handleError(error) } } @@ -88,7 +109,7 @@ async function del(url: string): Promise { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return true } catch (error) { - console.error(error) + handleError(error) return false } } @@ -313,24 +334,29 @@ async function pushApp() { return } - console.log(`Pushing ${color.bold(appName)} to server...`) - const localManifest = generateLocalManifest(process.cwd(), appName) - const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) + const result = await getManifest(appName) - if (!remoteManifest) { - console.error('App not found on server') + if (result === null) { + // Connection error - already printed return } + if (!result.exists) { + const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) + 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(remoteManifest.files)) + const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) // Files to upload (new or changed) const toUpload: string[] = [] for (const file of localFiles) { const local = localManifest.files[file]! - const remote = remoteManifest.files[file] + const remote = result.manifest?.files[file] if (!remote || local.hash !== remote.hash) { toUpload.push(file) } @@ -377,6 +403,114 @@ async function pushApp() { console.log(color.green('✓ Push complete')) } +async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(`${message} [y/N] `, (answer) => { + rl.close() + resolve(answer.toLowerCase() === 'y') + }) + }) +} + +async function newApp(name?: string) { + const appPath = name ? join(process.cwd(), name) : process.cwd() + const appName = name ?? basename(process.cwd()) + + if (name && existsSync(appPath)) { + console.error(`Directory already exists: ${name}`) + return + } + + const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json'] + const existing = filesToCheck.filter((f) => existsSync(join(appPath, f))) + if (existing.length > 0) { + console.error(`Files already exist: ${existing.join(', ')}`) + return + } + + const ok = await confirm(`Create ${color.bold(appName)} in ${appPath}?`) + if (!ok) return + + 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(

${appName}

)) + +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(' bun install') + console.log(' bun dev') +} + async function pullApp() { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') @@ -516,6 +650,12 @@ program .argument('', 'app name') .action(getApp) +program + .command('new') + .description('Create a new toes app') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(newApp) + program .command('push') .description('Push local changes to server') diff --git a/src/client/index.tsx b/src/client/index.tsx index b9ad2a2..c0dc7cf 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,5 +1,5 @@ import { render as renderApp } from 'hono/jsx/dom' -import { define, Styles } from 'forge' +import { define, Styles } from '@because/forge' import type { App, AppState } from '../shared/types' import { theme } from './themes' import { Modal, initModal } from './tags/modal' diff --git a/src/client/tags/emoji-picker.tsx b/src/client/tags/emoji-picker.tsx index 77a3e31..c5ad738 100644 --- a/src/client/tags/emoji-picker.tsx +++ b/src/client/tags/emoji-picker.tsx @@ -1,4 +1,4 @@ -import { define } from 'forge' +import { define } from '@because/forge' import { theme } from '../themes' import { openModal, closeModal } from './modal' import { update } from '../update' diff --git a/src/client/tags/modal.tsx b/src/client/tags/modal.tsx index d709e09..8d505af 100644 --- a/src/client/tags/modal.tsx +++ b/src/client/tags/modal.tsx @@ -1,5 +1,5 @@ import type { Child } from 'hono/jsx' -import { define } from 'forge' +import { define } from '@because/forge' import { theme } from '../themes' let modalTitle: string | null = null diff --git a/src/client/themes/index.ts b/src/client/themes/index.ts index 555ded5..431e837 100644 --- a/src/client/themes/index.ts +++ b/src/client/themes/index.ts @@ -1,4 +1,4 @@ -import { createThemes } from 'forge' +import { createThemes } from '@because/forge' import dark from './dark' import light from './light' diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 1ca8880..5cffb1f 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -1,9 +1,9 @@ import { allApps, onChange, startApp, stopApp, updateAppIcon } from '$apps' import type { App as BackendApp } from '$apps' import type { App as SharedApp } from '@types' -import { Hono } from 'hono' +import { Hype } from '@because/hype' -const router = new Hono() +const router = Hype.router() // BackendApp -> SharedApp function convert(app: BackendApp): SharedApp { @@ -14,46 +14,23 @@ function convert(app: BackendApp): SharedApp { } // SSE endpoint for real-time app state updates -router.get('/stream', c => { - const encoder = new TextEncoder() +router.sse('/stream', (send) => { + const broadcast = () => { + const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({ + name, + state, + icon, + error, + port, + started, + logs, + })) + send(apps) + } - const stream = new ReadableStream({ - start(controller) { - const send = () => { - // Strip proc field from apps before sending - const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({ - name, - state, - icon, - error, - port, - started, - logs, - })) - const data = JSON.stringify(apps) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - } - - // Send initial state - send() - - // Subscribe to changes - const unsub = onChange(send) - - // Handle client disconnect via abort signal - c.req.raw.signal.addEventListener('abort', () => { - unsub() - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) + broadcast() + const unsub = onChange(broadcast) + return () => unsub() }) router.get('/', c => c.json(allApps().map(convert))) @@ -78,47 +55,29 @@ router.get('/:app/logs', c => { return c.json(app.logs ?? []) }) -router.get('/:app/logs/stream', c => { +router.sse('/:app/logs/stream', (send, c) => { const appName = c.req.param('app') const targetApp = allApps().find(a => a.name === appName) - if (!targetApp) { - return c.json({ error: 'App not found' }, 404) - } + if (!targetApp) return - const encoder = new TextEncoder() let lastLogCount = 0 - const stream = new ReadableStream({ - start(controller) { - const sendNewLogs = () => { - const currentApp = allApps().find(a => a.name === appName) - if (!currentApp) return + const sendNewLogs = () => { + const currentApp = allApps().find(a => a.name === appName) + if (!currentApp) return - const logs = currentApp.logs ?? [] - const newLogs = logs.slice(lastLogCount) - lastLogCount = logs.length + const logs = currentApp.logs ?? [] + const newLogs = logs.slice(lastLogCount) + lastLogCount = logs.length - for (const line of newLogs) { - controller.enqueue(encoder.encode(`data: ${line}\n\n`)) - } - } + for (const line of newLogs) { + send(line) + } + } - sendNewLogs() - const unsub = onChange(sendNewLogs) - - c.req.raw.signal.addEventListener('abort', () => { - unsub() - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) + sendNewLogs() + const unsub = onChange(sendNewLogs) + return () => unsub() }) router.post('/:app/start', c => { diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index cfe6bdf..33f40ed 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -2,9 +2,9 @@ import { APPS_DIR, allApps } from '$apps' import { generateManifest } from '../sync' import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' import { dirname, join } from 'path' -import { Hono } from 'hono' +import { Hype } from '@because/hype' -const router = new Hono() +const router = Hype.router() router.get('/apps', c => c.json(allApps().map(a => a.name))) diff --git a/src/server/index.tsx b/src/server/index.tsx index b18317d..55c9c21 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,7 +1,7 @@ import { initApps } from '$apps' import appsRouter from './api/apps' import syncRouter from './api/sync' -import { Hype } from 'hype' +import { Hype } from '@because/hype' const app = new Hype({ layout: false })