diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 8ab618a62..e7229c9ac 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -67,6 +67,7 @@ import { GOOGLE_GEMINI_DEFAULT_MODEL, } from "./google-gemini-model-default.js"; import { healthCommand } from "./health.js"; +import { formatHealthCheckFailure } from "./health-format.js"; import { applyAuthProfileConfig, applyMinimaxConfig, @@ -1286,7 +1287,7 @@ export async function runConfigureWizard( try { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); } catch (err) { - runtime.error(`Health check failed: ${String(err)}`); + runtime.error(formatHealthCheckFailure(err)); note( [ "Docs:", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 64807b20b..291605014 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -80,6 +80,7 @@ import { shouldSuggestMemorySystem, } from "./doctor-workspace.js"; import { healthCommand } from "./health.js"; +import { formatHealthCheckFailure } from "./health-format.js"; import { applyWizardMetadata, printWizardHeader } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; @@ -310,7 +311,7 @@ export async function doctorCommand( note("Gateway not running.", "Gateway"); note(gatewayDetails.message, "Gateway connection"); } else { - runtime.error(`Health check failed: ${message}`); + runtime.error(formatHealthCheckFailure(err)); } } @@ -455,7 +456,7 @@ export async function doctorCommand( note("Gateway not running.", "Gateway"); note(gatewayDetails.message, "Gateway connection"); } else { - runtime.error(`Health check failed: ${message}`); + runtime.error(formatHealthCheckFailure(err)); } } } diff --git a/src/commands/health-format.test.ts b/src/commands/health-format.test.ts new file mode 100644 index 000000000..a0cd88322 --- /dev/null +++ b/src/commands/health-format.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { formatHealthCheckFailure } from "./health-format.js"; + +const stripAnsi = (input: string) => + input.replace( + // biome-ignore lint/suspicious/noControlCharactersInRegex: strip ANSI escape sequences + /\u001b\[[0-9;]*m/g, + "", + ); + +describe("formatHealthCheckFailure", () => { + it("keeps non-rich output stable", () => { + const err = new Error( + "gateway closed (1006 abnormal closure): no close reason", + ); + expect(formatHealthCheckFailure(err, { rich: false })).toBe( + `Health check failed: ${String(err)}`, + ); + }); + + it("formats gateway connection details as indented key/value lines", () => { + const err = new Error( + [ + "gateway closed (1006 abnormal closure (no close frame)): no close reason", + "Gateway target: ws://127.0.0.1:19001", + "Source: local loopback", + "Config: /Users/steipete/.clawdbot-dev/clawdbot.json", + "Bind: loopback", + ].join("\n"), + ); + + expect(stripAnsi(formatHealthCheckFailure(err, { rich: true }))).toBe( + [ + "Health check failed: gateway closed (1006 abnormal closure (no close frame)): no close reason", + " Gateway target: ws://127.0.0.1:19001", + " Source: local loopback", + " Config: /Users/steipete/.clawdbot-dev/clawdbot.json", + " Bind: loopback", + ].join("\n"), + ); + }); +}); diff --git a/src/commands/health-format.ts b/src/commands/health-format.ts new file mode 100644 index 000000000..81dfa8f8e --- /dev/null +++ b/src/commands/health-format.ts @@ -0,0 +1,48 @@ +import { colorize, isRich, theme } from "../terminal/theme.js"; + +const formatKv = (line: string, rich: boolean) => { + const idx = line.indexOf(": "); + if (idx <= 0) return colorize(rich, theme.muted, line); + const key = line.slice(0, idx); + const value = line.slice(idx + 2); + + const valueColor = + key === "Gateway target" || key === "Config" + ? theme.command + : key === "Source" + ? theme.muted + : theme.info; + + return `${colorize(rich, theme.muted, `${key}:`)} ${colorize(rich, valueColor, value)}`; +}; + +export function formatHealthCheckFailure( + err: unknown, + opts: { rich?: boolean } = {}, +): string { + const rich = opts.rich ?? isRich(); + const raw = String(err); + const message = err instanceof Error ? err.message : raw; + + if (!rich) return `Health check failed: ${raw}`; + + const lines = message + .split("\n") + .map((l) => l.trimEnd()) + .filter(Boolean); + const detailsIdx = lines.findIndex((l) => l.startsWith("Gateway target: ")); + + const summaryLines = (detailsIdx >= 0 ? lines.slice(0, detailsIdx) : lines) + .map((l) => l.trim()) + .filter(Boolean); + const detailLines = detailsIdx >= 0 ? lines.slice(detailsIdx) : []; + + const summary = summaryLines.length > 0 ? summaryLines.join(" ") : message; + const header = colorize(rich, theme.error.bold, "Health check failed"); + + const out: string[] = [`${header}: ${summary}`]; + for (const line of detailLines) { + out.push(` ${formatKv(line, rich)}`); + } + return out.join("\n"); +} diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 468af6f18..f20b9ca88 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, @@ -11,6 +12,11 @@ import { parseKeyValueOutput } from "./runtime-parse.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; const execFileAsync = promisify(execFile); + +const formatLine = (label: string, value: string) => { + const rich = isRich(); + return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; +}; function resolveHomeDir(env: Record): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim(); if (!home) throw new Error("Missing HOME"); @@ -378,7 +384,9 @@ export async function uninstallLegacyLaunchAgents({ const dest = path.join(trashDir, `${agent.label}.plist`); try { await fs.rename(agent.plistPath, dest); - stdout.write(`Moved legacy LaunchAgent to Trash: ${dest}\n`); + stdout.write( + `${formatLine("Moved legacy LaunchAgent to Trash", dest)}\n`, + ); } catch { stdout.write( `Legacy LaunchAgent remains at ${agent.plistPath} (could not move)\n`, @@ -414,7 +422,7 @@ export async function uninstallLaunchAgent({ try { await fs.mkdir(trashDir, { recursive: true }); await fs.rename(plistPath, dest); - stdout.write(`Moved LaunchAgent to Trash: ${dest}\n`); + stdout.write(`${formatLine("Moved LaunchAgent to Trash", dest)}\n`); } catch { stdout.write(`LaunchAgent remains at ${plistPath} (could not move)\n`); } @@ -446,7 +454,7 @@ export async function stopLaunchAgent({ `launchctl bootout failed: ${res.stderr || res.stdout}`.trim(), ); } - stdout.write(`Stopped LaunchAgent: ${domain}/${label}\n`); + stdout.write(`${formatLine("Stopped LaunchAgent", `${domain}/${label}`)}\n`); } export async function installLaunchAgent({ @@ -507,8 +515,8 @@ export async function installLaunchAgent({ `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`, ]); - stdout.write(`Installed LaunchAgent: ${plistPath}\n`); - stdout.write(`Logs: ${stdoutPath}\n`); + stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`); + stdout.write(`${formatLine("Logs", stdoutPath)}\n`); return { plistPath }; } @@ -525,5 +533,7 @@ export async function restartLaunchAgent({ `launchctl kickstart failed: ${res.stderr || res.stdout}`.trim(), ); } - stdout.write(`Restarted LaunchAgent: ${domain}/${label}\n`); + stdout.write( + `${formatLine("Restarted LaunchAgent", `${domain}/${label}`)}\n`, + ); } diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index c13d5ca32..565fed532 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import { GATEWAY_WINDOWS_TASK_NAME, LEGACY_GATEWAY_WINDOWS_TASK_NAMES, @@ -12,6 +13,11 @@ import type { GatewayServiceRuntime } from "./service-runtime.js"; const execFileAsync = promisify(execFile); +const formatLine = (label: string, value: string) => { + const rich = isRich(); + return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; +}; + function resolveHomeDir(env: Record): string { const home = env.USERPROFILE?.trim() || env.HOME?.trim(); if (!home) throw new Error("Missing HOME"); @@ -229,8 +235,10 @@ export async function installScheduledTask({ } await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]); - stdout.write(`Installed Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`); - stdout.write(`Task script: ${scriptPath}\n`); + stdout.write( + `${formatLine("Installed Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`, + ); + stdout.write(`${formatLine("Task script", scriptPath)}\n`); return { scriptPath }; } @@ -247,7 +255,7 @@ export async function uninstallScheduledTask({ const scriptPath = resolveTaskScriptPath(env); try { await fs.unlink(scriptPath); - stdout.write(`Removed task script: ${scriptPath}\n`); + stdout.write(`${formatLine("Removed task script", scriptPath)}\n`); } catch { stdout.write(`Task script not found at ${scriptPath}\n`); } @@ -272,7 +280,9 @@ export async function stopScheduledTask({ if (res.code !== 0 && !isTaskNotRunning(res)) { throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); } - stdout.write(`Stopped Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`); + stdout.write( + `${formatLine("Stopped Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`, + ); } export async function restartScheduledTask({ @@ -286,7 +296,9 @@ export async function restartScheduledTask({ if (res.code !== 0) { throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim()); } - stdout.write(`Restarted Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`); + stdout.write( + `${formatLine("Restarted Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`, + ); } export async function isScheduledTaskInstalled(): Promise { @@ -400,7 +412,9 @@ export async function uninstallLegacyScheduledTasks({ try { await fs.unlink(task.scriptPath); - stdout.write(`Removed legacy task script: ${task.scriptPath}\n`); + stdout.write( + `${formatLine("Removed legacy task script", task.scriptPath)}\n`, + ); } catch { stdout.write(`Legacy task script not found at ${task.scriptPath}\n`); } diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 6d6496c8d..b39cd30cc 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { runCommandWithTimeout, runExec } from "../process/exec.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import { GATEWAY_SYSTEMD_SERVICE_NAME, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, @@ -13,6 +14,11 @@ import type { GatewayServiceRuntime } from "./service-runtime.js"; const execFileAsync = promisify(execFile); +const formatLine = (label: string, value: string) => { + const rich = isRich(); + return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; +}; + function resolveHomeDir(env: Record): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim(); if (!home) throw new Error("Missing HOME"); @@ -410,7 +416,7 @@ export async function installSystemdService({ ); } - stdout.write(`Installed systemd service: ${unitPath}\n`); + stdout.write(`${formatLine("Installed systemd service", unitPath)}\n`); return { unitPath }; } @@ -428,7 +434,7 @@ export async function uninstallSystemdService({ const unitPath = resolveSystemdUnitPath(env); try { await fs.unlink(unitPath); - stdout.write(`Removed systemd service: ${unitPath}\n`); + stdout.write(`${formatLine("Removed systemd service", unitPath)}\n`); } catch { stdout.write(`Systemd service not found at ${unitPath}\n`); } @@ -447,7 +453,7 @@ export async function stopSystemdService({ `systemctl stop failed: ${res.stderr || res.stdout}`.trim(), ); } - stdout.write(`Stopped systemd service: ${unitName}\n`); + stdout.write(`${formatLine("Stopped systemd service", unitName)}\n`); } export async function restartSystemdService({ @@ -463,7 +469,7 @@ export async function restartSystemdService({ `systemctl restart failed: ${res.stderr || res.stdout}`.trim(), ); } - stdout.write(`Restarted systemd service: ${unitName}\n`); + stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`); } export async function isSystemdServiceEnabled(): Promise { @@ -584,7 +590,9 @@ export async function uninstallLegacySystemdUnits({ try { await fs.unlink(unit.unitPath); - stdout.write(`Removed legacy systemd service: ${unit.unitPath}\n`); + stdout.write( + `${formatLine("Removed legacy systemd service", unit.unitPath)}\n`, + ); } catch { stdout.write(`Legacy systemd unit not found at ${unit.unitPath}\n`); } diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index d67750f5f..302c9ec7e 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -13,6 +13,7 @@ import { type GatewayDaemonRuntime, } from "../commands/daemon-runtime.js"; import { healthCommand } from "../commands/health.js"; +import { formatHealthCheckFailure } from "../commands/health-format.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -664,7 +665,7 @@ export async function runOnboardingWizard( try { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); } catch (err) { - runtime.error(`Health check failed: ${String(err)}`); + runtime.error(formatHealthCheckFailure(err)); await prompter.note( [ "Docs:", @@ -740,7 +741,7 @@ export async function runOnboardingWizard( "Start TUI (best option!)", ); const wantsTui = await prompter.confirm({ - message: "Start TUI now? (best option!)", + message: "Do you want to hatch your bot now?", initialValue: true, }); if (wantsTui) {