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; -} 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); 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/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/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/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/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 { const handled = await captured.run(() => Promise.resolve( handleBapiError( - new BapiError( + BapiError.fromBody( 422, JSON.stringify({ errors: [ 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..239ec410 --- /dev/null +++ b/packages/cli-core/src/lib/errors.test.ts @@ -0,0 +1,140 @@ +import { test, expect, describe } from "bun:test"; +import { PlapiError, FapiError, BapiError } 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({}); + }); +}); + +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 ef577503..929417a0 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,15 @@ 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); + } + + static async fromResponse(response: Response): Promise { + const body = await response.text(); + return new PlapiError(response.status, body, response.url || undefined); + } } /** @@ -218,6 +297,15 @@ 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); + } + + static async fromResponse(response: Response): Promise { + const body = await response.text(); + return new FapiError(response.status, body, response.url || undefined); + } } /** @@ -238,6 +326,15 @@ 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); + } + + 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 { 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; } 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; 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; } 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 () => {