// JSON/text response helpers for Gateway service lifecycle commands. import { Writable } from "node:stream"; import type { GatewayService } from "../../daemon/service.js"; import { isSystemdUnavailableDetail, renderSystemdUnavailableHints, } from "../../daemon/systemd-hints.js"; import { classifySystemdUnavailableDetail } from "../../daemon/systemd-unavailable.js"; import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; /** Gateway service action emitted by lifecycle commands. */ type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart"; /** Stable hint category for machine-readable daemon command output. */ type DaemonHintKind = | "install" | "container-restart" | "container-foreground" | "systemd-unavailable" | "systemd-headless" | "wsl-systemd" | "generic"; /** Classified daemon recovery hint item. */ type DaemonHintItem = { kind: DaemonHintKind; text: string; }; /** Machine-readable response shape for service lifecycle commands. */ type DaemonActionResponse = { ok: boolean; action: DaemonAction; result?: string; message?: string; error?: string; hints?: string[]; hintItems?: DaemonHintItem[]; warnings?: string[]; service?: { label: string; loaded: boolean; loadedText: string; notLoadedText: string; }; }; function emitDaemonActionJson(payload: DaemonActionResponse) { defaultRuntime.writeJson(payload); } function classifyDaemonHintText(text: string): DaemonHintKind { if (/\b(gateway|node) install\b/u.test(text) || text.startsWith("Service not installed. Run:")) { return "install"; } if (text.startsWith("Restart the container or the service that manages it for ")) { return "container-restart"; } if (text.startsWith("systemd user services are unavailable;")) { return "systemd-unavailable"; } if ( text.startsWith("On a headless server (SSH/no desktop session):") || text.startsWith("Also ensure XDG_RUNTIME_DIR is set:") ) { return "systemd-headless"; } if (text.startsWith("If you're in a container, run the gateway in the foreground instead of")) { return "container-foreground"; } if ( text.startsWith("WSL2 needs systemd enabled:") || text.startsWith("Then run: wsl --shutdown") || text.startsWith("Verify: systemctl --user status") ) { return "wsl-systemd"; } return "generic"; } /** Classify plain-text hints for JSON daemon responses. */ function buildDaemonHintItems(hints: string[] | undefined): DaemonHintItem[] | undefined { if (!hints?.length) { return undefined; } return hints.map((text) => ({ kind: classifyDaemonHintText(text), text })); } /** Build the service metadata snapshot embedded in JSON action responses. */ export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) { return { label: service.label, loaded, loadedText: service.loadedText, notLoadedText: service.notLoadedText, }; } type DaemonEmit = (payload: Omit) => void; /** Emit a lifecycle result and mirror its message to text output. */ function emitDaemonActionMessage(params: { json: boolean; emit: DaemonEmit; payload: Omit; }): void { params.emit(params.payload); if (!params.json && params.payload.message) { defaultRuntime.log(params.payload.message); } } /** Emit the no-op success returned when a service is already running. */ export function emitDaemonAlreadyRunning(params: { serviceNoun: string; service: GatewayService; pid?: number; json: boolean; warnings: string[]; emit: DaemonEmit; }): void { const message = params.pid === undefined ? `${params.serviceNoun} service already running.` : `${params.serviceNoun} service already running (pid ${params.pid}).`; emitDaemonActionMessage({ json: params.json, emit: params.emit, payload: { ok: true, result: "already-running", message, service: buildDaemonServiceSnapshot(params.service, true), warnings: params.warnings.length ? params.warnings : undefined, }, }); } /** Emit a service-manager restart that has been accepted but not completed. */ export function emitDaemonScheduledRestart(params: { json: boolean; emit: DaemonEmit; result: string; message: string; service: GatewayService; loaded: boolean; warnings: string[]; }): true { emitDaemonActionMessage({ json: params.json, emit: params.emit, payload: { ok: true, result: params.result, message: params.message, service: buildDaemonServiceSnapshot(params.service, params.loaded), warnings: params.warnings.length ? params.warnings : undefined, }, }); return true; } /** Writable sink used when JSON output should suppress service command stdout. */ export function createNullWriter(): Writable { return new Writable({ write(_chunk, _encoding, callback) { callback(); }, }); } /** Create stdout/warning/emit/fail helpers for one daemon lifecycle action. */ export function createDaemonActionContext(params: { action: DaemonAction; json: boolean }): { stdout: Writable; warnings: string[]; emit: (payload: Omit) => void; fail: (message: string, hints?: string[], result?: "restart-health-failed") => void; } { const warnings: string[] = []; const stdout = params.json ? createNullWriter() : process.stdout; const emit = (payload: Omit) => { if (!params.json) { return; } emitDaemonActionJson({ action: params.action, ...payload, hintItems: payload.hintItems ?? buildDaemonHintItems(payload.hints), warnings: payload.warnings ?? (warnings.length ? warnings : undefined), }); }; const fail = (message: string, hints?: string[], result?: "restart-health-failed") => { if (params.json) { emit({ ok: false, error: message, hints, ...(result ? { result } : {}), }); } else { defaultRuntime.error(message); if (hints?.length) { for (const hint of hints) { defaultRuntime.log(`Tip: ${hint}`); } } } defaultRuntime.exit(1); }; return { stdout, warnings, emit, fail }; } async function buildInstallFailureHints(error: unknown): Promise { const detail = String(error); if (process.platform !== "linux" || !isSystemdUnavailableDetail(detail)) { return undefined; } return renderSystemdUnavailableHints({ wsl: await isWSL(), kind: classifySystemdUnavailableDetail(detail), }); } /** Install a service, convert platform install failures to hints, and emit the final response. */ export async function installDaemonServiceAndEmit(params: { serviceNoun: string; service: GatewayService; warnings: string[]; emit: (payload: Omit) => void; fail: (message: string, hints?: string[]) => void; install: () => Promise; /** * Runs only after the service has been written AND verified as loaded, but * before the success payload is emitted. Use this for post-success * diagnostics (e.g. linger warnings) so they never accompany a failed * install or a verification failure. Throwing here surfaces as a failure. */ onVerified?: () => Promise; }) { try { await params.install(); } catch (err) { params.fail( `${params.serviceNoun} install failed: ${String(err)}`, await buildInstallFailureHints(err), ); return; } let installed: boolean; try { installed = await params.service.isLoaded({ env: process.env }); } catch (err) { params.fail( `${params.serviceNoun} install verification failed: ${String(err)}`, await buildInstallFailureHints(err), ); return; } if (!installed) { params.fail( `${params.serviceNoun} install verification failed: service is not ${params.service.loadedText}.`, ); return; } // Post-success diagnostics run only on the verified-success path, so a // failed install or verification never carries their warnings. if (params.onVerified) { try { await params.onVerified(); } catch (err) { params.fail(`${params.serviceNoun} post-install check failed: ${String(err)}`); return; } } params.emit({ ok: true, result: "installed", service: buildDaemonServiceSnapshot(params.service, installed), warnings: params.warnings.length ? params.warnings : undefined, }); }