From f8567fccdfb16e111ed68ea64396a100342965f5 Mon Sep 17 00:00:00 2001 From: Zach Caceres Date: Thu, 19 Mar 2026 08:33:35 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20CLI=20bug=20fixes=20=E2=80=94=20schema?= =?UTF-8?q?=20drift,=20error=20handling,=20strict=20parsing,=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 8 bugs found via chaos monkey testing: - Add Followers, Employees, ProductCount to domain schema (Bug 1) - Detect API errors returned as HTTP 200 with Errors array (Bug 2) - Enable strict flag parsing to reject typos (Bug 3) - Only check --version at argv[0] position (Bug 4) - Reject single-dash args as primary values (Bug 6) - Handle empty object arrays in formatTable (Bug 7) - Show global flags in per-command help (Bug 8) Update docs for new error handling behavior. --- CLAUDE.md | 2 +- docs/guide/library.md | 7 +++- packages/builtwith-api/src/cli.ts | 24 +++++++++---- packages/builtwith-api/src/format.ts | 1 + packages/builtwith-api/src/request.ts | 24 +++++++++++-- packages/builtwith-api/src/schemas.ts | 3 ++ packages/builtwith-api/test/cli.test.ts | 40 +++++++++++++++++++++ packages/builtwith-api/test/format.test.ts | 12 +++++++ packages/builtwith-api/test/request.test.ts | 31 ++++++++++++++++ packages/builtwith-api/test/schemas.test.ts | 2 ++ 10 files changed, 135 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 977c306..bf98881 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ packages/ schemas.ts — Zod schemas for inputs + responses, BuiltWithClient interface commands.ts — Command registry shared by CLI and MCP params.ts — URL building, query string, boolean flag mapping - request.ts — HTTP request + Zod validation + request.ts — HTTP request, API error detection + Zod validation errors.ts — Error formatting (Zod + generic) config.ts — Constants (response format enum) cli.ts — CLI entry point diff --git a/docs/guide/library.md b/docs/guide/library.md index 8beebfb..927b4b9 100644 --- a/docs/guide/library.md +++ b/docs/guide/library.md @@ -202,13 +202,18 @@ try { const result = await client.free("example.com"); } catch (err) { if (err instanceof Error) { - // API errors: "BuiltWith API error 401: ..." + // HTTP errors: "BuiltWith API error 401: ..." + // BuiltWith errors (e.g. bad key): "BuiltWith API error: API Key is incorrect" // Validation errors: ZodError with detailed field info console.error(err.message); } } ``` +::: tip +BuiltWith sometimes returns errors as HTTP 200 with a JSON `{"Errors":[...]}` body. The client detects this and throws a clear error message instead of a confusing Zod validation failure. +::: + ## Rate Limits BuiltWith enforces two types of limits: diff --git a/packages/builtwith-api/src/cli.ts b/packages/builtwith-api/src/cli.ts index 54fec6c..8edc33b 100644 --- a/packages/builtwith-api/src/cli.ts +++ b/packages/builtwith-api/src/cli.ts @@ -39,13 +39,17 @@ function printCommandHelp(cmd: (typeof commands)[number]): void { for (const f of flags) { console.log(` --${f.name.padEnd(28)} ${f.description} (${f.type})`); } + console.log(); } + console.log("Global options:"); + console.log(` --api-key BuiltWith API key (or set BUILTWITH_API_KEY env var)`); + console.log(` --table Pretty-print output as a readable table instead of JSON`); } function run(): void { const argv = process.argv.slice(2); - if (argv.includes("--version") || argv.includes("-V")) { + if (argv[0] === "--version" || argv[0] === "-V") { console.log(version); process.exit(0); } @@ -72,7 +76,7 @@ function run(): void { const primaryArg = cmd.args.find((a) => a.required); const primaryValue = argv[1]; - if (primaryArg && (!primaryValue || primaryValue.startsWith("--"))) { + if (primaryArg && (!primaryValue || primaryValue.startsWith("-"))) { console.error(`Error: missing required argument <${primaryArg.name}>`); process.exit(1); } @@ -87,11 +91,17 @@ function run(): void { options[f.name] = { type: f.type === "boolean" ? "boolean" : "string" }; } - const { values } = parseArgs({ - args: argv.slice(2), // skip command + primary arg - options, - strict: false, - }); + let values: Record; + try { + ({ values } = parseArgs({ + args: argv.slice(2), // skip command + primary arg + options, + strict: true, + })); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } const useTable = Boolean(values.table); const apiKey = (values["api-key"] as string) || process.env.BUILTWITH_API_KEY; diff --git a/packages/builtwith-api/src/format.ts b/packages/builtwith-api/src/format.ts index 750a6ce..43dcfc8 100644 --- a/packages/builtwith-api/src/format.ts +++ b/packages/builtwith-api/src/format.ts @@ -15,6 +15,7 @@ export function formatTable(data: unknown, indent = 0): string { ); if (allFlatObjects) { const keys = [...new Set(data.flatMap((item) => Object.keys(item as Record)))]; + if (keys.length === 0) return `${pad}(${data.length} empty items)`; const widths = keys.map((k) => Math.max(k.length, ...data.map((item) => String((item as Record)[k] ?? "").length)), ); diff --git a/packages/builtwith-api/src/request.ts b/packages/builtwith-api/src/request.ts index bc0d1c4..caea7d5 100644 --- a/packages/builtwith-api/src/request.ts +++ b/packages/builtwith-api/src/request.ts @@ -1,4 +1,4 @@ -import type { z } from "zod/v4"; +import { z } from "zod/v4"; import { VALID_RESPONSE_TYPES } from "./config.js"; import type { ResponseFormat } from "./schemas.js"; @@ -9,6 +9,18 @@ const TEXT_FORMATS: readonly ResponseFormat[] = [ VALID_RESPONSE_TYPES.TSV, ]; +const ApiErrorSchema = z.object({ + Errors: z.array(z.object({ Message: z.string() })), +}); + +function checkForApiError(data: unknown): void { + const parsed = ApiErrorSchema.safeParse(data); + if (parsed.success && parsed.data.Errors.length > 0) { + const messages = parsed.data.Errors.map((e) => e.Message).join("; "); + throw new Error(`BuiltWith API error: ${messages}`); + } +} + export const request = async (url: string, format: ResponseFormat, schema: z.ZodType): Promise => { const res = await fetch(url); if (!res.ok) { @@ -18,7 +30,14 @@ export const request = async (url: string, format: ResponseFormat, schema: z. if (TEXT_FORMATS.includes(format)) { return res.text(); } - const data: unknown = await res.json(); + const raw = await res.text(); + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + throw new Error(`BuiltWith returned invalid JSON: ${raw.slice(0, 200)}`); + } + checkForApiError(data); return schema.parse(data); }; @@ -43,5 +62,6 @@ export const requestSafe = async ( console.warn("BuiltWith sent an invalid JSON payload. Falling back to text parsing."); return raw; } + checkForApiError(data); return schema.parse(data); }; diff --git a/packages/builtwith-api/src/schemas.ts b/packages/builtwith-api/src/schemas.ts index 6dfd41f..25d0e64 100644 --- a/packages/builtwith-api/src/schemas.ts +++ b/packages/builtwith-api/src/schemas.ts @@ -204,6 +204,9 @@ const AttributesSchema = z.strictObject({ CDimensions: z.number(), CGoals: z.number(), CMetrics: z.number(), + Followers: z.number(), + Employees: z.number(), + ProductCount: z.number().optional(), }); /** Validation schema for {@link DomainResponse}. */ diff --git a/packages/builtwith-api/test/cli.test.ts b/packages/builtwith-api/test/cli.test.ts index bf0f0cd..03182da 100644 --- a/packages/builtwith-api/test/cli.test.ts +++ b/packages/builtwith-api/test/cli.test.ts @@ -152,6 +152,46 @@ describe("CLI", () => { }); }); + describe("strict flag parsing (Bug 3)", () => { + it("rejects unknown flags (typos)", async () => { + const { stderr, exitCode } = await cli(["domain", "x.com", "--onlyLiveTechnologes", "--api-key", "test"]); + expect(stderr).toContain("Error:"); + expect(exitCode).toBe(1); + }); + + it("rejects string flags without a value (Bug 5)", async () => { + const { stderr, exitCode } = await cli(["domain", "x.com", "--api-key"]); + expect(stderr).toContain("Error:"); + expect(exitCode).toBe(1); + }); + }); + + describe("--version only at position 0 (Bug 4)", () => { + it("does not treat --version as a version flag when after a command", async () => { + const { stdout, exitCode } = await cli(["domain", "x.com", "--version", "--api-key", "test"]); + // Should NOT print version string — should either error or attempt the command + expect(stdout.trim()).not.toMatch(/^\d+\.\d+\.\d+$/); + expect(exitCode).not.toBe(0); + }); + }); + + describe("single-dash args rejected as primary value (Bug 6)", () => { + it("rejects -x as a primary argument", async () => { + const { stderr, exitCode } = await cli(["free", "-x", "--api-key", "test"]); + expect(stderr).toContain("missing required argument"); + expect(exitCode).toBe(1); + }); + }); + + describe("per-command help shows global flags (Bug 8)", () => { + it("shows --api-key and --table in command help", async () => { + const { stdout } = await cli(["domain", "--help"]); + expect(stdout).toContain("Global options:"); + expect(stdout).toContain("--api-key"); + expect(stdout).toContain("--table"); + }); + }); + describe("error formatting", () => { it("formats API errors cleanly, not raw JSON", async () => { const { stderr } = await cli(["free", "example.com", "--api-key", "badkey"]); diff --git a/packages/builtwith-api/test/format.test.ts b/packages/builtwith-api/test/format.test.ts index cac40d4..784aeca 100644 --- a/packages/builtwith-api/test/format.test.ts +++ b/packages/builtwith-api/test/format.test.ts @@ -95,6 +95,18 @@ describe("formatTable", () => { }); }); + describe("arrays of empty objects (Bug 7)", () => { + it("renders count instead of blank output", () => { + const result = formatTable([{}, {}, {}]); + expect(result).toContain("3 empty items"); + }); + + it("renders count for single empty object", () => { + const result = formatTable([{}]); + expect(result).toContain("1 empty items"); + }); + }); + describe("arrays of non-objects", () => { it("renders indexed entries for mixed arrays", () => { const result = formatTable(["hello", "world"]); diff --git a/packages/builtwith-api/test/request.test.ts b/packages/builtwith-api/test/request.test.ts index 754f278..8b4c99b 100644 --- a/packages/builtwith-api/test/request.test.ts +++ b/packages/builtwith-api/test/request.test.ts @@ -85,6 +85,29 @@ describe("request", () => { globalThis.fetch = mock(() => Promise.reject(new TypeError("fetch failed"))); await expect(request("https://api.example.com/test", "json", TestSchema)).rejects.toThrow("fetch failed"); }); + + it("throws clear error for API error returned as HTTP 200", async () => { + const apiError = { Errors: [{ Message: "API Key is incorrect" }] }; + globalThis.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(apiError), { status: 200 }))); + await expect(request("https://api.example.com/test", "json", TestSchema)).rejects.toThrow( + "BuiltWith API error: API Key is incorrect", + ); + }); + + it("throws clear error for multiple API errors as HTTP 200", async () => { + const apiError = { Errors: [{ Message: "Error one" }, { Message: "Error two" }] }; + globalThis.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(apiError), { status: 200 }))); + await expect(request("https://api.example.com/test", "json", TestSchema)).rejects.toThrow( + "BuiltWith API error: Error one; Error two", + ); + }); + + it("throws on invalid JSON with descriptive message", async () => { + globalThis.fetch = mock(() => Promise.resolve(new Response("this is not json {{{", { status: 200 }))); + await expect(request("https://api.example.com/test", "json", TestSchema)).rejects.toThrow( + "BuiltWith returned invalid JSON", + ); + }); }); describe("requestSafe", () => { @@ -153,4 +176,12 @@ describe("requestSafe", () => { "DNS resolution failed", ); }); + + it("throws clear error for API error returned as HTTP 200", async () => { + const apiError = { Errors: [{ Message: "API Key is incorrect" }] }; + globalThis.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(apiError), { status: 200 }))); + await expect(requestSafe("https://api.example.com/test", "json", TestSchema)).rejects.toThrow( + "BuiltWith API error: API Key is incorrect", + ); + }); }); diff --git a/packages/builtwith-api/test/schemas.test.ts b/packages/builtwith-api/test/schemas.test.ts index a1d3fe1..15176de 100644 --- a/packages/builtwith-api/test/schemas.test.ts +++ b/packages/builtwith-api/test/schemas.test.ts @@ -111,6 +111,8 @@ describe("DomainResponseSchema", () => { CDimensions: 5, CGoals: 2, CMetrics: 3, + Followers: 50000, + Employees: 200, }, FirstIndexed: 1609459200, LastIndexed: 1704067200,