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 { const now = Date.now() if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) { return appNamesCache } try { const apps = await get('/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 { const apps = await get('/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 { 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() }