| |
| 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"; |
|
|
| |
| type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart"; |
|
|
| |
| type DaemonHintKind = |
| | "install" |
| | "container-restart" |
| | "container-foreground" |
| | "systemd-unavailable" |
| | "systemd-headless" |
| | "wsl-systemd" |
| | "generic"; |
|
|
| |
| type DaemonHintItem = { |
| kind: DaemonHintKind; |
| text: string; |
| }; |
|
|
| |
| 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"; |
| } |
|
|
| |
| function buildDaemonHintItems(hints: string[] | undefined): DaemonHintItem[] | undefined { |
| if (!hints?.length) { |
| return undefined; |
| } |
| return hints.map((text) => ({ kind: classifyDaemonHintText(text), text })); |
| } |
|
|
| |
| export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) { |
| return { |
| label: service.label, |
| loaded, |
| loadedText: service.loadedText, |
| notLoadedText: service.notLoadedText, |
| }; |
| } |
|
|
| type DaemonEmit = (payload: Omit<DaemonActionResponse, "action">) => void; |
|
|
| |
| function emitDaemonActionMessage(params: { |
| json: boolean; |
| emit: DaemonEmit; |
| payload: Omit<DaemonActionResponse, "action">; |
| }): void { |
| params.emit(params.payload); |
| if (!params.json && params.payload.message) { |
| defaultRuntime.log(params.payload.message); |
| } |
| } |
|
|
| |
| 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, |
| }, |
| }); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| export function createNullWriter(): Writable { |
| return new Writable({ |
| write(_chunk, _encoding, callback) { |
| callback(); |
| }, |
| }); |
| } |
|
|
| |
| export function createDaemonActionContext(params: { action: DaemonAction; json: boolean }): { |
| stdout: Writable; |
| warnings: string[]; |
| emit: (payload: Omit<DaemonActionResponse, "action">) => 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<DaemonActionResponse, "action">) => { |
| 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<string[] | undefined> { |
| const detail = String(error); |
| if (process.platform !== "linux" || !isSystemdUnavailableDetail(detail)) { |
| return undefined; |
| } |
| return renderSystemdUnavailableHints({ |
| wsl: await isWSL(), |
| kind: classifySystemdUnavailableDetail(detail), |
| }); |
| } |
|
|
| |
| export async function installDaemonServiceAndEmit(params: { |
| serviceNoun: string; |
| service: GatewayService; |
| warnings: string[]; |
| emit: (payload: Omit<DaemonActionResponse, "action">) => void; |
| fail: (message: string, hints?: string[]) => void; |
| install: () => Promise<void>; |
| /** |
| * 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<void>; |
| }) { |
| 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; |
| } |
| |
| |
| 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, |
| }); |
| } |
|
|