Skip to content
Open
61 changes: 31 additions & 30 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
Expand All @@ -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");
Expand All @@ -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");
});
Expand All @@ -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");
});
Expand All @@ -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");
});
Expand All @@ -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: [
{
Expand All @@ -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 ---
Expand All @@ -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");
});

Expand All @@ -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");
});

Expand All @@ -322,7 +323,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(body, false);
const result = formatApiBody(new ApiError(400, body), false);
expect(result).toBe("Plan limitation");
});
});
Expand Down
82 changes: 23 additions & 59 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>;
}): 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) {
Expand Down Expand Up @@ -986,7 +969,6 @@ function formatSingleError(err: {
break;
}
}

return msg;
}

Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
}
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/api/bapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,6 @@ describe("config pull", () => {
instances: { development: "ins_dev" },
});

await expect(runConfigPull()).rejects.toThrow("API error");
await expect(runConfigPull()).rejects.toThrow("Unauthorized");
});
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,6 @@ describe("config schema", () => {
instances: { development: "ins_dev" },
});

await expect(runConfigSchema()).rejects.toThrow("API error");
await expect(runConfigSchema()).rejects.toThrow("Unauthorized");
});
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/doctor/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/env/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli-core/src/commands/link/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Loading