diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index f38cee36762..e1784420b83 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -43,6 +43,8 @@ describe("proxy cli runtime", () => { "OPENCLAW_DEBUG_PROXY_CERT_DIR", "OPENCLAW_DEBUG_PROXY_SESSION_ID", "OPENCLAW_DEBUG_PROXY_ENABLED", + "FORCE_COLOR", + "NO_COLOR", ] as const; const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); let tempDir = ""; @@ -54,6 +56,8 @@ describe("proxy cli runtime", () => { process.env.OPENCLAW_DEBUG_PROXY_CERT_DIR = path.join(tempDir, "certs"); delete process.env.OPENCLAW_DEBUG_PROXY_ENABLED; delete process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID; + delete process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; getRuntimeConfigMock.mockReset(); getRuntimeConfigMock.mockReturnValue({ proxy: { @@ -311,6 +315,66 @@ describe("proxy cli runtime", () => { ); }); + it("prints check errors on the same line", async () => { + runProxyValidationMock.mockResolvedValueOnce({ + ok: true, + config: { + enabled: true, + proxyUrl: "http://proxy.example:3128", + source: "config", + errors: [], + }, + checks: [ + { + kind: "denied", + url: "http://127.0.0.1:12345/", + ok: true, + error: "fetch failed", + }, + ], + }); + const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js"); + + await runProxyValidateCommand({}); + + expect(process.stdout.write).toHaveBeenCalledWith( + "Proxy validation passed\n\n" + + "Proxy\n" + + " Source: config\n" + + " URL: http://proxy.example:3128/\n\n" + + "Checks\n" + + " ✓ denied http://127.0.0.1:12345/ — fetch failed\n", + ); + }); + + it("applies the terminal color theme when rich output is enabled", async () => { + vi.resetModules(); + vi.doMock("../terminal/theme.js", () => ({ + colorize: (rich: boolean, color: (value: string) => string, value: string) => + rich ? color(value) : value, + isRich: () => true, + theme: { + heading: (value: string) => `${value}`, + success: (value: string) => `${value}`, + error: (value: string) => `${value}`, + muted: (value: string) => `${value}`, + warn: (value: string) => `${value}`, + }, + })); + try { + const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js"); + + await runProxyValidateCommand({}); + + const output = String(vi.mocked(process.stdout.write).mock.calls[0]?.[0] ?? ""); + expect(output).toContain("Proxy validation passed"); + expect(output).toContain("Checks"); + expect(output).toContain(""); + } finally { + vi.doUnmock("../terminal/theme.js"); + } + }); + it("prints actionable check failure output", async () => { runProxyValidationMock.mockResolvedValueOnce({ ok: false, @@ -347,8 +411,7 @@ describe("proxy cli runtime", () => { " URL: http://proxy.example:3128/\n\n" + "Checks\n" + " ✓ allowed http://target.example/allowed HTTP 200\n" + - " ✗ denied http://target.example/allowed HTTP 200\n" + - " Denied destination was reachable through the proxy\n\n" + + " ✗ denied http://target.example/allowed HTTP 200 — Denied destination was reachable through the proxy\n\n" + "Next steps\n" + " Update the proxy ACL so denied destinations are blocked, or pass the expected --denied-url values.\n", ); diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index c3ded290d08..a7299385d08 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -19,6 +19,7 @@ import { getDebugProxyCaptureStore, } from "../proxy-capture/store.sqlite.js"; import type { CaptureQueryPreset } from "../proxy-capture/types.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; export async function runDebugProxyStartCommand(opts: { host?: string; port?: number }) { const settings = resolveDebugProxySettings(); @@ -148,11 +149,41 @@ function redactProxyValidationResult(result: ProxyValidationResult): ProxyValida }; } -function formatProxyCheckLine(check: ProxyValidationResult["checks"][number]): string { - const icon = check.ok ? "✓" : "✗"; - const paddedKind = check.kind.padEnd(7, " "); - const status = check.status === undefined ? "" : ` HTTP ${check.status}`; - return ` ${icon} ${paddedKind} ${check.url}${status}`; +type ProxyValidationTextColors = { + heading: (value: string) => string; + success: (value: string) => string; + error: (value: string) => string; + muted: (value: string) => string; + warn: (value: string) => string; +}; + +function getProxyValidationTextColors(): ProxyValidationTextColors { + const rich = isRich(); + const apply = (color: (value: string) => string) => (value: string) => + colorize(rich, color, value); + return { + heading: apply(theme.heading), + success: apply(theme.success), + error: apply(theme.error), + muted: apply(theme.muted), + warn: apply(theme.warn), + }; +} + +function formatProxyCheckLine( + check: ProxyValidationResult["checks"][number], + colors: ProxyValidationTextColors, +): string { + const icon = check.ok ? colors.success("✓") : colors.error("✗"); + const paddedKind = colors.muted(check.kind.padEnd(7, " ")); + const status = + check.status === undefined + ? "" + : ` ${check.ok ? colors.success(`HTTP ${check.status}`) : colors.error(`HTTP ${check.status}`)}`; + const detail = check.error + ? ` — ${check.ok ? colors.muted(check.error) : colors.error(check.error)}` + : ""; + return ` ${icon} ${paddedKind} ${check.url}${status}${detail}`; } function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] { @@ -185,37 +216,35 @@ function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] } function formatProxyValidationText(result: ProxyValidationResult): string { + const colors = getProxyValidationTextColors(); const redactedProxyUrl = redactProxyUrl(result.config.proxyUrl); const lines = [ - `Proxy validation ${result.ok ? "passed" : "failed"}`, + result.ok ? colors.success("Proxy validation passed") : colors.error("Proxy validation failed"), "", - "Proxy", - ` Source: ${result.config.source}`, - ` URL: ${redactedProxyUrl ?? "not configured"}`, + colors.heading("Proxy"), + ` Source: ${colors.muted(result.config.source)}`, + ` URL: ${redactedProxyUrl ?? colors.muted("not configured")}`, ]; if (result.config.errors.length > 0) { - lines.push("", "Problems"); + lines.push("", colors.heading("Problems")); for (const error of result.config.errors) { - lines.push(` - ${error}`); + lines.push(` - ${colors.error(error)}`); } } if (result.checks.length > 0) { - lines.push("", "Checks"); + lines.push("", colors.heading("Checks")); for (const check of result.checks) { - lines.push(formatProxyCheckLine(check)); - if (check.error) { - lines.push(` ${check.error}`); - } + lines.push(formatProxyCheckLine(check, colors)); } } const nextSteps = formatProxyValidationNextSteps(result); if (nextSteps.length > 0) { - lines.push("", "Next steps"); + lines.push("", colors.heading("Next steps")); for (const nextStep of nextSteps) { - lines.push(` ${nextStep}`); + lines.push(` ${colors.warn(nextStep)}`); } }