Merge branch 'ssh-cli-auto'

This commit is contained in:
Chris Wanstrath 2026-02-28 23:04:54 -08:00
commit 7ee9163f76
9 changed files with 335 additions and 12 deletions

View File

@ -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
View 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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()
}

View File

@ -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
View 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()
}