Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/guide/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 17 additions & 7 deletions packages/builtwith-api/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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);
}
Expand All @@ -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);
}
Expand All @@ -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<string, unknown>;
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;
Expand Down
1 change: 1 addition & 0 deletions packages/builtwith-api/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)))];
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<string, unknown>)[k] ?? "").length)),
);
Expand Down
24 changes: 22 additions & 2 deletions packages/builtwith-api/src/request.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 <T>(url: string, format: ResponseFormat, schema: z.ZodType<T>): Promise<T | string> => {
const res = await fetch(url);
if (!res.ok) {
Expand All @@ -18,7 +30,14 @@ export const request = async <T>(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);
};

Expand All @@ -43,5 +62,6 @@ export const requestSafe = async <T>(
console.warn("BuiltWith sent an invalid JSON payload. Falling back to text parsing.");
return raw;
}
checkForApiError(data);
return schema.parse(data);
};
3 changes: 3 additions & 0 deletions packages/builtwith-api/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand Down
40 changes: 40 additions & 0 deletions packages/builtwith-api/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
12 changes: 12 additions & 0 deletions packages/builtwith-api/test/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
31 changes: 31 additions & 0 deletions packages/builtwith-api/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
);
});
});
2 changes: 2 additions & 0 deletions packages/builtwith-api/test/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ describe("DomainResponseSchema", () => {
CDimensions: 5,
CGoals: 2,
CMetrics: 3,
Followers: 50000,
Employees: 200,
},
FirstIndexed: 1609459200,
LastIndexed: 1704067200,
Expand Down
Loading