Early-return on invalid input, then unify the GET/POST and display paths so each concern is handled once instead of per-subcommand. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
7.1 KiB
TypeScript
269 lines
7.1 KiB
TypeScript
import { program } from 'commander'
|
|
|
|
import color from 'ansis'
|
|
|
|
import pkg from '../../package.json'
|
|
import { SHA } from './sha'
|
|
import {
|
|
cronList,
|
|
cronLog,
|
|
cronRun,
|
|
cronStatus,
|
|
envList,
|
|
envRm,
|
|
envSet,
|
|
getApp,
|
|
infoApp,
|
|
listApps,
|
|
logApp,
|
|
metricsApp,
|
|
newApp,
|
|
openApp,
|
|
perfToggle,
|
|
renameApp,
|
|
restartApp,
|
|
rmApp,
|
|
shareApp,
|
|
startApp,
|
|
stopApp,
|
|
unshareApp,
|
|
} from './commands'
|
|
|
|
program
|
|
.name('toes')
|
|
.version(`v${pkg.version}-${SHA}`, '-v, --version')
|
|
.addHelpText('beforeAll', (ctx) => {
|
|
if (ctx.command === program) {
|
|
return color.bold.cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
|
|
}
|
|
return ''
|
|
})
|
|
.addHelpCommand(false)
|
|
.configureOutput({
|
|
writeOut: (str) => {
|
|
const colored = str
|
|
.replace(/^([A-Z][\w ]*:)/gm, color.yellow('$1'))
|
|
process.stdout.write(colored)
|
|
},
|
|
})
|
|
|
|
program
|
|
.command('version', { hidden: true })
|
|
.action(() => console.log(program.version()))
|
|
|
|
// Apps
|
|
|
|
program
|
|
.command('status')
|
|
.helpGroup('Apps:')
|
|
.description('Show status of all apps, or details for a specific app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.option('-t, --tools', 'show only tools')
|
|
.option('-a, --apps', 'show only apps (exclude tools)')
|
|
.action((name?: string, options?: { apps?: boolean; tools?: boolean }) => {
|
|
if (name) return infoApp(name)
|
|
return listApps(options ?? {})
|
|
})
|
|
|
|
program
|
|
.command('list', { hidden: true })
|
|
.option('-t, --tools', 'show only tools')
|
|
.option('-a, --apps', 'show only apps (exclude tools)')
|
|
.action(listApps)
|
|
|
|
program
|
|
.command('info', { hidden: true })
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(infoApp)
|
|
|
|
program
|
|
.command('new')
|
|
.helpGroup('Apps:')
|
|
.description('Create a new toes app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.option('--ssr', 'SSR template with pages directory (default)')
|
|
.option('--bare', 'minimal template with no pages')
|
|
.option('--spa', 'single-page app with client-side rendering')
|
|
.action(newApp)
|
|
|
|
program
|
|
.command('get')
|
|
.helpGroup('Apps:')
|
|
.description('Clone an app from the server')
|
|
.argument('<name>', 'app name')
|
|
.argument('[directory]', 'target directory (defaults to app name)')
|
|
.action(getApp)
|
|
|
|
program
|
|
.command('open')
|
|
.helpGroup('Apps:')
|
|
.description('Open an app in browser')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(openApp)
|
|
|
|
program
|
|
.command('rename')
|
|
.helpGroup('Apps:')
|
|
.description('Rename an app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.argument('<new-name>', 'new app name')
|
|
.action(renameApp)
|
|
|
|
program
|
|
.command('rm')
|
|
.helpGroup('Apps:')
|
|
.description('Remove an app from the server')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(rmApp)
|
|
|
|
// Lifecycle
|
|
|
|
program
|
|
.command('start')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Start an app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(startApp)
|
|
|
|
program
|
|
.command('stop')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Stop an app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(stopApp)
|
|
|
|
program
|
|
.command('restart')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Restart an app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(restartApp)
|
|
|
|
program
|
|
.command('share')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Share an app via public tunnel')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(shareApp)
|
|
|
|
program
|
|
.command('unshare')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Stop sharing an app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(unshareApp)
|
|
|
|
program
|
|
.command('logs')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Show logs for an app')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.option('-f, --follow', 'follow log output')
|
|
.option('-d, --date <date>', 'show logs from a specific date (YYYY-MM-DD)')
|
|
.option('-s, --since <duration>', 'show logs since duration (e.g., 1h, 2d)')
|
|
.option('-g, --grep <pattern>', 'filter logs by pattern')
|
|
.action(logApp)
|
|
|
|
program
|
|
.command('log', { hidden: true })
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.option('-f, --follow', 'follow log output')
|
|
.option('-d, --date <date>', 'show logs from a specific date (YYYY-MM-DD)')
|
|
.option('-s, --since <duration>', 'show logs since duration (e.g., 1h, 2d)')
|
|
.option('-g, --grep <pattern>', 'filter logs by pattern')
|
|
.action(logApp)
|
|
|
|
program
|
|
.command('metrics')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Show CPU, memory, and disk metrics for apps')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.action(metricsApp)
|
|
|
|
const cron = program
|
|
.command('cron')
|
|
.helpGroup('Lifecycle:')
|
|
.description('Manage cron jobs')
|
|
.argument('[app]', 'app name (list jobs for specific app)')
|
|
.action(cronList)
|
|
|
|
cron
|
|
.command('log')
|
|
.description('Show cron job logs')
|
|
.argument('[target]', 'app name or job (app:name)')
|
|
.option('-f, --follow', 'follow log output')
|
|
.action(cronLog)
|
|
|
|
cron
|
|
.command('status')
|
|
.description('Show detailed status for a job')
|
|
.argument('<job>', 'job identifier (app:name)')
|
|
.action(cronStatus)
|
|
|
|
cron
|
|
.command('run')
|
|
.description('Run a job immediately')
|
|
.argument('<job>', 'job identifier (app:name)')
|
|
.action(cronRun)
|
|
|
|
// Config
|
|
|
|
const env = program
|
|
.command('env')
|
|
.helpGroup('Config:')
|
|
.description('Manage environment variables')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.option('-g, --global', 'manage global variables shared by all apps')
|
|
.action(envList)
|
|
|
|
env
|
|
.command('set')
|
|
.description('Set an environment variable')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.argument('<key>', 'variable name')
|
|
.argument('[value]', 'variable value (or use KEY=value format)')
|
|
.option('-g, --global', 'set a global variable shared by all apps')
|
|
.action(envSet)
|
|
|
|
env
|
|
.command('rm')
|
|
.description('Remove an environment variable')
|
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
.argument('<key>', 'variable name to remove')
|
|
.option('-g, --global', 'remove a global variable')
|
|
.action(envRm)
|
|
|
|
program
|
|
.command('perf')
|
|
.helpGroup('Config:')
|
|
.description('Toggle request timing for proxied app requests')
|
|
.argument('[on|off|status]', 'enable, disable, or check status (toggles if omitted)')
|
|
.action(perfToggle)
|
|
|
|
// Shell
|
|
|
|
program
|
|
.command('shell')
|
|
.description('Interactive shell')
|
|
.action(async () => {
|
|
const { shell } = await import('./shell')
|
|
await shell()
|
|
})
|
|
|
|
// Hide and disable commands that don't work over SSH
|
|
if (process.env.USER === 'cli') {
|
|
const disabled = ['shell', 'get', 'open']
|
|
for (const name of disabled) {
|
|
const cmd = program.commands.find((c) => c.name() === name)
|
|
if (!cmd) continue
|
|
cmd.helpInformation = () => ''
|
|
;(cmd as any)._hidden = true
|
|
cmd.action(() => {
|
|
console.error(`"${name}" is not available over SSH`)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
}
|
|
|
|
export { program }
|