toes/src/cli/commands/manage.ts

262 lines
7.1 KiB
TypeScript

import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import color from 'kleur'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { basename, join } from 'path'
import { del, get, getManifest, HOST, post } from '../http'
import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = {
running: color.green('●'),
starting: color.yellow('◎'),
stopped: color.gray('◯'),
invalid: color.red('◌'),
}
export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`)
const source = process.env.TOES_URL
? 'TOES_URL'
: process.env.TOES_HOST
? 'TOES_HOST' + (process.env.PORT ? ' + PORT' : '')
: process.env.NODE_ENV === 'production'
? 'default (production)'
: 'default (development)'
console.log(`Source: ${color.gray(source)}`)
if (process.env.TOES_URL) {
console.log(` TOES_URL=${process.env.TOES_URL}`)
}
if (process.env.TOES_HOST) {
console.log(` TOES_HOST=${process.env.TOES_HOST}`)
}
if (process.env.PORT) {
console.log(` PORT=${process.env.PORT}`)
}
if (process.env.NODE_ENV) {
console.log(` NODE_ENV=${process.env.NODE_ENV}`)
}
}
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: http://localhost:${app.port}`)
}
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 {
tools?: boolean
all?: boolean
}
export async function listApps(options: ListAppsOptions) {
const allApps: App[] | undefined = await get('/api/apps')
if (!allApps) return
if (options.all) {
const apps = allApps.filter((app) => !app.tool)
const tools = allApps.filter((app) => app.tool)
if (apps.length > 0) {
console.log('apps:')
for (const app of apps) {
console.log(` ${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
}
}
if (tools.length > 0) {
if (apps.length > 0) console.log()
console.log('tools:')
for (const tool of tools) {
console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`)
}
}
} else {
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}`)
}
}
}
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'
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 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 = `http://localhost:${app.port}`
console.log(`Opening ${url}`)
Bun.spawn(['open', url])
}
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
await post(`/api/apps/${name}/restart`)
}
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
await post(`/api/apps/${name}/start`)
}
export async function stopApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/stop`)
}