forked from defunkt/toes
renameApp() killed the old process with .kill() but didn't wait for it to actually exit before restarting on the same port. The OS still had the port bound, causing the new process to fail with "port is taken". Additionally, the old process's exit handler would fire after the rename and corrupt the app's state—releasing the new process's port, setting state to 'invalid', and nullifying the proc reference. Fix by: - Making renameApp async and awaiting proc.exited before proceeding - Guarding the exit handler to bail out when a newer process has taken over https://claude.ai/code/session_01W9GF8Cy7T6V2rnVcoNd1Nc
387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
|
|
import type { App as BackendApp } from '$apps'
|
|
import type { App as SharedApp } from '@types'
|
|
import { generateTemplates, type TemplateType } from '%templates'
|
|
import { Hype } from '@because/hype'
|
|
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'fs'
|
|
import { dirname, join } from 'path'
|
|
|
|
const timestamp = () => {
|
|
const d = new Date()
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
}
|
|
|
|
const router = Hype.router()
|
|
|
|
// BackendApp -> SharedApp
|
|
function convert(app: BackendApp): SharedApp {
|
|
const { proc, logs, ...rest } = app
|
|
return { ...rest, pid: proc?.pid }
|
|
}
|
|
|
|
// SSE endpoint for real-time app state updates
|
|
router.sse('/stream', (send) => {
|
|
const broadcast = () => {
|
|
const apps: SharedApp[] = allApps().map(({
|
|
name, state, icon, error, port, started, logs, tool
|
|
}) => ({
|
|
name, state, icon, error, port, started, logs, tool,
|
|
}))
|
|
send(apps)
|
|
}
|
|
|
|
broadcast()
|
|
const unsub = onChange(broadcast)
|
|
return () => unsub()
|
|
})
|
|
|
|
router.get('/', c => c.json(allApps().map(convert)))
|
|
|
|
router.get('/:app', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
return c.json(convert(app))
|
|
})
|
|
|
|
router.get('/:app/logs', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
// Check for date query param to read from disk
|
|
const date = c.req.query('date')
|
|
const tailParam = c.req.query('tail')
|
|
const tail = tailParam ? parseInt(tailParam, 10) : undefined
|
|
|
|
if (date) {
|
|
// Read from disk for historical logs
|
|
const lines = readLogs(appName, date, tail)
|
|
return c.json(lines)
|
|
}
|
|
|
|
// Return in-memory logs for today (real-time)
|
|
const logs = app.logs ?? []
|
|
if (tail && tail > 0) {
|
|
return c.json(logs.slice(-tail))
|
|
}
|
|
return c.json(logs)
|
|
})
|
|
|
|
router.post('/:app/logs', async c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
let body: { text?: string, stream?: 'stdout' | 'stderr' }
|
|
try {
|
|
body = await c.req.json()
|
|
} catch {
|
|
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
|
|
}
|
|
|
|
const text = body.text?.trimEnd()
|
|
if (!text) return c.json({ ok: false, error: 'Text is required' }, 400)
|
|
|
|
appendLog(appName, text, body.stream ?? 'stdout')
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
router.get('/:app/logs/dates', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
return c.json(getLogDates(appName))
|
|
})
|
|
|
|
router.post('/', async c => {
|
|
let body: { name?: string, template?: TemplateType, tool?: boolean }
|
|
try {
|
|
body = await c.req.json()
|
|
} catch {
|
|
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
|
|
}
|
|
|
|
const name = body.name?.trim().toLowerCase().replace(/\s+/g, '-')
|
|
if (!name) return c.json({ ok: false, error: 'App name is required' }, 400)
|
|
|
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
return c.json({ ok: false, error: 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' }, 400)
|
|
}
|
|
|
|
const appPath = join(APPS_DIR, name)
|
|
if (existsSync(appPath)) {
|
|
return c.json({ ok: false, error: 'An app with this name already exists' }, 400)
|
|
}
|
|
|
|
const template = body.template ?? 'ssr'
|
|
const templates = generateTemplates(name, template, { tool: body.tool })
|
|
|
|
// Create versioned directory structure
|
|
const ts = timestamp()
|
|
const versionPath = join(appPath, ts)
|
|
const currentPath = join(appPath, 'current')
|
|
|
|
// Create directories and write files into version directory
|
|
for (const [filename, content] of Object.entries(templates)) {
|
|
const fullPath = join(versionPath, filename)
|
|
const dir = dirname(fullPath)
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
writeFileSync(fullPath, content)
|
|
}
|
|
|
|
// Create current symlink
|
|
symlinkSync(ts, currentPath)
|
|
|
|
// Register and start the app
|
|
registerApp(name)
|
|
|
|
return c.json({ ok: true, name })
|
|
})
|
|
|
|
router.sse('/:app/logs/stream', (send, c) => {
|
|
const appName = c.req.param('app')
|
|
const targetApp = allApps().find(a => a.name === appName)
|
|
if (!targetApp) return
|
|
|
|
let lastLogCount = 0
|
|
|
|
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
|
|
|
|
for (const line of newLogs) {
|
|
send(line)
|
|
}
|
|
}
|
|
|
|
sendNewLogs()
|
|
const unsub = onChange(sendNewLogs)
|
|
return () => unsub()
|
|
})
|
|
|
|
router.post('/:app/start', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
startApp(appName)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
router.post('/:app/restart', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
stopApp(appName)
|
|
startApp(appName)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
router.post('/:app/stop', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
stopApp(appName)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
router.post('/:app/icon', c => {
|
|
const appName = c.req.param('app')
|
|
const icon = c.req.query('icon') ?? ''
|
|
if (!icon) return c.json({ error: 'No icon query param provided' })
|
|
|
|
try {
|
|
updateAppIcon(appName, icon)
|
|
return c.json({ ok: true })
|
|
} catch (error) {
|
|
return c.json({ error })
|
|
}
|
|
})
|
|
|
|
router.post('/:app/rename', async c => {
|
|
const appName = c.req.param('app')
|
|
|
|
let body: { name?: string }
|
|
try {
|
|
body = await c.req.json()
|
|
} catch {
|
|
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
|
|
}
|
|
|
|
const newName = body.name?.trim().toLowerCase().replace(/\s+/g, '-')
|
|
|
|
if (!newName) return c.json({ ok: false, error: 'New name is required' }, 400)
|
|
|
|
const result = await renameApp(appName, newName)
|
|
if (!result.ok) return c.json(result, 400)
|
|
|
|
return c.json({ ok: true, name: newName })
|
|
})
|
|
|
|
// --- Environment Variables ---
|
|
|
|
interface EnvVar {
|
|
key: string
|
|
value: string
|
|
}
|
|
|
|
const appEnvPath = (appName: string) => join(envDir(), `${appName}.env`)
|
|
const envDir = () => join(TOES_DIR, 'env')
|
|
const globalEnvPath = () => join(envDir(), '_global.env')
|
|
|
|
function parseEnvFile(path: string): EnvVar[] {
|
|
if (!existsSync(path)) return []
|
|
const content = readFileSync(path, 'utf-8')
|
|
const vars: EnvVar[] = []
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed || trimmed.startsWith('#')) continue
|
|
const eqIndex = trimmed.indexOf('=')
|
|
if (eqIndex === -1) continue
|
|
const key = trimmed.slice(0, eqIndex).trim()
|
|
let value = trimmed.slice(eqIndex + 1).trim()
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1)
|
|
}
|
|
if (key) vars.push({ key, value })
|
|
}
|
|
return vars
|
|
}
|
|
|
|
function writeEnvFile(path: string, vars: EnvVar[]) {
|
|
const dir = dirname(path)
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
|
|
writeFileSync(path, content)
|
|
}
|
|
|
|
// Global env vars
|
|
router.get('/env', c => {
|
|
return c.json(parseEnvFile(globalEnvPath()))
|
|
})
|
|
|
|
router.post('/env', async c => {
|
|
let body: { key?: string, value?: string }
|
|
try {
|
|
body = await c.req.json()
|
|
} catch {
|
|
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
|
|
}
|
|
|
|
const key = body.key?.trim().toUpperCase()
|
|
const value = body.value ?? ''
|
|
|
|
if (!key) return c.json({ ok: false, error: 'Key is required' }, 400)
|
|
|
|
const path = globalEnvPath()
|
|
const vars = parseEnvFile(path)
|
|
const existing = vars.findIndex(v => v.key === key)
|
|
|
|
if (existing >= 0) {
|
|
vars[existing]!.value = value
|
|
} else {
|
|
vars.push({ key, value })
|
|
}
|
|
|
|
writeEnvFile(path, vars)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
router.delete('/env/:key', c => {
|
|
const key = c.req.param('key')
|
|
if (!key) return c.json({ error: 'Key required' }, 400)
|
|
|
|
const path = globalEnvPath()
|
|
const vars = parseEnvFile(path).filter(v => v.key !== key.toUpperCase())
|
|
writeEnvFile(path, vars)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
// App env vars
|
|
router.get('/:app/env', c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
return c.json(parseEnvFile(appEnvPath(appName)))
|
|
})
|
|
|
|
// Set env var for an app
|
|
router.post('/:app/env', async c => {
|
|
const appName = c.req.param('app')
|
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
let body: { key?: string, value?: string }
|
|
try {
|
|
body = await c.req.json()
|
|
} catch {
|
|
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
|
|
}
|
|
|
|
const key = body.key?.trim().toUpperCase()
|
|
const value = body.value ?? ''
|
|
|
|
if (!key) return c.json({ ok: false, error: 'Key is required' }, 400)
|
|
|
|
const path = appEnvPath(appName)
|
|
const vars = parseEnvFile(path)
|
|
const existing = vars.findIndex(v => v.key === key)
|
|
|
|
if (existing >= 0) {
|
|
vars[existing]!.value = value
|
|
} else {
|
|
vars.push({ key, value })
|
|
}
|
|
|
|
writeEnvFile(path, vars)
|
|
|
|
// Restart app to pick up new env
|
|
await restartApp(appName)
|
|
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
// Delete env var for an app
|
|
router.delete('/:app/env/:key', async c => {
|
|
const appName = c.req.param('app')
|
|
const key = c.req.param('key')
|
|
if (!appName || !key) return c.json({ error: 'App and key required' }, 400)
|
|
|
|
const app = allApps().find(a => a.name === appName)
|
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
|
|
|
const path = appEnvPath(appName)
|
|
const vars = parseEnvFile(path).filter(v => v.key !== key.toUpperCase())
|
|
writeEnvFile(path, vars)
|
|
|
|
// Restart app to pick up removed env
|
|
await restartApp(appName)
|
|
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
export default router
|