diff --git a/src/app.ts b/src/app.ts index 91d2a30248..b3d6af2043 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,7 +26,12 @@ import { listCommand as teamListCommand } from "./commands/team/list.js"; import { traceRoute } from "./commands/trace/index.js"; import { listCommand as traceListCommand } from "./commands/trace/list.js"; import { CLI_VERSION } from "./lib/constants.js"; -import { AuthError, CliError, getExitCode } from "./lib/errors.js"; +import { + AuthError, + CliError, + getExitCode, + stringifyUnknown, +} from "./lib/errors.js"; import { error as errorColor } from "./lib/formatters/colors.js"; /** Top-level route map containing all CLI commands */ @@ -88,7 +93,7 @@ const customText: ApplicationText = { if (exc instanceof Error) { return `Unexpected error: ${exc.stack ?? exc.message}`; } - return `Unexpected error: ${String(exc)}`; + return `Unexpected error: ${stringifyUnknown(exc)}`; }, }; diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 396c93ff6e..6cbd945fba 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -18,7 +18,7 @@ import { } from "../../lib/db/defaults.js"; import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo } from "../../lib/db/user.js"; -import { AuthError } from "../../lib/errors.js"; +import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { error, muted, success } from "../../lib/formatters/colors.js"; import { formatExpiration, @@ -112,7 +112,7 @@ async function verifyCredentials( stdout.write(` ... and ${orgs.length - maxDisplay} more\n`); } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = stringifyUnknown(err); stderr.write(`\n${error("✗")} Could not verify credentials: ${message}\n`); } } diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 17b6156289..b9fb5054f2 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -11,7 +11,7 @@ import type { SentryContext } from "../../context.js"; import { findProjectsBySlug, listLogs } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { AuthError, ContextError } from "../../lib/errors.js"; +import { AuthError, ContextError, stringifyUnknown } from "../../lib/errors.js"; import { formatLogRow, formatLogsHeader, @@ -226,7 +226,7 @@ async function executeFollowMode(options: FollowModeOptions): Promise { Sentry.captureException(error); // Always write to stderr (doesn't interfere with JSON on stdout) - const message = error instanceof Error ? error.message : String(error); + const message = stringifyUnknown(error); stderr.write(`Error fetching logs: ${message}\n`); // Continue polling on transient errors (network, etc.) } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 3e095fcafb..0aec7b3ebc 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -48,7 +48,7 @@ import { } from "../types/index.js"; import type { AutofixResponse, AutofixState } from "../types/seer.js"; -import { ApiError, AuthError } from "./errors.js"; +import { ApiError, AuthError, stringifyUnknown } from "./errors.js"; import { resolveOrgRegion } from "./region.js"; import { getApiBaseUrl, @@ -84,8 +84,8 @@ function throwApiError( const status = response?.status ?? 0; const detail = error && typeof error === "object" && "detail" in error - ? String((error as { detail: unknown }).detail) - : String(error); + ? stringifyUnknown((error as { detail: unknown }).detail) + : stringifyUnknown(error); throw new ApiError( `${context}: ${status} ${response?.statusText ?? "Unknown"}`, status, diff --git a/src/lib/binary.ts b/src/lib/binary.ts index 4a899491e7..df4edafeae 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -15,7 +15,7 @@ import { import { chmod, mkdir, unlink } from "node:fs/promises"; import { delimiter, join, resolve } from "node:path"; import { getUserAgent } from "./constants.js"; -import { UpgradeError } from "./errors.js"; +import { stringifyUnknown, UpgradeError } from "./errors.js"; /** Known directories where the curl installer may place the binary */ export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".sentry/bin"]; @@ -143,7 +143,7 @@ export async function fetchWithUpgradeError( if (error instanceof Error && error.name === "AbortError") { throw error; } - const msg = error instanceof Error ? error.message : String(error); + const msg = stringifyUnknown(error); throw new UpgradeError( "network_error", `Failed to connect to ${serviceName}: ${msg}` diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 32d5000a79..ba833c7560 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -12,6 +12,7 @@ */ import type { Database } from "bun:sqlite"; +import { stringifyUnknown } from "../errors.js"; export const CURRENT_SCHEMA_VERSION = 5; @@ -391,7 +392,7 @@ function repairMissingTables(db: Database, result: RepairResult): void { db.exec(ddl); result.fixed.push(`Created table ${tableName}`); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = stringifyUnknown(e); result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } @@ -410,7 +411,7 @@ function repairMissingColumns(db: Database, result: RepairResult): void { db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${col.name} ${col.type}`); result.fixed.push(`Added column ${tableName}.${col.name}`); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = stringifyUnknown(e); result.failed.push( `Failed to add column ${tableName}.${col.name}: ${msg}` ); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index e9b1c8a8c9..de3d61f2ff 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -311,9 +311,39 @@ export class SeerError extends CliError { // Error Utilities +/** + * Convert an unknown value to a human-readable string. + * + * Handles Error instances (`.message`), plain objects (`JSON.stringify`), + * strings (as-is), and other primitives (`String()`). + * Use this instead of bare `String(value)` when the value might be a + * plain object — `String({})` produces the unhelpful `"[object Object]"`. + * + * @param value - Any thrown or unknown value + * @returns Human-readable string representation + */ +export function stringifyUnknown(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value instanceof Error) { + return value.message; + } + if (value && typeof value === "object") { + // JSON.stringify can throw on circular references or BigInt values. + // Fall back to String() which is always safe. + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + /** * Format any error for user display. - * Uses CliError.format() for CLI errors, message for standard errors. + * Uses CliError.format() for CLI errors, falls back to stringifyUnknown. * * @param error - Any thrown value * @returns Formatted error string @@ -322,10 +352,7 @@ export function formatError(error: unknown): string { if (error instanceof CliError) { return error.format(); } - if (error instanceof Error) { - return error.message; - } - return String(error); + return stringifyUnknown(error); } /** diff --git a/test/lib/binary.test.ts b/test/lib/binary.test.ts index 8d884d3ec5..0c9c056d4f 100644 --- a/test/lib/binary.test.ts +++ b/test/lib/binary.test.ts @@ -227,7 +227,7 @@ describe("fetchWithUpgradeError", () => { test("wraps non-Error thrown values as UpgradeError", async () => { globalThis.fetch = (async () => { // biome-ignore lint/style/useThrowOnlyError: intentionally testing non-Error throw - throw { toString: () => "custom thrown value" }; + throw { code: "ECONNRESET", reason: "connection reset" }; }) as typeof globalThis.fetch; try { @@ -235,7 +235,7 @@ describe("fetchWithUpgradeError", () => { expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(UpgradeError); - expect((error as UpgradeError).message).toContain("custom thrown value"); + expect((error as UpgradeError).message).toContain("ECONNRESET"); } }); }); diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 8f13e515d0..0bb9675834 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -9,6 +9,7 @@ import { formatError, getExitCode, SeerError, + stringifyUnknown, UpgradeError, ValidationError, } from "../../src/lib/errors.js"; @@ -253,6 +254,60 @@ describe("SeerError", () => { }); }); +describe("stringifyUnknown", () => { + test("returns strings as-is", () => { + expect(stringifyUnknown("hello")).toBe("hello"); + expect(stringifyUnknown("")).toBe(""); + }); + + test("extracts message from Error instances", () => { + expect(stringifyUnknown(new Error("something broke"))).toBe( + "something broke" + ); + expect(stringifyUnknown(new TypeError("bad type"))).toBe("bad type"); + }); + + test("serializes plain objects to JSON", () => { + expect(stringifyUnknown({ code: "not_found" })).toBe( + '{"code":"not_found"}' + ); + expect(stringifyUnknown({ detail: { message: "Forbidden" } })).toBe( + '{"detail":{"message":"Forbidden"}}' + ); + }); + + test("serializes empty objects", () => { + expect(stringifyUnknown({})).toBe("{}"); + }); + + test("serializes arrays to JSON", () => { + expect(stringifyUnknown(["error1", "error2"])).toBe('["error1","error2"]'); + }); + + test("converts primitives via String()", () => { + expect(stringifyUnknown(42)).toBe("42"); + expect(stringifyUnknown(null)).toBe("null"); + expect(stringifyUnknown(undefined)).toBe("undefined"); + expect(stringifyUnknown(true)).toBe("true"); + expect(stringifyUnknown(0)).toBe("0"); + }); + + test("falls back to String() for circular references", () => { + const circular: Record = { name: "loop" }; + circular.self = circular; + // Should not throw — falls back to String() which returns [object Object] + expect(() => stringifyUnknown(circular)).not.toThrow(); + expect(stringifyUnknown(circular)).toBe("[object Object]"); + }); + + test("falls back to String() for BigInt values", () => { + const obj = { count: BigInt(42) }; + // JSON.stringify throws on BigInt — should fall back gracefully + expect(() => stringifyUnknown(obj)).not.toThrow(); + expect(stringifyUnknown(obj)).toBe("[object Object]"); + }); +}); + describe("formatError", () => { test("uses format() for CliError subclasses", () => { const err = new ApiError("API failed", 500, "Server error"); @@ -270,6 +325,11 @@ describe("formatError", () => { expect(formatError(null)).toBe("null"); expect(formatError(undefined)).toBe("undefined"); }); + + test("serializes plain objects instead of [object Object]", () => { + expect(formatError({ code: "not_found" })).toBe('{"code":"not_found"}'); + expect(formatError({})).toBe("{}"); + }); }); describe("getExitCode", () => {