diff --git a/package.json b/package.json index e476a9b..7851827 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.3", - "commander": "^14.0.3", + "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" } diff --git a/scripts/setup-ssh.sh b/scripts/setup-ssh.sh new file mode 100755 index 0000000..b22b85b --- /dev/null +++ b/scripts/setup-ssh.sh @@ -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" <> /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" diff --git a/src/cli/commands/cron.ts b/src/cli/commands/cron.ts index 2f82ad8..72912eb 100644 --- a/src/cli/commands/cron.ts +++ b/src/cli/commands/cron.ts @@ -1,6 +1,6 @@ import type { LogLine } from '@types' import color from 'kleur' -import { get, handleError, makeUrl, post } from '../http' +import { get, getSignal, handleError, makeUrl, post } from '../http' import { resolveAppName } from '../name' interface CronJobSummary { @@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) => async function tailCronLogs(app: string, grep?: string) { try { const url = makeUrl(`/api/apps/${app}/logs/stream`) - const res = await fetch(url) + const res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`App not found: ${app}`) return diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index 23798d0..81f0655 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -1,5 +1,5 @@ import type { LogLine } from '@types' -import { get, handleError, makeUrl } from '../http' +import { get, getSignal, handleError, makeUrl } from '../http' import { resolveAppName } from '../name' interface LogOptions { @@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) { export async function tailLogs(name: string, grep?: string) { try { const url = makeUrl(`/api/apps/${name}/logs/stream`) - const res = await fetch(url) + const res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`App not found: ${name}`) return diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index d5b0e09..178e13d 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -5,7 +5,7 @@ import color from 'kleur' import { diffLines } from 'diff' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' 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 { getAppName, getAppPackage, isApp, resolveAppName } from '../name' @@ -592,7 +592,7 @@ export async function syncApp() { const url = makeUrl(`/api/sync/apps/${appName}/watch`) let res: Response try { - res = await fetch(url) + res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) watcher.close() @@ -967,7 +967,7 @@ interface VersionsResponse { async function getVersions(appName: string): Promise { 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) { console.error(`App not found: ${appName}`) return null diff --git a/src/cli/http.ts b/src/cli/http.ts index 85a642f..a955b03 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -1,5 +1,8 @@ import type { Manifest } from '@types' +import { AsyncLocalStorage } from 'node:async_hooks' + const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local' +const signalStore = new AsyncLocalStorage() const normalizeUrl = (url: string) => 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) : DEFAULT_HOST +export const getSignal = () => signalStore.getStore() + +export function withSignal(signal: AbortSignal, fn: () => T): T { + return signalStore.run(signal, fn) +} + export function makeUrl(path: string): string { return `${HOST}${path}` } @@ -36,7 +45,7 @@ export function handleError(error: unknown): void { export async function get(url: string): Promise { try { - const res = await fetch(makeUrl(url)) + const res = await fetch(makeUrl(url), { signal: getSignal() }) if (!res.ok) { const text = await res.text() const msg = tryParseError(text) ?? `${res.status} ${res.statusText}` @@ -50,7 +59,7 @@ export async function get(url: string): Promise { export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> { 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.ok) throw new Error(`${res.status} ${res.statusText}`) const data = await res.json() @@ -68,6 +77,7 @@ export async function post(url: string, body?: B): Promise { try { const fullUrl = makeUrl(url) - const res = await fetch(fullUrl) + const res = await fetch(fullUrl, { signal: getSignal() }) if (!res.ok) { const text = await res.text() const msg = tryParseError(text) ?? `${res.status} ${res.statusText}` @@ -117,6 +128,7 @@ export async function del(url: string): Promise { try { const res = await fetch(makeUrl(url), { method: 'DELETE', + signal: getSignal(), }) if (!res.ok) { const text = await res.text() diff --git a/src/cli/index.ts b/src/cli/index.ts index b60b2f7..1a16599 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,13 @@ #!/usr/bin/env bun 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() +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 643cc71..46279e4 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -322,4 +322,14 @@ program .option('-v, --version ', 'version to rollback to (prompts if omitted)') .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 } diff --git a/src/cli/shell.ts b/src/cli/shell.ts new file mode 100644 index 0000000..904325d --- /dev/null +++ b/src/cli/shell.ts @@ -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 { + 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: { _hidden?: boolean }) => !cmd._hidden) + .map((cmd: { name: () => string }) => 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() +}