/** * CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`. * All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO. * `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9). */ import type { AgentConfig } from './config/agentConfig.js' import type { AgentIdentity } from './keys/identity.js' import type { Keystore } from './keys/keystore.js' import type { InstallOptions } from './service/install.js' import type { EnrollResult } from 'relay-contracts' export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall' const COMMANDS: readonly CliCommand[] = ['pair', 'run', 'status', 'install', 'uninstall'] export interface CliArgs { readonly command: CliCommand readonly code?: string readonly flags: Readonly> } export class CliUsageError extends Error { constructor(message: string) { super(message) this.name = 'CliUsageError' } } export interface CliDeps { loadConfig(): AgentConfig openKeystore(stateDir: string): Keystore generateIdentity(): AgentIdentity redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise runTunnel(cfg: AgentConfig, ks: Keystore): Promise /** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */ resolveInstallOptions(): InstallOptions installService(cfg: AgentConfig, options: InstallOptions): Promise uninstallService(): Promise print(line: string): void } /** Parse argv (already sliced past node/script) into a typed CliArgs. */ export function parseArgs(argv: readonly string[]): CliArgs { const [command, ...rest] = argv if (command === undefined || !COMMANDS.includes(command as CliCommand)) { throw new CliUsageError(`unknown command '${command ?? ''}' (expected ${COMMANDS.join(' | ')})`) } const flags: Record = {} const positionals: string[] = [] for (const token of rest) { if (token.startsWith('--')) { const [k, v] = token.slice(2).split('=') flags[k!] = v ?? true } else { positionals.push(token) } } const args: CliArgs = { command: command as CliCommand, flags } if (command === 'pair') { const code = positionals[0] if (code === undefined) throw new CliUsageError('usage: web-terminal-agent pair ') return { ...args, code } } return args } /** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */ export async function runCli(args: CliArgs, deps: CliDeps): Promise { const cfg = deps.loadConfig() const ks = deps.openKeystore(cfg.stateDir) switch (args.command) { case 'pair': { const id = ks.loadIdentity() ?? deps.generateIdentity() ks.saveIdentity(id) const enroll = await deps.redeem(cfg, args.code!, id, ks) deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`) if (args.flags['install']) await deps.installService(cfg, deps.resolveInstallOptions()) return 0 } case 'run': { if (ks.loadIdentity() === null || ks.loadCert() === null) { throw new CliUsageError('not enrolled — run `web-terminal-agent pair ` first') } return deps.runTunnel(cfg, ks) } case 'status': { const enrolled = ks.loadIdentity() !== null && ks.loadCert() !== null // INV9: print only non-secret identifiers. deps.print(`enrolled: ${enrolled}`) deps.print(`host_id: ${cfg.hostId ?? '(none)'}`) deps.print(`subdomain: ${cfg.subdomain ?? '(none)'}`) return 0 } case 'install': await deps.installService(cfg, deps.resolveInstallOptions()) return 0 case 'uninstall': await deps.uninstallService() return 0 } }