345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
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<string, string> = {
|
|
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<string | undefined> {
|
|
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')}`)
|
|
}
|
|
}
|
|
|