From 91716ab5ea3369278bc56ca3a86ac3e6f945f347 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:38:17 -0600 Subject: [PATCH 1/9] refactor(errors): parse Clerk error envelope into structured ApiError fields --- .../cli-core/src/commands/config/pull.test.ts | 2 +- .../cli-core/src/commands/config/push.test.ts | 2 +- .../src/commands/config/schema.test.ts | 2 +- .../cli-core/src/commands/env/pull.test.ts | 2 +- packages/cli-core/src/lib/errors.test.ts | 85 +++++++++++++++++++ packages/cli-core/src/lib/errors.ts | 84 +++++++++++++++++- .../cli-core/src/lib/token-exchange.test.ts | 4 +- 7 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 packages/cli-core/src/lib/errors.test.ts diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index 702d7527..be7a33a1 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -310,6 +310,6 @@ describe("config pull", () => { instances: { development: "ins_dev" }, }); - await expect(runConfigPull()).rejects.toThrow("API error"); + await expect(runConfigPull()).rejects.toThrow("Unauthorized"); }); }); diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 5b720dad..95cbd1ea 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -632,7 +632,7 @@ describe("config push", () => { instances: { development: "ins_dev" }, }); - await expect(runConfigPatch({ json: '{"a":1}', yes: true })).rejects.toThrow("API error"); + await expect(runConfigPatch({ json: '{"a":1}', yes: true })).rejects.toThrow("Bad Request"); }); test("shows success message after push", async () => { diff --git a/packages/cli-core/src/commands/config/schema.test.ts b/packages/cli-core/src/commands/config/schema.test.ts index 89f1d67a..41f49ab7 100644 --- a/packages/cli-core/src/commands/config/schema.test.ts +++ b/packages/cli-core/src/commands/config/schema.test.ts @@ -275,6 +275,6 @@ describe("config schema", () => { instances: { development: "ins_dev" }, }); - await expect(runConfigSchema()).rejects.toThrow("API error"); + await expect(runConfigSchema()).rejects.toThrow("Unauthorized"); }); }); diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 328ce0cd..cb4a3eb0 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -487,7 +487,7 @@ describe("env pull", () => { instances: { development: "ins_dev" }, }); - await expect(runEnvPull()).rejects.toThrow("API error"); + await expect(runEnvPull()).rejects.toThrow("Unauthorized"); }); test("sends include_secret_keys=true in API request", async () => { diff --git a/packages/cli-core/src/lib/errors.test.ts b/packages/cli-core/src/lib/errors.test.ts new file mode 100644 index 00000000..1a3129b2 --- /dev/null +++ b/packages/cli-core/src/lib/errors.test.ts @@ -0,0 +1,85 @@ +import { test, expect, describe } from "bun:test"; +import { PlapiError } from "./errors.ts"; + +describe("ApiError envelope parsing (via PlapiError.fromBody)", () => { + test("parses a standard single-error envelope", () => { + const body = JSON.stringify({ + errors: [ + { + code: "production_instance_exists", + message: "You can only have one production instance.", + long_message: "Each application can have at most one production instance.", + meta: { application_id: "app_123" }, + }, + ], + clerk_trace_id: "trace_abc", + }); + const err = PlapiError.fromBody(400, body, "https://api.example.com/x"); + expect(err.status).toBe(400); + expect(err.code).toBe("production_instance_exists"); + expect(err.message).toBe("You can only have one production instance."); + expect(err.longMessage).toBe("Each application can have at most one production instance."); + expect(err.meta).toEqual({ application_id: "app_123" }); + expect(err.clerkTraceId).toBe("trace_abc"); + expect(err.body).toBe(body); + expect(err.url).toBe("https://api.example.com/x"); + }); + + test("uses the first entry on a multi-error envelope", () => { + const body = JSON.stringify({ + errors: [ + { code: "first", message: "First problem" }, + { code: "second", message: "Second problem" }, + ], + }); + const err = PlapiError.fromBody(400, body); + expect(err.code).toBe("first"); + expect(err.message).toBe("First problem"); + }); + + test("populates nullable fields when optional envelope keys are absent", () => { + const body = JSON.stringify({ errors: [{ code: "x", message: "y" }] }); + const err = PlapiError.fromBody(400, body); + expect(err.longMessage).toBeNull(); + expect(err.meta).toBeNull(); + expect(err.clerkTraceId).toBeNull(); + }); + + test("falls back gracefully on non-JSON bodies", () => { + const err = PlapiError.fromBody(500, "proxy error"); + expect(err.code).toBeNull(); + expect(err.message).toBe("proxy error"); + expect(err.longMessage).toBeNull(); + expect(err.meta).toBeNull(); + }); + + test("truncates very long non-JSON bodies in the message", () => { + const longBody = "x".repeat(500); + const err = PlapiError.fromBody(500, longBody); + expect(err.code).toBeNull(); + expect(err.message).toBe("x".repeat(200) + "..."); + expect(err.body).toBe(longBody); + }); + + test("falls back when body is JSON but not a Clerk envelope", () => { + const body = JSON.stringify({ unrelated: "shape" }); + const err = PlapiError.fromBody(400, body); + expect(err.code).toBeNull(); + expect(err.message).toBe(body); + expect(err.meta).toBeNull(); + }); + + test("falls back when body is an empty string", () => { + const err = PlapiError.fromBody(500, ""); + expect(err.code).toBeNull(); + expect(err.message).toBe("API error (500)"); + }); + + test("preserves an empty meta object rather than coercing to null", () => { + const body = JSON.stringify({ + errors: [{ code: "x", message: "y", meta: {} }], + }); + const err = PlapiError.fromBody(400, body); + expect(err.meta).toEqual({}); + }); +}); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index ef577503..2b79a976 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -151,6 +151,67 @@ export class UserAbortError extends Error { } } +interface ClerkErrorEntry { + code?: string; + message?: string; + long_message?: string; + meta?: Record; +} + +interface ClerkErrorEnvelope { + errors?: ClerkErrorEntry[]; + clerk_trace_id?: string; +} + +interface ParsedApiBody { + code: string | null; + message: string; + longMessage: string | null; + meta: Record | null; + clerkTraceId: string | null; +} + +const MAX_BODY_PREVIEW = 200; + +function truncateBody(body: string): string { + return body.length > MAX_BODY_PREVIEW ? body.slice(0, MAX_BODY_PREVIEW) + "..." : body; +} + +function parseApiBody(status: number, body: string): ParsedApiBody { + const fallback = (msg: string): ParsedApiBody => ({ + code: null, + message: msg, + longMessage: null, + meta: null, + clerkTraceId: null, + }); + + if (body.length === 0) { + return fallback(`API error (${status})`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return fallback(truncateBody(body)); + } + + const envelope = parsed as ClerkErrorEnvelope; + const first = envelope.errors?.[0]; + if (!first) { + return fallback(truncateBody(body)); + } + + return { + code: first.code ?? null, + message: first.message ?? `API error (${status})`, + longMessage: first.long_message ?? null, + meta: first.meta ?? null, + clerkTraceId: envelope.clerk_trace_id ?? null, + }; +} + /** * Base class for HTTP API errors. * @@ -165,14 +226,23 @@ export class UserAbortError extends Error { */ export class ApiError extends Error { public context?: string; + public code: string | null; + public longMessage: string | null; + public meta: Record | null; + public clerkTraceId: string | null; constructor( public status: number, public body: string, public headers?: Headers, ) { - super(`API error (${status}): ${body}`); + const parsed = parseApiBody(status, body); + super(parsed.message); this.name = "ApiError"; + this.code = parsed.code; + this.longMessage = parsed.longMessage; + this.meta = parsed.meta; + this.clerkTraceId = parsed.clerkTraceId; } } @@ -195,6 +265,10 @@ export class PlapiError extends ApiError { super(status, body); this.name = "PlapiError"; } + + static fromBody(status: number, body: string, url?: string): PlapiError { + return new PlapiError(status, body, url); + } } /** @@ -218,6 +292,10 @@ export class FapiError extends ApiError { super(status, body); this.name = "FapiError"; } + + static fromBody(status: number, body: string, url?: string): FapiError { + return new FapiError(status, body, url); + } } /** @@ -238,6 +316,10 @@ export class BapiError extends ApiError { super(status, body, headers); this.name = "BapiError"; } + + static fromBody(status: number, body: string, headers: Headers): BapiError { + return new BapiError(status, body, headers); + } } export function isAuthError(error: unknown): error is AuthError | ApiError { diff --git a/packages/cli-core/src/lib/token-exchange.test.ts b/packages/cli-core/src/lib/token-exchange.test.ts index dd3c0264..e3de0e30 100644 --- a/packages/cli-core/src/lib/token-exchange.test.ts +++ b/packages/cli-core/src/lib/token-exchange.test.ts @@ -75,7 +75,7 @@ describe("exchangeCodeForToken", () => { codeVerifier: "verifier", redirectUri: "http://127.0.0.1:3000/callback", }), - ).rejects.toThrow("API error (400)"); + ).rejects.toThrow("invalid_grant"); }); test("includes error body in thrown message", async () => { @@ -126,7 +126,7 @@ describe("fetchUserInfo", () => { return new Response("Unauthorized", { status: 401 }); }) as unknown as typeof fetch; - await expect(fetchUserInfo("expired-token")).rejects.toThrow("API error (401)"); + await expect(fetchUserInfo("expired-token")).rejects.toThrow("Unauthorized"); }); test("includes response body in error message", async () => { From c4ea684fcff041ef4f45b65b173105bb165ea485 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:44:10 -0600 Subject: [PATCH 2/9] feat(errors): add fromResponse factories to ApiError subclasses --- packages/cli-core/src/lib/errors.test.ts | 57 +++++++++++++++++++++++- packages/cli-core/src/lib/errors.ts | 15 +++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/errors.test.ts b/packages/cli-core/src/lib/errors.test.ts index 1a3129b2..239ec410 100644 --- a/packages/cli-core/src/lib/errors.test.ts +++ b/packages/cli-core/src/lib/errors.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test"; -import { PlapiError } from "./errors.ts"; +import { PlapiError, FapiError, BapiError } from "./errors.ts"; describe("ApiError envelope parsing (via PlapiError.fromBody)", () => { test("parses a standard single-error envelope", () => { @@ -83,3 +83,58 @@ describe("ApiError envelope parsing (via PlapiError.fromBody)", () => { expect(err.meta).toEqual({}); }); }); + +describe("PlapiError factories", () => { + test("fromResponse reads body, status, and response.url", async () => { + const body = JSON.stringify({ errors: [{ code: "x", message: "y" }] }); + const response = new Response(body, { + status: 422, + headers: { "content-type": "application/json" }, + }); + Object.defineProperty(response, "url", { + value: "https://api.example.com/r", + }); + const err = await PlapiError.fromResponse(response); + expect(err).toBeInstanceOf(PlapiError); + expect(err.status).toBe(422); + expect(err.code).toBe("x"); + expect(err.url).toBe("https://api.example.com/r"); + }); + + test("fromBody is synchronous and accepts an optional url", () => { + const err = PlapiError.fromBody(500, "boom", "https://api.example.com/r"); + expect(err).toBeInstanceOf(PlapiError); + expect(err.url).toBe("https://api.example.com/r"); + expect(err.code).toBeNull(); + }); +}); + +describe("FapiError factories", () => { + test("fromResponse reads body, status, and response.url", async () => { + const response = new Response("not json", { status: 503 }); + Object.defineProperty(response, "url", { value: "https://fapi.example.com/x" }); + const err = await FapiError.fromResponse(response); + expect(err).toBeInstanceOf(FapiError); + expect(err.status).toBe(503); + expect(err.url).toBe("https://fapi.example.com/x"); + }); +}); + +describe("BapiError factories", () => { + test("fromResponse captures headers", async () => { + const response = new Response("err", { + status: 400, + headers: { "x-clerk-trace": "abc" }, + }); + const err = await BapiError.fromResponse(response); + expect(err).toBeInstanceOf(BapiError); + expect(err.headers.get("x-clerk-trace")).toBe("abc"); + }); + + test("fromBody requires headers explicitly", () => { + const headers = new Headers({ "x-y": "z" }); + const err = BapiError.fromBody(400, "boom", headers); + expect(err).toBeInstanceOf(BapiError); + expect(err.headers.get("x-y")).toBe("z"); + }); +}); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 2b79a976..929417a0 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -269,6 +269,11 @@ export class PlapiError extends ApiError { static fromBody(status: number, body: string, url?: string): PlapiError { return new PlapiError(status, body, url); } + + static async fromResponse(response: Response): Promise { + const body = await response.text(); + return new PlapiError(response.status, body, response.url || undefined); + } } /** @@ -296,6 +301,11 @@ export class FapiError extends ApiError { static fromBody(status: number, body: string, url?: string): FapiError { return new FapiError(status, body, url); } + + static async fromResponse(response: Response): Promise { + const body = await response.text(); + return new FapiError(response.status, body, response.url || undefined); + } } /** @@ -320,6 +330,11 @@ export class BapiError extends ApiError { static fromBody(status: number, body: string, headers: Headers): BapiError { return new BapiError(status, body, headers); } + + static async fromResponse(response: Response): Promise { + const body = await response.text(); + return new BapiError(response.status, body, response.headers); + } } export function isAuthError(error: unknown): error is AuthError | ApiError { From 34ce50e2e0fffbb110d0e2c6e764cef2bd6d679d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:45:10 -0600 Subject: [PATCH 3/9] refactor(plapi): construct PlapiError via fromResponse --- packages/cli-core/src/lib/plapi.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 3835570e..5a9ca3ac 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -83,8 +83,7 @@ async function plapiFetch(method: string, url: URL, init?: { body?: string }): P body: init?.body, }); if (!response.ok) { - const body = await response.text(); - throw new PlapiError(response.status, body, url.toString()); + throw await PlapiError.fromResponse(response); } return response; } From 0d2900168f3287c673d09ab172256a70e1b8366d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:45:24 -0600 Subject: [PATCH 4/9] refactor(fapi): construct FapiError via fromResponse --- packages/cli-core/src/lib/fapi.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/fapi.ts b/packages/cli-core/src/lib/fapi.ts index abc56b65..7b9ad8d9 100644 --- a/packages/cli-core/src/lib/fapi.ts +++ b/packages/cli-core/src/lib/fapi.ts @@ -75,8 +75,7 @@ export type { UserSettingsJSON }; async function fapiFetch(method: "GET" | "POST", url: URL): Promise { const response = await loggedFetch(url, { tag: "fapi", method }); if (!response.ok) { - const body = await response.text(); - throw new FapiError(response.status, body, url.toString()); + throw await FapiError.fromResponse(response); } return response; } From 02edb770188b870eb54898507464cf76b0e4695e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:45:41 -0600 Subject: [PATCH 5/9] refactor(keyless): construct BapiError via fromResponse --- packages/cli-core/src/lib/keyless.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index 2ccfea89..b7fff390 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -59,8 +59,7 @@ export async function createAccountlessApp(framework?: string): Promise; From 0fa9a875991e1678f5702cdf3e1148c43480a5f3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:45:57 -0600 Subject: [PATCH 6/9] refactor(bapi): construct BapiError via fromResponse --- packages/cli-core/src/commands/api/bapi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/api/bapi.ts b/packages/cli-core/src/commands/api/bapi.ts index a4860a6f..7b500951 100644 --- a/packages/cli-core/src/commands/api/bapi.ts +++ b/packages/cli-core/src/commands/api/bapi.ts @@ -43,12 +43,12 @@ export async function bapiRequest(options: { body: options.body, }); - const rawBody = await response.text(); - if (!response.ok) { - throw new BapiError(response.status, rawBody, response.headers); + throw await BapiError.fromResponse(response); } + const rawBody = await response.text(); + let body: unknown; try { body = JSON.parse(rawBody); From 89fb2ea284a459c72831e607267f23b40b2e7d54 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:46:10 -0600 Subject: [PATCH 7/9] refactor(users): construct BapiError via fromResponse --- .../src/commands/users/interactive/instance-context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts index 5e9bcf41..f57619c0 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -51,10 +51,10 @@ async function fetchCurrentBapiInstance(secretKey: string): Promise Date: Tue, 12 May 2026 20:47:59 -0600 Subject: [PATCH 8/9] test: construct ApiError subclasses via fromBody in fixtures --- packages/cli-core/src/commands/doctor/doctor.test.ts | 2 +- packages/cli-core/src/commands/link/index.test.ts | 8 ++++---- packages/cli-core/src/commands/users/create.test.ts | 2 +- packages/cli-core/src/lib/bapi-command.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index bad4c48b..cfc17466 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -346,7 +346,7 @@ describe("checkLinkedAppExists", () => { const ctx = createMockContext({ token: "test_token", profile: mockProfile, - applicationError: new PlapiError(404, "Not found"), + applicationError: PlapiError.fromBody(404, "Not found"), }); const result = await checkLinkedAppExists(ctx); expectCheck(result, { diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index e7627af7..2ca19644 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -587,7 +587,7 @@ describe("link", () => { test("shows picker with only create option when listApplications fails with 500", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); - mockListApplications.mockRejectedValue(new PlapiError(500, "Internal Server Error")); + mockListApplications.mockRejectedValue(PlapiError.fromBody(500, "Internal Server Error")); mockSearch.mockImplementation( async (config: { source: (term: string | undefined) => { name: string; value: string }[]; @@ -622,7 +622,7 @@ describe("link", () => { test("propagates listApplications errors that are not 5xx", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); - mockListApplications.mockRejectedValue(new PlapiError(401, "Unauthorized")); + mockListApplications.mockRejectedValue(PlapiError.fromBody(401, "Unauthorized")); await expect(runLink()).rejects.toBeInstanceOf(PlapiError); }); @@ -1248,7 +1248,7 @@ describe("link", () => { mockListApplications.mockResolvedValue([mockApp]); mockSearch.mockResolvedValue("__create_new__"); mockInput.mockResolvedValue("My App"); - mockCreateApplication.mockRejectedValue(new PlapiError(422, "Unprocessable Entity")); + mockCreateApplication.mockRejectedValue(PlapiError.fromBody(422, "Unprocessable Entity")); await expect(link()).rejects.toBeInstanceOf(PlapiError); expect(mockSetProfile).not.toHaveBeenCalled(); @@ -1265,7 +1265,7 @@ describe("link", () => { name: "My App", instances: [], }); - mockFetchApplication.mockRejectedValue(new PlapiError(503, "Service Unavailable")); + mockFetchApplication.mockRejectedValue(PlapiError.fromBody(503, "Service Unavailable")); await expect(link()).rejects.toBeInstanceOf(PlapiError); expect(mockSetProfile).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index f19a1c84..972a2cdd 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -171,7 +171,7 @@ describe("users create", () => { test("prints raw BAPI validation errors to stdout for machine use", async () => { mockHandleBapiError.mockImplementation((error: unknown) => error instanceof BapiError); mockBapiRequest.mockRejectedValue( - new BapiError( + BapiError.fromBody( 422, JSON.stringify({ errors: [ diff --git a/packages/cli-core/src/lib/bapi-command.test.ts b/packages/cli-core/src/lib/bapi-command.test.ts index af421b2a..1699c69b 100644 --- a/packages/cli-core/src/lib/bapi-command.test.ts +++ b/packages/cli-core/src/lib/bapi-command.test.ts @@ -43,7 +43,7 @@ describe("bapi-command", () => { const handled = await captured.run(() => Promise.resolve( handleBapiError( - new BapiError( + BapiError.fromBody( 422, JSON.stringify({ errors: [ From 93d7a24450d86a353e52b89c20fc8bdb0c194184 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:51:12 -0600 Subject: [PATCH 9/9] refactor(cli): read structured ApiError fields in the global handler Replace formatApiBody(body: string) with formatApiBody(error: ApiError), deleting extractApiErrorCode, extractApiErrors, and formatSingleError. The new formatStructuredError reads code/message/meta directly from the parsed ApiError instance. The agent path builds ApiErrorEntry inline from structured fields and surfaces clerkTraceId in verbose human mode. Tests updated to construct ApiError instances and reflect single-error output for multi-error bodies. --- packages/cli-core/src/cli-program.test.ts | 61 ++++++++--------- packages/cli-core/src/cli-program.ts | 82 +++++++---------------- 2 files changed, 54 insertions(+), 89 deletions(-) diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 3dc13520..a0769a99 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createProgram, formatApiBody } from "./cli-program.ts"; +import { ApiError } from "./lib/errors.ts"; import { STANDARD_AGENT_DIRS, EXTRA_REL_PATHS } from "./lib/skill-detection.ts"; test("registers users as a top-level command", () => { @@ -140,7 +141,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("Your plan does not support these features"); expect(result).toContain("Unsupported features: saml, custom_roles"); }); @@ -155,7 +156,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("Unknown config key: sesion"); expect(result).toContain("Did you mean: session"); expect(result).toContain("Parameter: sesion"); @@ -171,7 +172,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("This feature is not enabled on this instance"); expect(result).toContain("Feature: organizations"); }); @@ -186,7 +187,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("Invalid value for session.lifetime"); expect(result).toContain("Parameter: session.lifetime"); }); @@ -201,7 +202,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("Cannot clear this key"); expect(result).toContain("Parameter: sign_up.mode"); }); @@ -216,14 +217,15 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("Value is not in the allowed set"); expect(result).toContain("Parameter: branding.logo_url"); }); // --- Multiple errors --- + // The structured path reads from the first parsed error only. - test("formats multiple errors joined by newlines", () => { + test("formats multiple errors: surfaces first error with its meta", () => { const body = JSON.stringify({ errors: [ { @@ -238,13 +240,9 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toContain("Invalid session lifetime"); - expect(result).toContain("Unknown key: bogus"); - expect(result).toContain("Did you mean: session"); - // Two errors separated by newline - const lines = result.split("\n"); - expect(lines.length).toBeGreaterThanOrEqual(2); + expect(result).toContain("Parameter: session.lifetime"); }); // --- Error without meta --- @@ -253,32 +251,34 @@ describe("formatApiBody", () => { const body = JSON.stringify({ errors: [{ code: "resource_not_found", message: "Instance not found" }], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toBe("Instance not found"); }); - // --- Fallback paths --- + // --- Bodies without a Clerk errors array --- + // parseApiBody falls back to truncateBody(body) as the message when there + // is no errors[0], so formatStructuredError returns the truncated body string. - test("falls back to parsed.error when no errors array", () => { + test("returns truncated body when no errors array (error field only)", () => { const body = JSON.stringify({ error: "Something went wrong" }); - const result = formatApiBody(body, false); - expect(result).toBe("Something went wrong"); + const result = formatApiBody(new ApiError(400, body), false); + expect(result).toBe(body); }); - test("falls back to parsed.message when no errors array or error field", () => { + test("returns truncated body when no errors array (message field only)", () => { const body = JSON.stringify({ message: "Bad request" }); - const result = formatApiBody(body, false); - expect(result).toBe("Bad request"); + const result = formatApiBody(new ApiError(400, body), false); + expect(result).toBe(body); }); test("truncates non-JSON body over 200 chars", () => { const body = "x".repeat(300); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toBe("x".repeat(200) + "..."); }); test("returns short non-JSON body as-is", () => { - const result = formatApiBody("Bad Request", false); + const result = formatApiBody(new ApiError(400, "Bad Request"), false); expect(result).toBe("Bad Request"); }); @@ -287,28 +287,29 @@ describe("formatApiBody", () => { test("verbose mode returns full pretty-printed JSON", () => { const obj = { errors: [{ code: "test", message: "test msg" }] }; const body = JSON.stringify(obj); - const result = formatApiBody(body, true); + const result = formatApiBody(new ApiError(400, body), true); expect(result).toBe("\n" + JSON.stringify(obj, null, 2)); }); test("verbose mode returns raw body for non-JSON", () => { - const result = formatApiBody("not json", true); + const result = formatApiBody(new ApiError(400, "not json"), true); expect(result).toBe("\nnot json"); }); // --- Edge cases --- - test("handles empty errors array by falling through", () => { + test("handles empty errors array by returning truncated body", () => { const body = JSON.stringify({ errors: [], message: "fallback" }); - const result = formatApiBody(body, false); - expect(result).toBe("fallback"); + const result = formatApiBody(new ApiError(400, body), false); + // No errors[0] so parseApiBody falls back to truncateBody(body) + expect(result).toBe(body); }); test("handles error with empty meta", () => { const body = JSON.stringify({ errors: [{ code: "config_validation_error", message: "Bad value", meta: {} }], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toBe("Bad value"); }); @@ -322,7 +323,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(body, false); + const result = formatApiBody(new ApiError(400, body), false); expect(result).toBe("Plan limitation"); }); }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a406fdcb..21d91cb4 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -922,40 +922,23 @@ Tutorial — enable completions for your shell: return program; } -export function formatApiBody(body: string, verbose: boolean): string { +export function formatApiBody(error: ApiError, verbose: boolean): string { if (verbose) { try { - return "\n" + JSON.stringify(JSON.parse(body), null, 2); + return "\n" + JSON.stringify(JSON.parse(error.body), null, 2); } catch { - return "\n" + body; + return "\n" + error.body; } } - - try { - const parsed = JSON.parse(body); - if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { - return parsed.errors.map(formatSingleError).join("\n"); - } - if (parsed.error) return parsed.error; - if (parsed.message) return parsed.message; - } catch { - // not JSON - } - - if (body.length > 200) return body.slice(0, 200) + "..."; - return body; + return formatStructuredError(error); } -function formatSingleError(err: { - message?: string; - code?: string; - meta?: Record; -}): string { - let msg = err.message ?? "Unknown error"; - const meta = err.meta; +function formatStructuredError(error: ApiError): string { + let msg = error.message; + const { meta, code } = error; if (!meta) return msg; - switch (err.code) { + switch (code) { case "unsupported_subscription_plan_features": { const features = meta.unsupported_features; if (Array.isArray(features) && features.length > 0) { @@ -986,7 +969,6 @@ function formatSingleError(err: { break; } } - return msg; } @@ -1041,13 +1023,21 @@ export async function runProgram( } if (error instanceof ApiError) { - const detail = formatApiBody(error.body, verbose); + const detail = formatApiBody(error, verbose); const prefix = error.context ?? "Request failed"; if (isAgent()) { - const apiCode = extractApiErrorCode(error.body); - const apiErrors = extractApiErrors(error.body); + const apiErrors: ApiErrorEntry[] | undefined = + error.code || error.meta + ? [ + { + ...(error.code ? { code: error.code } : {}), + ...(error.message ? { message: error.message } : {}), + ...(error.meta ? { meta: error.meta } : {}), + }, + ] + : undefined; outputJsonError( - apiCode ?? "api_error", + error.code ?? "api_error", `${prefix} (${error.status}): ${detail}`, undefined, apiErrors, @@ -1057,6 +1047,9 @@ export async function runProgram( if (verbose && (error instanceof PlapiError || error instanceof FapiError) && error.url) { log.error(` URL: ${error.url}`); } + if (verbose && error.clerkTraceId) { + log.error(` Trace: ${error.clerkTraceId}`); + } } process.exit(EXIT_CODE.GENERAL); } @@ -1106,32 +1099,3 @@ function outputJsonError( if (errors?.length) payload.error.errors = errors; log.raw(JSON.stringify(payload)); } - -/** Extract the error code from a Clerk API JSON response body, if present. */ -function extractApiErrorCode(body: string): string | undefined { - try { - const parsed = JSON.parse(body); - return parsed.errors?.[0]?.code; - } catch { - return undefined; - } -} - -/** Extract the full errors array from a Clerk API JSON response body, if present. */ -function extractApiErrors(body: string): ApiErrorEntry[] | undefined { - try { - const parsed = JSON.parse(body); - if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { - return parsed.errors.map((e: ApiErrorEntry) => { - const entry: ApiErrorEntry = {}; - if (e.code) entry.code = e.code; - if (e.message) entry.message = e.message; - if (e.meta && Object.keys(e.meta).length > 0) entry.meta = e.meta; - return entry; - }); - } - } catch { - // not JSON - } - return undefined; -}