Merge branch 'ssh-cli-auto'
This commit is contained in:
commit
7ee9163f76
|
|
@ -45,7 +45,7 @@
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.3",
|
"@because/sneaker": "^0.0.3",
|
||||||
"commander": "^14.0.3",
|
"commander": "14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
scripts/setup-ssh.sh
Executable file
65
scripts/setup-ssh.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# setup-ssh.sh - Configure SSH for the toes CLI user
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
|
||||||
|
# 2. Sets an empty password on `cli` for passwordless SSH
|
||||||
|
# 3. Adds a Match block in sshd_config to allow empty passwords for `cli`
|
||||||
|
# 4. Adds /usr/local/bin/toes to /etc/shells
|
||||||
|
# 5. Restarts sshd
|
||||||
|
#
|
||||||
|
# Run as root on the toes machine.
|
||||||
|
# Usage: ssh cli@toes.local
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOES_SHELL="/usr/local/bin/toes"
|
||||||
|
SSHD_CONFIG="/etc/ssh/sshd_config"
|
||||||
|
|
||||||
|
echo "==> Setting up SSH CLI user for toes"
|
||||||
|
|
||||||
|
# 1. Create cli system user
|
||||||
|
if ! id cli &>/dev/null; then
|
||||||
|
useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli
|
||||||
|
echo " Created cli user"
|
||||||
|
else
|
||||||
|
echo " cli user already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Set empty password
|
||||||
|
passwd -d cli
|
||||||
|
echo " Set empty password on cli"
|
||||||
|
|
||||||
|
# 3. Add Match block for cli user in sshd_config
|
||||||
|
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
|
||||||
|
cat >> "$SSHD_CONFIG" <<EOF
|
||||||
|
|
||||||
|
# toes CLI: allow passwordless SSH for the cli user
|
||||||
|
Match User cli
|
||||||
|
PermitEmptyPasswords yes
|
||||||
|
EOF
|
||||||
|
echo " Added Match User cli block to sshd_config"
|
||||||
|
else
|
||||||
|
echo " sshd_config already has Match User cli block"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Ensure /usr/local/bin/toes is in /etc/shells
|
||||||
|
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
|
||||||
|
echo "$TOES_SHELL" >> /etc/shells
|
||||||
|
echo " Added $TOES_SHELL to /etc/shells"
|
||||||
|
else
|
||||||
|
echo " $TOES_SHELL already in /etc/shells"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if toes binary doesn't exist yet
|
||||||
|
if [ ! -f "$TOES_SHELL" ]; then
|
||||||
|
echo " WARNING: $TOES_SHELL does not exist yet"
|
||||||
|
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Restart sshd
|
||||||
|
echo " Restarting sshd..."
|
||||||
|
systemctl restart sshd || service ssh restart || true
|
||||||
|
|
||||||
|
echo "==> Done. Connect with: ssh cli@toes.local"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { get, handleError, makeUrl, post } from '../http'
|
import { get, getSignal, handleError, makeUrl, post } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface CronJobSummary {
|
interface CronJobSummary {
|
||||||
|
|
@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) =>
|
||||||
async function tailCronLogs(app: string, grep?: string) {
|
async function tailCronLogs(app: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${app}`)
|
console.error(`App not found: ${app}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import { get, handleError, makeUrl } from '../http'
|
import { get, getSignal, handleError, makeUrl } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface LogOptions {
|
interface LogOptions {
|
||||||
|
|
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
|
||||||
export async function tailLogs(name: string, grep?: string) {
|
export async function tailLogs(name: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import color from 'kleur'
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
import { del, download, get, getManifest, getSignal, handleError, makeUrl, post, put } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
||||||
|
|
||||||
|
|
@ -592,7 +592,7 @@ export async function syncApp() {
|
||||||
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(url)
|
res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
|
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
|
||||||
watcher.close()
|
watcher.close()
|
||||||
|
|
@ -967,7 +967,7 @@ interface VersionsResponse {
|
||||||
|
|
||||||
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() })
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
console.error(`App not found: ${appName}`)
|
console.error(`App not found: ${appName}`)
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||||
|
const signalStore = new AsyncLocalStorage<AbortSignal>()
|
||||||
|
|
||||||
const normalizeUrl = (url: string) =>
|
const normalizeUrl = (url: string) =>
|
||||||
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
||||||
|
|
@ -17,6 +20,12 @@ export const HOST = process.env.TOES_URL
|
||||||
? normalizeUrl(process.env.TOES_URL)
|
? normalizeUrl(process.env.TOES_URL)
|
||||||
: DEFAULT_HOST
|
: DEFAULT_HOST
|
||||||
|
|
||||||
|
export const getSignal = () => signalStore.getStore()
|
||||||
|
|
||||||
|
export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
|
||||||
|
return signalStore.run(signal, fn)
|
||||||
|
}
|
||||||
|
|
||||||
export function makeUrl(path: string): string {
|
export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +45,7 @@ export function handleError(error: unknown): void {
|
||||||
|
|
||||||
export async function get<T>(url: string): Promise<T | undefined> {
|
export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url))
|
const res = await fetch(makeUrl(url), { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
|
@ -50,7 +59,7 @@ export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
|
|
||||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -68,6 +77,7 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -85,6 +95,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: body as BodyInit,
|
body: body as BodyInit,
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -101,7 +112,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
export async function download(url: string): Promise<Buffer | undefined> {
|
export async function download(url: string): Promise<Buffer | undefined> {
|
||||||
try {
|
try {
|
||||||
const fullUrl = makeUrl(url)
|
const fullUrl = makeUrl(url)
|
||||||
const res = await fetch(fullUrl)
|
const res = await fetch(fullUrl, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
|
@ -117,6 +128,7 @@ export async function del(url: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { program } from './setup'
|
import { program } from './setup'
|
||||||
|
|
||||||
program.parse()
|
const isCliUser = process.env.USER === 'cli'
|
||||||
|
const noArgs = process.argv.length <= 2
|
||||||
|
const isTTY = !!process.stdin.isTTY
|
||||||
|
|
||||||
|
if (isCliUser && noArgs && isTTY) {
|
||||||
|
const { shell } = await import('./shell')
|
||||||
|
await shell()
|
||||||
|
} else {
|
||||||
|
program.parse()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,4 +322,14 @@ program
|
||||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
.action((name, options) => rollbackApp(name, options.version))
|
||||||
|
|
||||||
|
// Shell
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('shell')
|
||||||
|
.description('Interactive shell')
|
||||||
|
.action(async () => {
|
||||||
|
const { shell } = await import('./shell')
|
||||||
|
await shell()
|
||||||
|
})
|
||||||
|
|
||||||
export { program }
|
export { program }
|
||||||
|
|
|
||||||
227
src/cli/shell.ts
Normal file
227
src/cli/shell.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
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: { _hidden?: boolean }) => !cmd._hidden)
|
||||||
|
.map((cmd: { name: () => string }) => 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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user