new and create (push)

This commit is contained in:
Chris Wanstrath 2026-01-29 19:52:42 -08:00
parent 9e58995e66
commit b7bda052e9
11 changed files with 235 additions and 97 deletions

View File

@ -1,5 +1,5 @@
import { Hype } from 'hype'
import { define, stylesToCSS } from 'forge'
import { define, stylesToCSS } from '@because/forge'
const app = new Hype()

38
apps/clock2/bun.lock Normal file
View File

@ -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=="],
}
}

View File

@ -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"

View File

@ -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<T>(url: string): Promise<T | undefined> {
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<T, B = unknown>(url: string, body?: B): Promise<T | undefine
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return await res.json()
} catch (error) {
console.error(error)
handleError(error)
}
}
@ -64,7 +85,7 @@ async function put(url: string, body: Buffer | Uint8Array): Promise<boolean> {
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<Buffer | undefined> {
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<boolean> {
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<boolean> {
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(<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(' bun install')
console.log(' bun dev')
}
async function pullApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
@ -516,6 +650,12 @@ program
.argument('<name>', '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')

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -1,4 +1,4 @@
import { createThemes } from 'forge'
import { createThemes } from '@because/forge'
import dark from './dark'
import light from './light'

View File

@ -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,13 +14,8 @@ function convert(app: BackendApp): SharedApp {
}
// SSE endpoint for real-time app state updates
router.get('/stream', c => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
const send = () => {
// Strip proc field from apps before sending
router.sse('/stream', (send) => {
const broadcast = () => {
const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({
name,
state,
@ -30,30 +25,12 @@ router.get('/stream', c => {
started,
logs,
}))
const data = JSON.stringify(apps)
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
send(apps)
}
// 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,18 +55,13 @@ 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
@ -99,26 +71,13 @@ router.get('/:app/logs/stream', c => {
lastLogCount = logs.length
for (const line of newLogs) {
controller.enqueue(encoder.encode(`data: ${line}\n\n`))
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',
},
})
return () => unsub()
})
router.post('/:app/start', c => {

View File

@ -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)))

View File

@ -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 })