diff --git a/apps/cli/src/__tests__/get-error-message.test.ts b/apps/cli/src/__tests__/get-error-message.test.ts new file mode 100644 index 0000000000..1c92dbed56 --- /dev/null +++ b/apps/cli/src/__tests__/get-error-message.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { getErrorMessage } from "../commands/helpers.js"; + +describe("getErrorMessage", () => { + it("unwraps the cause chain so connect errors survive fetch failed", () => { + const err = new TypeError("fetch failed", { + cause: new Error("connect EPERM 127.0.0.1:38886"), + }); + + expect(getErrorMessage(err)).toBe( + "fetch failed: connect EPERM 127.0.0.1:38886", + ); + }); + + it("unwraps every connection error in an aggregate cause", () => { + const err = new TypeError("fetch failed", { + cause: new AggregateError([ + new Error("connect EPERM ::1:38886"), + new Error("connect ECONNREFUSED 127.0.0.1:38886"), + ]), + }); + + expect(getErrorMessage(err)).toBe( + "fetch failed: connect EPERM ::1:38886: connect ECONNREFUSED 127.0.0.1:38886", + ); + }); + + it("returns the message unchanged without a cause", () => { + expect(getErrorMessage(new Error("plain"))).toBe("plain"); + }); + + it("stringifies non-Error values", () => { + expect(getErrorMessage("boom")).toBe("boom"); + }); + + it("stops on a cyclic cause chain", () => { + const err = new Error("loop"); + err.cause = err; + + expect(getErrorMessage(err)).toBe("loop"); + }); +}); diff --git a/apps/cli/src/commands/helpers.ts b/apps/cli/src/commands/helpers.ts index 9f083f8d36..1106690b3f 100644 --- a/apps/cli/src/commands/helpers.ts +++ b/apps/cli/src/commands/helpers.ts @@ -88,8 +88,39 @@ export async function confirmDestructiveAction( } export function getErrorMessage(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); + if (!(err instanceof Error)) return String(err); + // Node's fetch says "fetch failed" and keeps the actionable socket errors + // under `cause`. Multi-address connections use an AggregateError, so walk + // both links while guarding against malformed cyclic error graphs. + const seen = new Set(); + const messages: string[] = []; + const pending: Error[] = [err]; + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined || seen.has(current)) continue; + seen.add(current); + + if (current.message.length > 0) { + messages.push(current.message); + } + + const children: Error[] = []; + if (current.cause instanceof Error) { + children.push(current.cause); + } + if (current instanceof AggregateError) { + children.push( + ...current.errors.filter( + (nested): nested is Error => nested instanceof Error, + ), + ); + } + for (let index = children.length - 1; index >= 0; index -= 1) { + pending.push(children[index]); + } + } + return messages.join(": "); } export function parseReasoningLevel(