/** * Terminal-responder: when a deploy runs on a TTY, this subscribes * to `config.required.*`, `secret.required.*`, and `ensure.required.*` * events and prompts the operator on the terminal for each one, * replying on the bus. * * Just one of several responder shapes — the bus query/reply path is * a race, and the terminal is one racer. Other racers (Claude * subagent, `celilo events respond` from another shell, autoresponder * daemon) compete for the same events; first reply wins. * * The terminal-responder doesn't try to detect "another responder * just won the race while my user was typing." For Stage 1, if the * user types after losing, their reply is a stale-reply and the * deploy already moved on. The user sees their prompt close and the * deploy continues with the winning value (logged by the deploy in * its normal output). Race-during-typing detection is a follow-up. * * Secret + ensure values never cross the bus. The responder writes * them directly to the encrypted store via the same helpers the * deploy uses, then replies with `{ acknowledged: true }`. Other * responder shapes (Claude subagent, separate-shell `events respond`) * achieve the same out-of-band injection by shelling out to * `celilo module secret set`; the in-process terminal-responder takes * the shorter path of calling the helpers directly. */ import { hostname } from 'node:os'; import { confirm, isCancel, multiselect, select } from '@celilo/cli-display'; import { type Bus, openBus } from '@celilo/event-bus'; import { defineEvents } from '@celilo/event-bus'; import { log, promptPassword, promptText } from '../cli/prompts'; import { getEventBusPath } from '../config/paths'; import { getDb } from '../db/client'; import { generateSecret } from '../secrets/generators'; import { getOrCreateMasterKey } from '../secrets/master-key'; import type { ConfigRequiredPayload, EnsureRequiredPayload, InterviewRequiredPayload, SecretRequiredPayload, } from './bus-interview'; import { readModuleSecretKey, writeModuleSecretKey } from './config-interview'; const NO_SCHEMAS = defineEvents({}); /** * Identifies this responder in the bus's `emittedBy` audit field. * The deploy's audit log shows "config.required.lunacycle.domain → * answered by terminal:hostname" so operators can correlate which * shell answered which prompt. */ function responderId(): string { return `terminal:${hostname()}:${process.pid}`; } export interface TerminalResponderHandle { /** Stop watching for events. Doesn't cancel any in-flight prompt. */ close(): void; } /** * Register transient subscriptions on the three interview event * families. For each event, prompt the operator on the terminal, optionally * inject the secret value out-of-band, and reply on the bus. * * Returns a handle the caller can close() when the deploy completes. */ export function startTerminalResponder(): TerminalResponderHandle { const bus: Bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS }); const me = responderId(); // Track which queries we've already started a prompt for, so a // re-fire (e.g. validation re-emit) doesn't double-prompt. Shared // across the three watches by event id (ids are globally unique // within the events table). const handled = new Set(); // Pattern matches exactly `config.required..` (4 dot // segments). The reply we emit is `config.required...reply` // (5 segments) — wouldn't match — but `**` would have matched, and // we'd recursively try to prompt for our own reply event. The // explicit replyFor === null check below is defense in depth in // case any responder ever emits a reply with the same shape. const configWatch = bus.watch('config.required.*.*', async (event) => { if (event.replyFor !== null) return; if (handled.has(event.id)) return; handled.add(event.id); const payload = event.payload as ConfigRequiredPayload; if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') { log.warn( `Terminal responder skipped malformed event ${event.type} (id ${event.id}): missing module/key`, ); return; } const message = payload.description ? `${payload.module}.${payload.key} — ${payload.description}:` : `${payload.module}.${payload.key}:`; let coerced: unknown; if (payload.options && payload.options.length > 0) { // Multi-select prompt for vars with options[] declared in the // manifest. multiselect returns an array of selected values // directly — no JSON-typing to coerce. const selected = await multiselect({ message, options: payload.options.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint, })), required: payload.required, }); if (isCancel(selected)) { log.warn( `Terminal responder: cancelled prompt for ${payload.module}.${payload.key}; no reply emitted`, ); return; } coerced = selected as unknown[]; } else { // Free-text prompt with type-shape validation at submit-time. const typeHint = describeTypeHint(payload.type); const value = await promptText({ message: typeHint ? `${message} (${typeHint})` : message, validate: (val) => { if (payload.required && (!val || val.trim() === '')) { return 'This field is required'; } if (payload.pattern && val) { const re = new RegExp(payload.pattern); if (!re.test(val)) return `Value must match: ${payload.pattern}`; } try { coerceValue(val, payload.type); } catch (err) { return err instanceof Error ? err.message : String(err); } }, }); coerced = coerceValue(value, payload.type); } bus.emitRaw( `${event.type}.reply`, { value: coerced }, { replyFor: event.id, emittedBy: me, }, ); }); const secretWatch = bus.watch('secret.required.*.*', async (event) => { if (event.replyFor !== null) return; if (handled.has(event.id)) return; handled.add(event.id); const payload = event.payload as SecretRequiredPayload; if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') { log.warn( `Terminal responder skipped malformed event ${event.type} (id ${event.id}): missing module/key`, ); return; } const value = await promptForSecret(payload); if (value === undefined) { // User cancelled; don't reply. log.warn( `Terminal responder: cancelled prompt for ${payload.module}.${payload.key}; no reply emitted`, ); return; } try { const db = getDb(); const masterKey = await getOrCreateMasterKey(); await writeModuleSecretKey(payload.module, payload.key, value, db, masterKey); } catch (err) { log.error( `Terminal responder failed to write secret ${payload.module}.${payload.key}: ${ err instanceof Error ? err.message : String(err) }`, ); return; } bus.emitRaw( `${event.type}.reply`, { acknowledged: true }, { replyFor: event.id, emittedBy: me, }, ); }); const ensureWatch = bus.watch('ensure.required.*.*', async (event) => { if (event.replyFor !== null) return; if (handled.has(event.id)) return; handled.add(event.id); const payload = event.payload as EnsureRequiredPayload; if (!payload || typeof payload.provider !== 'string' || !Array.isArray(payload.inputs)) { log.warn(`Terminal responder skipped malformed ensure event ${event.type} (id ${event.id})`); return; } const values: Record = {}; let acknowledged = false; let cancelled = false; let masterKey: Buffer | null = null; for (const input of payload.inputs) { const message = input.hint ? `${input.prompt}\n ${input.hint}` : input.prompt; const userValue = await promptText({ message }); if (userValue === undefined || userValue.trim() === '') { // Treat empty/cancel as cancellation; don't reply with a // half-filled values object. cancelled = true; break; } if (input.target.startsWith('config.')) { // The deploy applies the read-merge-write on the config side // using the value we hand back here. Keyed by full target; // the deploy already knows objectKey from the input payload // it sent us, so we don't echo it. values[input.target] = userValue; continue; } // Secret target: read-merge-write the secret in-process so the // value never crosses the bus. The deploy will re-read after // the ack, so it sees the merged object. const name = input.target.slice('secret.'.length); const db = getDb(); if (!masterKey) masterKey = await getOrCreateMasterKey(); let obj: Record = {}; const currentRaw = await readModuleSecretKey(payload.provider, name, db, masterKey); if (currentRaw) { try { const parsed = JSON.parse(currentRaw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { obj = parsed as Record; } } catch { // Existing secret isn't a JSON object — overwrite (the // schema declares this secret as an object, so any other // shape is stale). } } obj[input.objectKey] = userValue; try { await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), db, masterKey); acknowledged = true; } catch (err) { log.error( `Terminal responder failed to write secret ${payload.provider}.${name}: ${ err instanceof Error ? err.message : String(err) }`, ); cancelled = true; break; } } if (cancelled) { log.warn( `Terminal responder: cancelled ensure ${payload.provider}.${payload.ensureId}; no reply emitted`, ); return; } bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, { replyFor: event.id, emittedBy: me, }); }); // Generic interview family (ISS-0127). Non-deploy commands (e.g. // `service reconfigure`) ask their questions here so they're driveable // over the bus like a deploy. Renders by `kind`. Like the other watches, // we ignore reply events (replyFor !== null) and de-dupe by event id. const interviewWatch = bus.watch('interview.required.*.*', async (event) => { if (event.replyFor !== null) return; if (handled.has(event.id)) return; handled.add(event.id); const payload = event.payload as InterviewRequiredPayload; if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') { log.warn( `Terminal responder skipped malformed interview event ${event.type} (id ${event.id}): missing scope/key`, ); return; } const message = payload.description ? `${payload.message} — ${payload.description}` : payload.message; let value: unknown; if (payload.kind === 'confirm') { const answer = await confirm({ message, initialValue: payload.defaultValue === 'true', }); if (isCancel(answer)) { log.warn( `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`, ); return; } value = answer; } else if (payload.kind === 'select') { const answer = await select({ message, options: (payload.options ?? []).map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint, })), initialValue: payload.defaultValue, }); if (isCancel(answer)) { log.warn( `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`, ); return; } value = answer; } else if (payload.kind === 'multiselect') { const answer = await multiselect({ message, options: (payload.options ?? []).map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint, })), required: payload.required, }); if (isCancel(answer)) { log.warn( `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`, ); return; } value = answer; } else { // kind === 'text' const type = payload.type ?? 'string'; const typeHint = describeTypeHint(type); const answer = await promptText({ message: typeHint ? `${message} (${typeHint})` : message, defaultValue: payload.defaultValue, placeholder: payload.placeholder, validate: (val) => { if (payload.required && (!val || val.trim() === '')) { return 'This field is required'; } if (payload.pattern && val) { const re = new RegExp(payload.pattern); if (!re.test(val)) return `Value must match: ${payload.pattern}`; } try { coerceValue(val, type); } catch (err) { return err instanceof Error ? err.message : String(err); } }, }); if (answer === undefined) { log.warn( `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`, ); return; } value = coerceValue(answer, type); } bus.emitRaw( `${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me, }, ); }); // Liveness probe: lets a non-interactive caller in another shell // (e.g. `module generate`) detect that a terminal-responder is // running here and that calling busInterview is safe. const probeWatch = bus.watch('responder.probe', async (event) => { if (event.replyFor !== null) return; bus.emitRaw( `${event.type}.reply`, { kind: 'terminal', emittedBy: me }, { replyFor: event.id, emittedBy: me }, ); }); return { close() { configWatch.close(); secretWatch.close(); ensureWatch.close(); interviewWatch.close(); probeWatch.close(); bus.close(); }, }; } /** * Prompt the operator for a secret value, handling all three * `style` variants. Returns the value to store, or `undefined` if * the user cancelled. * * For `user_password`: re-prompts on mismatch (loop until match or * cancel) — a much friendlier UX than the old "fail and re-run * deploy" behavior. * * For `generated_optional`: empty input falls back to * `generateSecret(payload.generate)` so the operator can hit Enter * to skip and let the deploy auto-generate. */ async function promptForSecret(payload: SecretRequiredPayload): Promise { const message = payload.description ? `${payload.module}.${payload.key} — ${payload.description}:` : `${payload.module}.${payload.key}:`; const style = payload.style ?? 'user_provided'; // string-map: Record gathered via add-loop, stored as // JSON. Bypasses the style switch — string-map secrets are always // collected key-by-key, never as a single masked input. if (payload.type === 'string-map') { return promptForStringMap(payload); } if (style === 'user_password') { while (true) { const value = await promptPassword({ message, validate: (val) => (!val || val.trim() === '' ? 'This field is required' : undefined), }); if (value === undefined) return undefined; const confirm = await promptPassword({ message: `Confirm ${payload.module}.${payload.key}:`, validate: (val) => (!val || val.trim() === '' ? 'This field is required' : undefined), }); if (confirm === undefined) return undefined; if (value === confirm) return value; log.error('Passwords do not match. Try again.'); } } if (style === 'generated_optional') { const optMessage = `${message} (Enter = auto-generate)`; const value = await promptPassword({ message: optMessage, validate: () => undefined, }); if (value === undefined) return undefined; if (value.trim() === '') { const format = payload.generate?.format ?? 'base64'; const length = payload.generate?.length ?? 32; const generated = generateSecret({ format, length }); log.message(`Auto-generated ${format} secret: ${payload.module}.${payload.key}`); return generated; } return value; } // Default: user_provided — single prompt, required. const value = await promptPassword({ message, validate: (val) => payload.required && (!val || val.trim() === '') ? 'This field is required' : undefined, }); return value; } /** * Add-loop UX for `type: string-map` secrets. Collects key/value pairs * one at a time (key via `promptText`, value via `promptPassword` since * we're inside the secret responder), then JSON-stringifies the result * for storage. The operator never has to type braces, quotes, or commas. * * Empty key terminates the loop. If `payload.required` and zero entries * have been collected, we re-ask rather than ack-ing with an empty map — * the alternative would be a successful "ack" that fails downstream * validation, which is worse UX. */ async function promptForStringMap(payload: SecretRequiredPayload): Promise { const keyLabel = payload.key_label ?? 'key'; const valueLabel = payload.value_label ?? 'value'; const header = payload.description ? `${payload.module}.${payload.key} — ${payload.description}` : `${payload.module}.${payload.key}`; log.message(header); log.message( `Add ${keyLabel.toLowerCase()} → ${valueLabel.toLowerCase()} entries one at a time. Press Enter on an empty ${keyLabel.toLowerCase()} when done.`, ); // Compile the optional regex validators once. An invalid regex (i.e. // a typo in the manifest) gets surfaced once here; we treat it as // "no validation" rather than wedging the whole interview. const keyRegex = compileMaybeRegex( payload.key_pattern, `${payload.module}.${payload.key} key_pattern`, ); const valueRegex = compileMaybeRegex( payload.value_pattern, `${payload.module}.${payload.key} value_pattern`, ); const keyPatternMessage = payload.key_pattern_message ?? `must match: ${payload.key_pattern}`; const valuePatternMessage = payload.value_pattern_message ?? `must match: ${payload.value_pattern}`; const collected: Record = {}; while (true) { const count = Object.keys(collected).length; const keyMessage = count === 0 ? `${keyLabel}:` : `${keyLabel} (or Enter to finish):`; const key = await promptText({ message: keyMessage, // promptText empties + falsy returns terminate the loop, so // validate has to allow empty input. Apply the key regex only // to non-empty values so the operator can still finish the loop. validate: (val) => { if (!val || val.trim() === '') return undefined; if (keyRegex && !keyRegex.test(val.trim())) return keyPatternMessage; return undefined; }, }); if (key === undefined) { // User cancelled (Ctrl-C). Return undefined so the responder // doesn't ack — the deploy stays paused. return undefined; } const trimmedKey = key.trim(); if (trimmedKey === '') { if (count === 0 && payload.required) { log.error( `At least one ${keyLabel.toLowerCase()} is required. Add an entry or Ctrl-C to cancel.`, ); continue; } break; } if (collected[trimmedKey] !== undefined) { log.warn(`${keyLabel} '${trimmedKey}' was already entered — overwriting previous value.`); } const value = await promptPassword({ message: `${valueLabel} for '${trimmedKey}':`, validate: (val) => { if (!val || val.trim() === '') return 'Required'; if (valueRegex && !valueRegex.test(val)) return valuePatternMessage; return undefined; }, }); if (value === undefined) { // User cancelled mid-entry; abort the whole interview. return undefined; } collected[trimmedKey] = value; log.success(`Added ${trimmedKey}`); } return JSON.stringify(collected); } /** * Compile a regex pattern from manifest config, returning null on * empty or invalid input. A bad regex emits a one-time warning and * disables that validation rather than crashing the interview. */ function compileMaybeRegex(pattern: string | undefined, label: string): RegExp | null { if (!pattern) return null; try { return new RegExp(pattern); } catch (err) { log.warn( `${label}: invalid regex '${pattern}' — ${err instanceof Error ? err.message : String(err)}. Skipping validation.`, ); return null; } } /** * Short hint shown in the prompt for non-string types so the operator * isn't guessing what shape we want. Returns null for plain strings — * no hint needed. */ function describeTypeHint(type: ConfigRequiredPayload['type']): string | null { switch (type) { case 'string': return null; case 'integer': return 'integer'; case 'number': return 'number'; case 'boolean': return 'true/false/yes/no/y/n/1/0'; case 'array': return 'JSON array, e.g. ["a","b"]'; case 'object': return 'JSON object, e.g. {"k":"v"}'; default: return null; } } function coerceValue(raw: string | undefined, type: ConfigRequiredPayload['type']): unknown { // The prompt layer yields undefined when the user cancels (Ctrl+C). // Bubble that up as an error so validate sees it and the responder can // skip the reply rather than emit a malformed one. if (raw === undefined) { throw new Error('Cancelled'); } switch (type) { case 'string': return raw; case 'integer': case 'number': { const n = Number(raw); if (!Number.isFinite(n)) { throw new Error(`Expected ${type}, got "${raw}"`); } return n; } case 'boolean': if (/^(true|yes|y|1)$/i.test(raw.trim())) return true; if (/^(false|no|n|0)$/i.test(raw.trim())) return false; throw new Error(`Expected boolean, got "${raw}"`); case 'array': case 'object': try { return JSON.parse(raw); } catch (err) { throw new Error( `Expected ${type} as JSON, got "${raw}": ${err instanceof Error ? err.message : err}`, ); } default: return raw; } }