228 lines
5.7 KiB
TypeScript
228 lines
5.7 KiB
TypeScript
import type { App } from '@types'
|
|
|
|
import * as readline from 'readline'
|
|
|
|
import color from 'kleur'
|
|
|
|
import { get, handleError, HOST, withSignal } from './http'
|
|
import { program } from './setup'
|
|
import { STATE_ICONS } from './commands/manage'
|
|
|
|
let appNamesCache: string[] = []
|
|
let appNamesCacheTime = 0
|
|
|
|
const APP_CACHE_TTL = 5000
|
|
|
|
function tokenize(input: string): string[] {
|
|
const tokens: string[] = []
|
|
let current = ''
|
|
let quote: string | null = null
|
|
|
|
for (const ch of input) {
|
|
if (quote) {
|
|
if (ch === quote) {
|
|
quote = null
|
|
} else {
|
|
current += ch
|
|
}
|
|
} else if (ch === '"' || ch === "'") {
|
|
quote = ch
|
|
} else if (ch === ' ' || ch === '\t') {
|
|
if (current) {
|
|
tokens.push(current)
|
|
current = ''
|
|
}
|
|
} else {
|
|
current += ch
|
|
}
|
|
}
|
|
if (current) tokens.push(current)
|
|
return tokens
|
|
}
|
|
|
|
async function fetchAppNames(): Promise<string[]> {
|
|
const now = Date.now()
|
|
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
|
|
return appNamesCache
|
|
}
|
|
try {
|
|
const apps = await get<App[]>('/api/apps')
|
|
if (apps) {
|
|
appNamesCache = apps.map(a => a.name)
|
|
appNamesCacheTime = now
|
|
}
|
|
} catch {
|
|
// use stale cache
|
|
}
|
|
return appNamesCache
|
|
}
|
|
|
|
function getCommandNames(): string[] {
|
|
return program.commands
|
|
.filter((cmd) => !(cmd as any)._hidden)
|
|
.map((cmd) => cmd.name())
|
|
}
|
|
|
|
async function printBanner(): Promise<void> {
|
|
const apps = await get<App[]>('/api/apps')
|
|
if (!apps) {
|
|
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
|
console.log()
|
|
return
|
|
}
|
|
|
|
// Cache app names from banner fetch
|
|
appNamesCache = apps.map(a => a.name)
|
|
appNamesCacheTime = Date.now()
|
|
|
|
const visibleApps = apps.filter(a => !a.tool)
|
|
|
|
console.log()
|
|
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
|
console.log()
|
|
|
|
// App status line
|
|
const parts = visibleApps.map(a => {
|
|
const icon = STATE_ICONS[a.state] ?? '\u25CB'
|
|
return `${icon} ${a.name}`
|
|
})
|
|
if (parts.length > 0) {
|
|
console.log(' ' + parts.join(' '))
|
|
console.log()
|
|
}
|
|
|
|
const running = visibleApps.filter(a => a.state === 'running').length
|
|
const stopped = visibleApps.filter(a => a.state !== 'running').length
|
|
const summary = []
|
|
if (running) summary.push(`${running} running`)
|
|
if (stopped) summary.push(`${stopped} stopped`)
|
|
if (summary.length > 0) {
|
|
console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`))
|
|
} else {
|
|
console.log(color.gray(' no apps \u2014 type "help" for commands'))
|
|
}
|
|
console.log()
|
|
}
|
|
|
|
export async function shell(): Promise<void> {
|
|
await printBanner()
|
|
|
|
// Configure Commander to throw instead of exiting
|
|
program.exitOverride()
|
|
program.configureOutput({
|
|
writeOut: (str: string) => process.stdout.write(str),
|
|
writeErr: (str: string) => process.stderr.write(str),
|
|
})
|
|
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
prompt: color.cyan('toes> '),
|
|
completer: (line: string, callback: (err: null, result: [string[], string]) => void) => {
|
|
const tokens = tokenize(line)
|
|
const trailing = line.endsWith(' ')
|
|
|
|
if (tokens.length === 0 || (tokens.length === 1 && !trailing)) {
|
|
// Complete command names
|
|
const partial = tokens[0] ?? ''
|
|
const commands = getCommandNames()
|
|
const hits = commands.filter(c => c.startsWith(partial))
|
|
callback(null, [hits, partial])
|
|
} else {
|
|
// Complete app names
|
|
const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '')
|
|
const names = appNamesCache
|
|
const hits = names.filter(n => n.startsWith(partial))
|
|
callback(null, [hits, partial])
|
|
}
|
|
},
|
|
})
|
|
|
|
// Refresh app names cache in background for tab completion
|
|
fetchAppNames()
|
|
|
|
let activeAbort: AbortController | null = null
|
|
|
|
rl.on('SIGINT', () => {
|
|
if (activeAbort) {
|
|
activeAbort.abort()
|
|
activeAbort = null
|
|
console.log()
|
|
rl.prompt()
|
|
} else {
|
|
// Clear current line
|
|
rl.write(null, { ctrl: true, name: 'u' })
|
|
console.log()
|
|
rl.prompt()
|
|
}
|
|
})
|
|
|
|
rl.prompt()
|
|
|
|
for await (const line of rl) {
|
|
const input = line.trim()
|
|
|
|
if (!input) {
|
|
rl.prompt()
|
|
continue
|
|
}
|
|
|
|
if (input === 'exit' || input === 'quit') {
|
|
break
|
|
}
|
|
|
|
if (input === 'clear') {
|
|
console.clear()
|
|
rl.prompt()
|
|
continue
|
|
}
|
|
|
|
if (input === 'help') {
|
|
program.outputHelp()
|
|
rl.prompt()
|
|
continue
|
|
}
|
|
|
|
const tokens = tokenize(input)
|
|
|
|
// Set up AbortController for this command
|
|
activeAbort = new AbortController()
|
|
const signal = activeAbort.signal
|
|
|
|
// Pause readline so commands can use their own prompts
|
|
rl.pause()
|
|
|
|
try {
|
|
await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens]))
|
|
} catch (err: unknown) {
|
|
// Commander throws on exitOverride — suppress help/version exits
|
|
if (err && typeof err === 'object' && 'code' in err) {
|
|
const code = (err as { code: string }).code
|
|
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
|
|
// Already printed, just continue
|
|
} else if (code === 'commander.unknownCommand') {
|
|
console.error(`Unknown command: ${tokens[0]}`)
|
|
} else {
|
|
// Other Commander errors (missing arg, etc.)
|
|
// Commander already printed the error message
|
|
}
|
|
} else if (signal.aborted) {
|
|
// Command was cancelled by Ctrl+C
|
|
} else {
|
|
handleError(err)
|
|
}
|
|
} finally {
|
|
activeAbort = null
|
|
}
|
|
|
|
// Refresh app names cache after commands that might change state
|
|
fetchAppNames()
|
|
|
|
rl.resume()
|
|
rl.prompt()
|
|
}
|
|
|
|
rl.close()
|
|
console.log()
|
|
}
|