import type { App } from '@types' import { generateTemplates, type TemplateType } from '%templates' import { readSyncState } from '%sync' import color from 'kleur' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { basename, join } from 'path' import { del, get, getManifest, HOST, makeAppUrl, post } from '../http' import { confirm, prompt } from '../prompts' import { resolveAppName } from '../name' import { pushApp } from './sync' export const STATE_ICONS: Record = { error: color.red('●'), running: color.green('●'), starting: color.yellow('◎'), stopped: color.gray('◯'), invalid: color.red('◌'), } const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) async function waitForState(name: string, target: string, timeout: number): Promise { const start = Date.now() while (Date.now() - start < timeout) { await sleep(500) const app: App | undefined = await get(`/api/apps/${name}`) if (!app) return undefined if (app.state === target) return target // Terminal failure states — stop polling if (target === 'running' && (app.state === 'stopped' || app.state === 'invalid' || app.state === 'error')) return app.state if (target === 'stopped' && (app.state === 'invalid' || app.state === 'error')) return app.state } // Timed out — return last known state const app: App | undefined = await get(`/api/apps/${name}`) return app?.state } export async function configShow() { console.log(`Host: ${color.bold(HOST)}`) const syncState = readSyncState(process.cwd()) if (syncState) { console.log(`Version: ${color.bold(syncState.version)}`) } } export async function infoApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const app: App | undefined = await get(`/api/apps/${name}`) if (!app) { console.error(`App not found: ${name}`) return } const icon = STATE_ICONS[app.state] ?? '◯' console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`) console.log(` State: ${app.state}`) if (app.port) { console.log(` Port: ${app.port}`) console.log(` URL: ${makeAppUrl(app.port)}`) } if (app.tunnelUrl) { console.log(` Tunnel: ${app.tunnelUrl}`) } if (app.pid) { console.log(` PID: ${app.pid}`) } if (app.started) { const uptime = Date.now() - app.started const seconds = Math.floor(uptime / 1000) % 60 const minutes = Math.floor(uptime / 60000) % 60 const hours = Math.floor(uptime / 3600000) const parts = [] if (hours) parts.push(`${hours}h`) if (minutes) parts.push(`${minutes}m`) parts.push(`${seconds}s`) console.log(` Uptime: ${parts.join(' ')}`) } if (app.error) console.log(` Error: ${color.red(app.error)}`) } interface ListAppsOptions { apps?: boolean tools?: boolean } export async function listApps(options: ListAppsOptions) { const allApps: App[] | undefined = await get('/api/apps') if (!allApps) return if (options.apps || options.tools) { const filtered = allApps.filter((app) => { if (options.tools) return app.tool return !app.tool }) for (const app of filtered) { console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`) } } else { const apps = allApps.filter((app) => !app.tool) const tools = allApps.filter((app) => app.tool) if (tools.length === 0) { // No tools, just list apps without header/indent for (const app of apps) { console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`) } } else { if (apps.length > 0) { console.log('apps:') for (const app of apps) { console.log(` ${STATE_ICONS[app.state] ?? '◯'} ${app.name}`) } console.log() } console.log('tools:') for (const tool of tools) { console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`) } } } } interface NewAppOptions { ssr?: boolean bare?: boolean spa?: boolean } export async function newApp(name: string | undefined, options: NewAppOptions) { const appPath = name ? join(process.cwd(), name) : process.cwd() const appName = name ?? basename(process.cwd()) // Determine template type from flags let template: TemplateType = 'ssr' if (options.bare) template = 'bare' else if (options.spa) template = 'spa' const pkgPath = join(appPath, 'package.json') // If package.json exists, ensure it has scripts.toes and bail if (existsSync(pkgPath)) { const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) if (!pkg.scripts?.toes) { pkg.scripts = pkg.scripts ?? {} pkg.scripts.toes = 'bun start' writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') console.log(color.green('✓ Added scripts.toes to package.json')) } return } if (name && existsSync(appPath)) { console.error(`Directory already exists: ${name}`) return } const filesToCheck = ['index.tsx', 'tsconfig.json'] const existing = filesToCheck.filter((f) => existsSync(join(appPath, f))) if (existing.length > 0) { console.error(`Files already exist: ${existing.join(', ')}`) return } const templateLabel = template === 'ssr' ? '' : ` (${template})` const ok = await confirm(`Create ${color.bold(appName)}${templateLabel} in ${appPath}?`) if (!ok) return const templates = generateTemplates(appName, template) // Create directories for all template files for (const filename of Object.keys(templates)) { const dir = join(appPath, filename, '..') mkdirSync(dir, { recursive: true }) } for (const [filename, content] of Object.entries(templates)) { writeFileSync(join(appPath, filename), content) } process.chdir(appPath) await pushApp() 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') } export async function openApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const app: App | undefined = await get(`/api/apps/${name}`) if (!app) { console.error(`App not found: ${name}`) return } if (app.state !== 'running') { console.error(`App is not running: ${name}`) return } const url = makeAppUrl(app.port!) console.log(`Opening ${url}`) Bun.spawn(['open', url]) } export async function shareApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${name}/tunnel`) if (!result) return if (!result.ok) { console.error(color.red(result.error ?? 'Failed to share')) return } process.stdout.write(`${color.cyan('↗')} Sharing ${color.bold(name)}...`) // Poll until tunnelUrl appears const start = Date.now() while (Date.now() - start < 15000) { await sleep(500) const app: App | undefined = await get(`/api/apps/${name}`) if (app?.tunnelUrl) { console.log(` ${color.cyan(app.tunnelUrl)}`) return } } console.log(` ${color.yellow('enabled (URL pending)')}`) } export async function renameApp(arg: string | undefined, newName: string) { const name = resolveAppName(arg) if (!name) return const result = await getManifest(name) if (result === null) return if (!result.exists) { console.error(`App not found on server: ${name}`) return } const expected = `sudo rename ${name} ${newName}` console.log(`This will rename ${color.bold(name)} to ${color.bold(newName)}.`) const answer = await prompt(`Type "${expected}" to confirm: `) if (answer !== expected) { console.log('Aborted.') return } const response = await post<{ ok: boolean, error?: string, name?: string }>(`/api/apps/${name}/rename`, { name: newName }) if (!response) return if (!response.ok) { console.error(color.red(`Error: ${response.error}`)) return } console.log(color.green(`✓ Renamed ${name} to ${response.name}`)) } export async function restartApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const result = await post(`/api/apps/${name}/restart`) if (!result) return process.stdout.write(`${color.yellow('↻')} Restarting ${color.bold(name)}...`) const state = await waitForState(name, 'running', 15000) if (state === 'running') { console.log(` ${color.green('running')}`) } else { console.log(` ${color.red(state ?? 'unknown')}`) } } export async function rmApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const result = await getManifest(name) if (result === null) return if (!result.exists) { console.error(`App not found on server: ${name}`) return } const expected = `sudo rm ${name}` console.log(`This will ${color.red('permanently delete')} ${color.bold(name)} from the server.`) const answer = await prompt(`Type "${expected}" to confirm: `) if (answer !== expected) { console.log('Aborted.') return } const success = await del(`/api/sync/apps/${name}`) if (success) { console.log(color.green(`✓ Removed ${name}`)) } } export async function startApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const result = await post(`/api/apps/${name}/start`) if (!result) return process.stdout.write(`${color.green('▶')} Starting ${color.bold(name)}...`) const state = await waitForState(name, 'running', 15000) if (state === 'running') { console.log(` ${color.green('running')}`) } else { console.log(` ${color.red(state ?? 'unknown')}`) } } export async function unshareApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const result = await del(`/api/apps/${name}/tunnel`) if (!result) return console.log(`${color.gray('↗')} Unshared ${color.bold(name)}`) } export async function stopApp(arg?: string) { const name = resolveAppName(arg) if (!name) return const result = await post(`/api/apps/${name}/stop`) if (!result) return process.stdout.write(`${color.red('■')} Stopping ${color.bold(name)}...`) const state = await waitForState(name, 'stopped', 10000) if (state === 'stopped') { console.log(` ${color.gray('stopped')}`) } else { console.log(` ${color.yellow(state ?? 'unknown')}`) } }