@thepixelhouse/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/cli.js ADDED
@@ -0,0 +1,851 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command as Command7 } from "commander";
5
+ import chalk7 from "chalk";
6
+
7
+ // src/commands/screenshot.ts
8
+ import { Command } from "commander";
9
+ import * as fs2 from "fs";
10
+ import * as path2 from "path";
11
+ import ora from "ora";
12
+
13
+ // src/api/client.ts
14
+ var ApiClient = class {
15
+ baseUrl;
16
+ apiKey;
17
+ constructor(baseUrl, apiKey) {
18
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
19
+ this.apiKey = apiKey;
20
+ }
21
+ // ─── Screenshots ──────────────────────────────────
22
+ async captureScreenshot(params) {
23
+ return this.post("/v1/screenshots?sync=true", params);
24
+ }
25
+ async getScreenshot(id) {
26
+ return this.get(`/v1/screenshots/${id}`);
27
+ }
28
+ async downloadScreenshotImage(id) {
29
+ return this.getBlob(`/v1/screenshots/${id}/image`);
30
+ }
31
+ // ─── Comparisons ──────────────────────────────────
32
+ async compareScreenshots(params) {
33
+ return this.post("/v1/comparisons", params);
34
+ }
35
+ async getComparison(id) {
36
+ return this.get(`/v1/comparisons/${id}`);
37
+ }
38
+ async downloadDiffImage(id) {
39
+ return this.getBlob(`/v1/comparisons/${id}/diff`);
40
+ }
41
+ // ─── Baselines ────────────────────────────────────
42
+ async createBaseline(params) {
43
+ return this.post("/v1/baselines", params);
44
+ }
45
+ async listBaselines(params) {
46
+ const query = new URLSearchParams({ projectId: params.projectId });
47
+ if (params.branch) query.set("branch", params.branch);
48
+ if (params.cursor) query.set("cursor", params.cursor);
49
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
50
+ return this.getPaginated(`/v1/baselines?${query.toString()}`);
51
+ }
52
+ async deleteBaseline(id) {
53
+ return this.delete(`/v1/baselines/${id}`);
54
+ }
55
+ // ─── Tests ────────────────────────────────────────
56
+ async runTest(params) {
57
+ return this.post("/v1/tests/run", params);
58
+ }
59
+ // ─── Monitors ─────────────────────────────────────
60
+ async listMonitors(params) {
61
+ const query = new URLSearchParams({ projectId: params.projectId });
62
+ if (params.cursor) query.set("cursor", params.cursor);
63
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
64
+ return this.getPaginated(`/v1/monitors?${query.toString()}`);
65
+ }
66
+ async createMonitor(params) {
67
+ return this.post("/v1/monitors", params);
68
+ }
69
+ async updateMonitor(id, params) {
70
+ return this.patch(`/v1/monitors/${id}`, params);
71
+ }
72
+ async deleteMonitor(id) {
73
+ return this.delete(`/v1/monitors/${id}`);
74
+ }
75
+ // ─── Internal helpers ─────────────────────────────
76
+ async request(path5, init) {
77
+ const url = `${this.baseUrl}${path5}`;
78
+ const headers = new Headers(init.headers);
79
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
80
+ headers.set("Accept", "application/json");
81
+ headers.set("User-Agent", "@pixelhouse/cli");
82
+ return fetch(url, { ...init, headers });
83
+ }
84
+ async get(path5) {
85
+ try {
86
+ const res = await this.request(path5, { method: "GET" });
87
+ const body = await res.json();
88
+ if (!res.ok || "error" in body) {
89
+ const errBody = body;
90
+ return { success: false, error: errBody.error?.message ?? `API returned ${res.status}` };
91
+ }
92
+ return { success: true, data: body.data };
93
+ } catch (err) {
94
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
95
+ }
96
+ }
97
+ async getPaginated(path5) {
98
+ try {
99
+ const res = await this.request(path5, { method: "GET" });
100
+ const body = await res.json();
101
+ if (!res.ok || "error" in body) {
102
+ const errBody = body;
103
+ return { success: false, error: errBody.error?.message ?? `API returned ${res.status}` };
104
+ }
105
+ return { success: true, data: body };
106
+ } catch (err) {
107
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
108
+ }
109
+ }
110
+ async post(path5, body) {
111
+ try {
112
+ const res = await this.request(path5, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify(body)
116
+ });
117
+ const json = await res.json();
118
+ if (!res.ok || "error" in json) {
119
+ const errBody = json;
120
+ return { success: false, error: errBody.error?.message ?? `API returned ${res.status}` };
121
+ }
122
+ return { success: true, data: json.data };
123
+ } catch (err) {
124
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
125
+ }
126
+ }
127
+ async patch(path5, body) {
128
+ try {
129
+ const res = await this.request(path5, {
130
+ method: "PATCH",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify(body)
133
+ });
134
+ const json = await res.json();
135
+ if (!res.ok || "error" in json) {
136
+ const errBody = json;
137
+ return { success: false, error: errBody.error?.message ?? `API returned ${res.status}` };
138
+ }
139
+ return { success: true, data: json.data };
140
+ } catch (err) {
141
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
142
+ }
143
+ }
144
+ async delete(path5) {
145
+ try {
146
+ const res = await this.request(path5, { method: "DELETE" });
147
+ const json = await res.json();
148
+ if (!res.ok || "error" in json) {
149
+ const errBody = json;
150
+ return { success: false, error: errBody.error?.message ?? `API returned ${res.status}` };
151
+ }
152
+ return { success: true, data: json.data };
153
+ } catch (err) {
154
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
155
+ }
156
+ }
157
+ async getBlob(path5) {
158
+ try {
159
+ const res = await this.request(path5, { method: "GET" });
160
+ if (!res.ok) {
161
+ let message = `API returned ${res.status}`;
162
+ try {
163
+ const errJson = await res.json();
164
+ if (errJson.error?.message) message = errJson.error.message;
165
+ } catch {
166
+ }
167
+ return { success: false, error: message };
168
+ }
169
+ const buffer = await res.arrayBuffer();
170
+ return { success: true, data: buffer };
171
+ } catch (err) {
172
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
173
+ }
174
+ }
175
+ };
176
+
177
+ // src/config/loader.ts
178
+ import * as fs from "fs";
179
+ import * as path from "path";
180
+ var CONFIG_FILE_NAME = ".pixelhouserc.json";
181
+ function findConfigFile(startDir) {
182
+ let dir = path.resolve(startDir);
183
+ const root = path.parse(dir).root;
184
+ while (true) {
185
+ const candidate = path.join(dir, CONFIG_FILE_NAME);
186
+ if (fs.existsSync(candidate)) {
187
+ return candidate;
188
+ }
189
+ if (dir === root) {
190
+ return null;
191
+ }
192
+ dir = path.dirname(dir);
193
+ }
194
+ }
195
+ function readConfigFile(filePath) {
196
+ try {
197
+ const raw = fs.readFileSync(filePath, "utf-8");
198
+ return JSON.parse(raw);
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ function resolveConfig(flags) {
204
+ const configPath = findConfigFile(process.cwd());
205
+ const fileConfig = configPath ? readConfigFile(configPath) ?? {} : {};
206
+ const envApiKey = process.env["PIXELHOUSE_API_KEY"];
207
+ const envApiUrl = process.env["PIXELHOUSE_API_URL"];
208
+ return {
209
+ ...fileConfig,
210
+ apiKey: flags.apiKey ?? envApiKey ?? fileConfig.apiKey,
211
+ apiUrl: flags.apiUrl ?? envApiUrl ?? fileConfig.apiUrl ?? "https://api.thepixelhouse.co.uk"
212
+ };
213
+ }
214
+ function requireApiKey(config) {
215
+ if (!config.apiKey) {
216
+ throw new Error(
217
+ "No API key found. Provide one via --api-key, PIXELHOUSE_API_KEY env var, or .pixelhouserc.json"
218
+ );
219
+ }
220
+ return config.apiKey;
221
+ }
222
+
223
+ // src/lib/errors.ts
224
+ var EXIT_CODES = {
225
+ SUCCESS: 0,
226
+ REGRESSION: 1,
227
+ ERROR: 2
228
+ };
229
+ var CliError = class extends Error {
230
+ exitCode;
231
+ constructor(message, exitCode = EXIT_CODES.ERROR) {
232
+ super(message);
233
+ this.name = "CliError";
234
+ this.exitCode = exitCode;
235
+ }
236
+ };
237
+ function toCliError(err) {
238
+ if (err instanceof CliError) {
239
+ return err;
240
+ }
241
+ if (err instanceof Error) {
242
+ return new CliError(err.message, EXIT_CODES.ERROR);
243
+ }
244
+ return new CliError(String(err), EXIT_CODES.ERROR);
245
+ }
246
+ function handleError(err) {
247
+ const cliError = toCliError(err);
248
+ if (cliError.message) {
249
+ process.stderr.write(`Error: ${cliError.message}
250
+ `);
251
+ }
252
+ process.exit(cliError.exitCode);
253
+ }
254
+
255
+ // src/lib/output.ts
256
+ import chalk from "chalk";
257
+ function formatJson(data) {
258
+ return JSON.stringify(data, null, 2);
259
+ }
260
+ function formatTable(rows, columns) {
261
+ if (rows.length === 0) {
262
+ return " No results.";
263
+ }
264
+ const cols = columns ?? Object.keys(rows[0]);
265
+ const widths = {};
266
+ for (const col of cols) {
267
+ widths[col] = col.length;
268
+ }
269
+ for (const row of rows) {
270
+ for (const col of cols) {
271
+ const value = String(row[col] ?? "");
272
+ widths[col] = Math.max(widths[col] ?? 0, value.length);
273
+ }
274
+ }
275
+ const header = cols.map((col) => col.toUpperCase().padEnd(widths[col] ?? 0)).join(" ");
276
+ const separator = cols.map((col) => "-".repeat(widths[col] ?? 0)).join(" ");
277
+ const body = rows.map(
278
+ (row) => cols.map((col) => String(row[col] ?? "").padEnd(widths[col] ?? 0)).join(" ")
279
+ );
280
+ return [
281
+ chalk.bold(header),
282
+ chalk.dim(separator),
283
+ ...body
284
+ ].join("\n");
285
+ }
286
+ function formatSuccess(message) {
287
+ return chalk.green(`${chalk.bold("OK")} ${message}`);
288
+ }
289
+ function formatError(message) {
290
+ return chalk.red(`${chalk.bold("Error")} ${message}`);
291
+ }
292
+ function formatInfo(label, value) {
293
+ return `${chalk.dim(label + ":")} ${value}`;
294
+ }
295
+ function output(text, jsonData, jsonMode) {
296
+ if (jsonMode) {
297
+ process.stdout.write(formatJson(jsonData) + "\n");
298
+ } else {
299
+ process.stdout.write(text + "\n");
300
+ }
301
+ }
302
+
303
+ // src/commands/screenshot.ts
304
+ function createScreenshotCommand() {
305
+ const cmd = new Command("screenshot").description("Capture a screenshot of a URL").argument("<url>", "URL to capture").option("--viewport <viewport>", "viewport preset or WxH (e.g. desktop, 1440x900)", "desktop").option("--full-page", "capture the full scrollable page", false).option("--project <id>", "project ID to associate the screenshot with").option("--output <path>", "save the screenshot PNG to a local file").option("--wait-for <strategy>", "page load strategy: networkIdle, domContentLoaded, load", "networkIdle").action(async (url, opts) => {
306
+ const parentOpts = cmd.parent?.opts();
307
+ const config = resolveConfig({
308
+ apiKey: parentOpts?.apiKey,
309
+ apiUrl: parentOpts?.apiUrl
310
+ });
311
+ const jsonMode = parentOpts?.json ?? false;
312
+ const apiKey = requireApiKey(config);
313
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
314
+ const spinner = jsonMode ? null : ora("Capturing screenshot...").start();
315
+ const result = await client.captureScreenshot({
316
+ url,
317
+ viewport: opts.viewport,
318
+ fullPage: opts.fullPage,
319
+ projectId: opts.project,
320
+ waitFor: opts.waitFor
321
+ });
322
+ if (!result.success) {
323
+ spinner?.fail("Screenshot capture failed");
324
+ throw new CliError(result.error, EXIT_CODES.ERROR);
325
+ }
326
+ const screenshot = result.data;
327
+ if (opts.output) {
328
+ const downloadResult = await client.downloadScreenshotImage(screenshot.id);
329
+ if (!downloadResult.success) {
330
+ spinner?.warn("Screenshot captured but download failed");
331
+ throw new CliError(`Failed to download image: ${downloadResult.error}`, EXIT_CODES.ERROR);
332
+ }
333
+ const outputPath = path2.resolve(opts.output);
334
+ fs2.writeFileSync(outputPath, Buffer.from(downloadResult.data));
335
+ spinner?.succeed("Screenshot captured and saved");
336
+ const text2 = [
337
+ formatSuccess("Screenshot captured and saved"),
338
+ formatInfo("ID", screenshot.id),
339
+ formatInfo("URL", screenshot.url),
340
+ formatInfo("Viewport", `${screenshot.viewport} (${String(screenshot.viewportWidth)}x${String(screenshot.viewportHeight ?? "auto")})`),
341
+ formatInfo("Dimensions", `${String(screenshot.width ?? "?")}x${String(screenshot.height ?? "?")}`),
342
+ formatInfo("Image URL", screenshot.imageUrl),
343
+ formatInfo("Saved to", outputPath)
344
+ ].join("\n");
345
+ output(text2, screenshot, jsonMode);
346
+ return;
347
+ }
348
+ spinner?.succeed("Screenshot captured");
349
+ const text = [
350
+ formatSuccess("Screenshot captured"),
351
+ formatInfo("ID", screenshot.id),
352
+ formatInfo("URL", screenshot.url),
353
+ formatInfo("Viewport", `${screenshot.viewport} (${String(screenshot.viewportWidth)}x${String(screenshot.viewportHeight ?? "auto")})`),
354
+ formatInfo("Dimensions", `${String(screenshot.width ?? "?")}x${String(screenshot.height ?? "?")}`),
355
+ formatInfo("Image URL", screenshot.imageUrl)
356
+ ].join("\n");
357
+ output(text, screenshot, jsonMode);
358
+ });
359
+ return cmd;
360
+ }
361
+
362
+ // src/commands/compare.ts
363
+ import { Command as Command2 } from "commander";
364
+ import * as fs3 from "fs";
365
+ import * as path3 from "path";
366
+ import ora2 from "ora";
367
+ import chalk2 from "chalk";
368
+ function createCompareCommand() {
369
+ const cmd = new Command2("compare").description("Compare two screenshots for visual differences").argument("<screenshot-a>", "ID of the first screenshot (before)").argument("<screenshot-b>", "ID of the second screenshot (after)").option("--threshold <number>", "diff threshold percentage (0-100)", "1.0").option("--output <path>", "save the diff image to a local file").action(async (screenshotA, screenshotB, opts) => {
370
+ const parentOpts = cmd.parent?.opts();
371
+ const config = resolveConfig({
372
+ apiKey: parentOpts?.apiKey,
373
+ apiUrl: parentOpts?.apiUrl
374
+ });
375
+ const jsonMode = parentOpts?.json ?? false;
376
+ const apiKey = requireApiKey(config);
377
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
378
+ const threshold = parseFloat(opts.threshold);
379
+ if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
380
+ throw new CliError("Threshold must be a number between 0 and 100", EXIT_CODES.ERROR);
381
+ }
382
+ const spinner = jsonMode ? null : ora2("Comparing screenshots...").start();
383
+ const result = await client.compareScreenshots({
384
+ screenshotAId: screenshotA,
385
+ screenshotBId: screenshotB,
386
+ threshold
387
+ });
388
+ if (!result.success) {
389
+ spinner?.fail("Comparison failed");
390
+ throw new CliError(result.error, EXIT_CODES.ERROR);
391
+ }
392
+ const comparison = result.data;
393
+ if (opts.output && comparison.diffImageUrl) {
394
+ const downloadResult = await client.downloadDiffImage(comparison.id);
395
+ if (downloadResult.success) {
396
+ const outputPath = path3.resolve(opts.output);
397
+ fs3.writeFileSync(outputPath, Buffer.from(downloadResult.data));
398
+ }
399
+ }
400
+ spinner?.succeed("Comparison complete");
401
+ const statusLabel = comparison.status === "pass" ? chalk2.green("PASS") : comparison.status === "fail" ? chalk2.red("FAIL") : chalk2.yellow(comparison.status.toUpperCase());
402
+ const lines = [
403
+ `${chalk2.bold("Result:")} ${statusLabel}`,
404
+ formatInfo("Comparison ID", comparison.id),
405
+ formatInfo("Diff percentage", comparison.diffPercentage !== null ? `${String(comparison.diffPercentage)}%` : "N/A"),
406
+ formatInfo("SSIM score", comparison.ssimScore !== null ? String(comparison.ssimScore) : "N/A"),
407
+ formatInfo("Threshold", `${String(comparison.threshold)}%`)
408
+ ];
409
+ if (comparison.diffImageUrl) {
410
+ lines.push(formatInfo("Diff image", comparison.diffImageUrl));
411
+ }
412
+ if (opts.output && comparison.diffImageUrl) {
413
+ lines.push(formatInfo("Saved to", path3.resolve(opts.output)));
414
+ }
415
+ output(lines.join("\n"), comparison, jsonMode);
416
+ });
417
+ return cmd;
418
+ }
419
+
420
+ // src/commands/regression.ts
421
+ import { Command as Command3 } from "commander";
422
+ import ora3 from "ora";
423
+ import chalk3 from "chalk";
424
+ function toTableRow(url, result) {
425
+ return {
426
+ url,
427
+ status: result.status,
428
+ diff: result.diffPercentage !== null ? `${String(result.diffPercentage)}%` : "N/A",
429
+ ssim: result.ssimScore !== null ? String(result.ssimScore) : "N/A",
430
+ threshold: `${String(result.threshold)}%`
431
+ };
432
+ }
433
+ function createRegressionCommand() {
434
+ const regression = new Command3("regression").description("Visual regression testing commands");
435
+ regression.command("run").description("Run visual regression tests").option("--url <url>", "single URL to test").option("--baseline <id>", "baseline ID to compare against").option("--threshold <number>", "diff threshold percentage", "1.0").option("--viewport <viewport>", "viewport preset or WxH", "desktop").option("--ci", "CI mode: exit with code 0 (pass), 1 (regression), 2 (error)", false).option("--fail-on-diff", "exit with non-zero code if any diff exceeds threshold", false).action(async (opts) => {
436
+ const parentOpts = regression.parent?.opts();
437
+ const config = resolveConfig({
438
+ apiKey: parentOpts?.apiKey,
439
+ apiUrl: parentOpts?.apiUrl
440
+ });
441
+ const jsonMode = parentOpts?.json ?? false;
442
+ const apiKey = requireApiKey(config);
443
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
444
+ const threshold = parseFloat(opts.threshold);
445
+ if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
446
+ throw new CliError("Threshold must be a number between 0 and 100", EXIT_CODES.ERROR);
447
+ }
448
+ const pages = buildPageList(opts, config);
449
+ if (pages.length === 0) {
450
+ throw new CliError(
451
+ "No pages to test. Provide --url and --baseline, or define pages in .pixelhouserc.json",
452
+ EXIT_CODES.ERROR
453
+ );
454
+ }
455
+ const spinner = jsonMode ? null : ora3(`Running ${String(pages.length)} regression test(s)...`).start();
456
+ const rows = [];
457
+ const results = [];
458
+ let hasFailure = false;
459
+ let hasError = false;
460
+ for (const page of pages) {
461
+ try {
462
+ const result = await client.runTest({
463
+ url: page.url,
464
+ baselineId: page.baselineId,
465
+ viewport: page.viewport ?? opts.viewport,
466
+ threshold: page.threshold ?? threshold
467
+ });
468
+ if (!result.success) {
469
+ hasError = true;
470
+ rows.push({
471
+ url: page.url,
472
+ status: "error",
473
+ diff: "N/A",
474
+ ssim: "N/A",
475
+ threshold: `${String(page.threshold ?? threshold)}%`
476
+ });
477
+ continue;
478
+ }
479
+ results.push(result.data);
480
+ rows.push(toTableRow(page.url, result.data));
481
+ if (result.data.status === "fail") {
482
+ hasFailure = true;
483
+ }
484
+ if (result.data.status === "error") {
485
+ hasError = true;
486
+ }
487
+ } catch {
488
+ hasError = true;
489
+ rows.push({
490
+ url: page.url,
491
+ status: "error",
492
+ diff: "N/A",
493
+ ssim: "N/A",
494
+ threshold: `${String(page.threshold ?? threshold)}%`
495
+ });
496
+ }
497
+ }
498
+ const colouredRows = rows.map((row) => ({
499
+ ...row,
500
+ status: row.status === "pass" ? chalk3.green("pass") : row.status === "fail" ? chalk3.red("fail") : chalk3.yellow(row.status)
501
+ }));
502
+ spinner?.stop();
503
+ const summary = hasFailure ? formatError(`${String(rows.filter((r) => r.status === "fail" || r.status === chalk3.red("fail")).length)} test(s) failed`) : hasError ? formatError(`${String(rows.filter((r) => r.status === "error" || r.status === chalk3.yellow("error")).length)} test(s) errored`) : formatSuccess(`All ${String(rows.length)} test(s) passed`);
504
+ const tableOutput = formatTable(colouredRows, ["url", "status", "diff", "ssim", "threshold"]);
505
+ const text = `${tableOutput}
506
+
507
+ ${summary}`;
508
+ output(text, { results, summary: { total: rows.length, passed: rows.filter((r) => r.status === "pass").length, failed: rows.filter((r) => r.status === "fail").length, errored: rows.filter((r) => r.status === "error").length } }, jsonMode);
509
+ if (opts.ci || opts.failOnDiff) {
510
+ if (hasError) {
511
+ process.exit(EXIT_CODES.ERROR);
512
+ }
513
+ if (hasFailure) {
514
+ process.exit(EXIT_CODES.REGRESSION);
515
+ }
516
+ process.exit(EXIT_CODES.SUCCESS);
517
+ }
518
+ });
519
+ return regression;
520
+ }
521
+ function buildPageList(opts, config) {
522
+ if (opts.url && opts.baseline) {
523
+ return [
524
+ {
525
+ url: opts.url,
526
+ baselineId: opts.baseline,
527
+ viewport: opts.viewport,
528
+ threshold: parseFloat(opts.threshold)
529
+ }
530
+ ];
531
+ }
532
+ if (config.pages && config.pages.length > 0) {
533
+ return config.pages.filter((p) => !!p.baselineId).map((page) => ({
534
+ url: page.url,
535
+ baselineId: page.baselineId,
536
+ viewport: page.viewport ?? config.viewport,
537
+ threshold: page.threshold ?? config.threshold
538
+ }));
539
+ }
540
+ return [];
541
+ }
542
+
543
+ // src/commands/baseline.ts
544
+ import { Command as Command4 } from "commander";
545
+ import ora4 from "ora";
546
+ import chalk4 from "chalk";
547
+ function createBaselineCommand() {
548
+ const baseline = new Command4("baseline").description("Manage screenshot baselines");
549
+ baseline.command("list").description("List baselines for a project").requiredOption("--project <id>", "project ID").option("--branch <name>", "filter by branch name").option("--limit <number>", "max results to return", "25").action(async (opts) => {
550
+ const parentOpts = baseline.parent?.opts();
551
+ const config = resolveConfig({
552
+ apiKey: parentOpts?.apiKey,
553
+ apiUrl: parentOpts?.apiUrl
554
+ });
555
+ const jsonMode = parentOpts?.json ?? false;
556
+ const apiKey = requireApiKey(config);
557
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
558
+ const spinner = jsonMode ? null : ora4("Fetching baselines...").start();
559
+ const result = await client.listBaselines({
560
+ projectId: opts.project,
561
+ branch: opts.branch,
562
+ limit: parseInt(opts.limit, 10)
563
+ });
564
+ if (!result.success) {
565
+ spinner?.fail("Failed to fetch baselines");
566
+ throw new CliError(result.error, EXIT_CODES.ERROR);
567
+ }
568
+ spinner?.succeed(`Found ${String(result.data.data.length)} baseline(s)`);
569
+ const rows = result.data.data.map((b) => ({
570
+ id: b.id,
571
+ url: b.url,
572
+ viewport: b.viewport,
573
+ branch: b.branch,
574
+ active: b.isActive ? chalk4.green("yes") : chalk4.dim("no"),
575
+ created: b.createdAt
576
+ }));
577
+ const tableText = formatTable(rows, ["id", "url", "viewport", "branch", "active", "created"]);
578
+ output(tableText, result.data, jsonMode);
579
+ });
580
+ baseline.command("create").description("Promote a screenshot to a baseline").argument("<screenshot-id>", "screenshot ID to promote").requiredOption("--project <id>", "project ID").requiredOption("--url <url>", "page URL the baseline represents").option("--viewport <viewport>", "viewport for this baseline", "desktop").option("--branch <name>", "branch name", "main").action(async (screenshotId, opts) => {
581
+ const parentOpts = baseline.parent?.opts();
582
+ const config = resolveConfig({
583
+ apiKey: parentOpts?.apiKey,
584
+ apiUrl: parentOpts?.apiUrl
585
+ });
586
+ const jsonMode = parentOpts?.json ?? false;
587
+ const apiKey = requireApiKey(config);
588
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
589
+ const spinner = jsonMode ? null : ora4("Creating baseline...").start();
590
+ const result = await client.createBaseline({
591
+ screenshotId,
592
+ projectId: opts.project,
593
+ url: opts.url,
594
+ viewport: opts.viewport,
595
+ branch: opts.branch
596
+ });
597
+ if (!result.success) {
598
+ spinner?.fail("Failed to create baseline");
599
+ throw new CliError(result.error, EXIT_CODES.ERROR);
600
+ }
601
+ spinner?.succeed("Baseline created");
602
+ const text = [
603
+ formatSuccess("Baseline created"),
604
+ formatInfo("ID", result.data.id),
605
+ formatInfo("URL", result.data.url),
606
+ formatInfo("Viewport", result.data.viewport),
607
+ formatInfo("Branch", result.data.branch)
608
+ ].join("\n");
609
+ output(text, result.data, jsonMode);
610
+ });
611
+ baseline.command("delete").description("Delete a baseline").argument("<id>", "baseline ID to delete").action(async (id) => {
612
+ const parentOpts = baseline.parent?.opts();
613
+ const config = resolveConfig({
614
+ apiKey: parentOpts?.apiKey,
615
+ apiUrl: parentOpts?.apiUrl
616
+ });
617
+ const jsonMode = parentOpts?.json ?? false;
618
+ const apiKey = requireApiKey(config);
619
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
620
+ const spinner = jsonMode ? null : ora4("Deleting baseline...").start();
621
+ const result = await client.deleteBaseline(id);
622
+ if (!result.success) {
623
+ spinner?.fail("Failed to delete baseline");
624
+ throw new CliError(result.error, EXIT_CODES.ERROR);
625
+ }
626
+ spinner?.succeed("Baseline deleted");
627
+ const text = formatSuccess(`Baseline ${id} deleted`);
628
+ output(text, { id, deleted: true }, jsonMode);
629
+ });
630
+ return baseline;
631
+ }
632
+
633
+ // src/commands/monitor.ts
634
+ import { Command as Command5 } from "commander";
635
+ import ora5 from "ora";
636
+ import chalk5 from "chalk";
637
+ function intervalToCron(interval) {
638
+ if (interval.includes(" ")) {
639
+ return interval;
640
+ }
641
+ const match = /^(\d+)(m|h|d)$/i.exec(interval);
642
+ if (!match) {
643
+ return interval;
644
+ }
645
+ const value = parseInt(match[1], 10);
646
+ const unit = match[2].toLowerCase();
647
+ switch (unit) {
648
+ case "m":
649
+ return `*/${String(value)} * * * *`;
650
+ case "h":
651
+ return `0 */${String(value)} * * *`;
652
+ case "d":
653
+ return `0 0 */${String(value)} * *`;
654
+ default:
655
+ return interval;
656
+ }
657
+ }
658
+ function createMonitorCommand() {
659
+ const monitor = new Command5("monitor").description("Manage scheduled visual monitors");
660
+ monitor.command("list").description("List monitors for a project").requiredOption("--project <id>", "project ID").option("--limit <number>", "max results to return", "25").action(async (opts) => {
661
+ const parentOpts = monitor.parent?.opts();
662
+ const config = resolveConfig({
663
+ apiKey: parentOpts?.apiKey,
664
+ apiUrl: parentOpts?.apiUrl
665
+ });
666
+ const jsonMode = parentOpts?.json ?? false;
667
+ const apiKey = requireApiKey(config);
668
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
669
+ const spinner = jsonMode ? null : ora5("Fetching monitors...").start();
670
+ const result = await client.listMonitors({
671
+ projectId: opts.project,
672
+ limit: parseInt(opts.limit, 10)
673
+ });
674
+ if (!result.success) {
675
+ spinner?.fail("Failed to fetch monitors");
676
+ throw new CliError(result.error, EXIT_CODES.ERROR);
677
+ }
678
+ spinner?.succeed(`Found ${String(result.data.data.length)} monitor(s)`);
679
+ const rows = result.data.data.map((m) => ({
680
+ id: m.id,
681
+ url: m.url,
682
+ viewport: m.viewport,
683
+ schedule: m.cronExpression,
684
+ threshold: `${String(m.threshold)}%`,
685
+ active: m.isActive ? chalk5.green("yes") : chalk5.dim("no"),
686
+ lastRun: m.lastRunAt ?? "never",
687
+ lastStatus: m.lastStatus ?? "-"
688
+ }));
689
+ const tableText = formatTable(rows, ["id", "url", "viewport", "schedule", "threshold", "active", "lastRun", "lastStatus"]);
690
+ output(tableText, result.data, jsonMode);
691
+ });
692
+ monitor.command("add").description("Add a new visual monitor for a URL").argument("<url>", "URL to monitor").requiredOption("--project <id>", "project ID").option("--every <interval>", "check interval (e.g. 30m, 1h, 6h)", "1h").option("--threshold <number>", "diff threshold percentage", "1.0").option("--viewport <viewport>", "viewport preset or WxH", "desktop").action(async (url, opts) => {
693
+ const parentOpts = monitor.parent?.opts();
694
+ const config = resolveConfig({
695
+ apiKey: parentOpts?.apiKey,
696
+ apiUrl: parentOpts?.apiUrl
697
+ });
698
+ const jsonMode = parentOpts?.json ?? false;
699
+ const apiKey = requireApiKey(config);
700
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
701
+ const threshold = parseFloat(opts.threshold);
702
+ if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
703
+ throw new CliError("Threshold must be a number between 0 and 100", EXIT_CODES.ERROR);
704
+ }
705
+ const cronExpression = intervalToCron(opts.every);
706
+ const spinner = jsonMode ? null : ora5("Creating monitor...").start();
707
+ const result = await client.createMonitor({
708
+ projectId: opts.project,
709
+ url,
710
+ viewport: opts.viewport,
711
+ cronExpression,
712
+ threshold
713
+ });
714
+ if (!result.success) {
715
+ spinner?.fail("Failed to create monitor");
716
+ throw new CliError(result.error, EXIT_CODES.ERROR);
717
+ }
718
+ spinner?.succeed("Monitor created");
719
+ const text = [
720
+ formatSuccess("Monitor created"),
721
+ formatInfo("ID", result.data.id),
722
+ formatInfo("URL", result.data.url),
723
+ formatInfo("Schedule", result.data.cronExpression),
724
+ formatInfo("Threshold", `${String(result.data.threshold)}%`),
725
+ formatInfo("Viewport", result.data.viewport)
726
+ ].join("\n");
727
+ output(text, result.data, jsonMode);
728
+ });
729
+ monitor.command("pause").description("Pause a monitor").argument("<id>", "monitor ID to pause").action(async (id) => {
730
+ const parentOpts = monitor.parent?.opts();
731
+ const config = resolveConfig({
732
+ apiKey: parentOpts?.apiKey,
733
+ apiUrl: parentOpts?.apiUrl
734
+ });
735
+ const jsonMode = parentOpts?.json ?? false;
736
+ const apiKey = requireApiKey(config);
737
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
738
+ const spinner = jsonMode ? null : ora5("Pausing monitor...").start();
739
+ const result = await client.updateMonitor(id, { isActive: false });
740
+ if (!result.success) {
741
+ spinner?.fail("Failed to pause monitor");
742
+ throw new CliError(result.error, EXIT_CODES.ERROR);
743
+ }
744
+ spinner?.succeed("Monitor paused");
745
+ const text = formatSuccess(`Monitor ${id} paused`);
746
+ output(text, result.data, jsonMode);
747
+ });
748
+ monitor.command("resume").description("Resume a paused monitor").argument("<id>", "monitor ID to resume").action(async (id) => {
749
+ const parentOpts = monitor.parent?.opts();
750
+ const config = resolveConfig({
751
+ apiKey: parentOpts?.apiKey,
752
+ apiUrl: parentOpts?.apiUrl
753
+ });
754
+ const jsonMode = parentOpts?.json ?? false;
755
+ const apiKey = requireApiKey(config);
756
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
757
+ const spinner = jsonMode ? null : ora5("Resuming monitor...").start();
758
+ const result = await client.updateMonitor(id, { isActive: true });
759
+ if (!result.success) {
760
+ spinner?.fail("Failed to resume monitor");
761
+ throw new CliError(result.error, EXIT_CODES.ERROR);
762
+ }
763
+ spinner?.succeed("Monitor resumed");
764
+ const text = formatSuccess(`Monitor ${id} resumed`);
765
+ output(text, result.data, jsonMode);
766
+ });
767
+ monitor.command("delete").description("Delete a monitor").argument("<id>", "monitor ID to delete").action(async (id) => {
768
+ const parentOpts = monitor.parent?.opts();
769
+ const config = resolveConfig({
770
+ apiKey: parentOpts?.apiKey,
771
+ apiUrl: parentOpts?.apiUrl
772
+ });
773
+ const jsonMode = parentOpts?.json ?? false;
774
+ const apiKey = requireApiKey(config);
775
+ const client = new ApiClient(config.apiUrl ?? "https://api.thepixelhouse.co.uk", apiKey);
776
+ const spinner = jsonMode ? null : ora5("Deleting monitor...").start();
777
+ const result = await client.deleteMonitor(id);
778
+ if (!result.success) {
779
+ spinner?.fail("Failed to delete monitor");
780
+ throw new CliError(result.error, EXIT_CODES.ERROR);
781
+ }
782
+ spinner?.succeed("Monitor deleted");
783
+ const text = formatSuccess(`Monitor ${id} deleted`);
784
+ output(text, { id, deleted: true }, jsonMode);
785
+ });
786
+ return monitor;
787
+ }
788
+
789
+ // src/commands/init.ts
790
+ import { Command as Command6 } from "commander";
791
+ import * as fs4 from "fs";
792
+ import * as path4 from "path";
793
+ import chalk6 from "chalk";
794
+ var DEFAULT_CONFIG = {
795
+ apiUrl: "https://api.thepixelhouse.co.uk",
796
+ viewport: "desktop",
797
+ threshold: 1,
798
+ branch: "main",
799
+ pages: []
800
+ };
801
+ function createInitCommand() {
802
+ const cmd = new Command6("init").description("Create a .pixelhouserc.json configuration file").option("--force", "overwrite existing configuration file", false).action(async (opts) => {
803
+ const parentOpts = cmd.parent?.opts();
804
+ const jsonMode = parentOpts?.json ?? false;
805
+ const configPath = path4.join(process.cwd(), CONFIG_FILE_NAME);
806
+ if (fs4.existsSync(configPath) && !opts.force) {
807
+ throw new CliError(
808
+ `${CONFIG_FILE_NAME} already exists. Use --force to overwrite.`,
809
+ EXIT_CODES.ERROR
810
+ );
811
+ }
812
+ const config = { ...DEFAULT_CONFIG };
813
+ const apiKey = parentOpts?.apiKey ?? process.env["PIXELHOUSE_API_KEY"];
814
+ if (apiKey) {
815
+ config.apiKey = apiKey;
816
+ }
817
+ fs4.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
818
+ const text = [
819
+ formatSuccess("Configuration file created"),
820
+ formatInfo("Path", configPath),
821
+ "",
822
+ chalk6.dim("Next steps:"),
823
+ chalk6.dim(` 1. Add your API key to ${CONFIG_FILE_NAME} or set PIXELHOUSE_API_KEY`),
824
+ chalk6.dim(' 2. Add pages to the "pages" array for regression testing'),
825
+ chalk6.dim(' 3. Run "pixelhouse screenshot <url>" to capture your first screenshot')
826
+ ].join("\n");
827
+ output(text, { path: configPath, config }, jsonMode);
828
+ });
829
+ return cmd;
830
+ }
831
+
832
+ // src/cli.ts
833
+ var VERSION = "0.1.0";
834
+ function createProgram() {
835
+ const program2 = new Command7();
836
+ program2.name("pixelhouse").description("Visual regression testing CLI for The Pixel House").version(VERSION, "-v, --version").option("--api-key <key>", "API key (overrides PIXELHOUSE_API_KEY env var)").option("--api-url <url>", "API base URL (default: https://api.thepixelhouse.co.uk)").option("--json", "output results as JSON", false).option("--no-color", "disable coloured output").hook("preAction", (_thisCommand, actionCommand) => {
837
+ const rootOpts = actionCommand.optsWithGlobals();
838
+ if (rootOpts.color === false) {
839
+ chalk7.level = 0;
840
+ }
841
+ });
842
+ program2.addCommand(createScreenshotCommand());
843
+ program2.addCommand(createCompareCommand());
844
+ program2.addCommand(createRegressionCommand());
845
+ program2.addCommand(createBaselineCommand());
846
+ program2.addCommand(createMonitorCommand());
847
+ program2.addCommand(createInitCommand());
848
+ return program2;
849
+ }
850
+ var program = createProgram();
851
+ program.parseAsync(process.argv).catch(handleError);
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@thepixelhouse/cli",
3
+ "version": "0.1.0",
4
+ "description": "Visual regression testing CLI for The Pixel House. Capture screenshots, diff them, manage baselines, and run monitors from your terminal.",
5
+ "license": "MIT",
6
+ "author": "ToggleKit Ltd <hello@thepixelhouse.co.uk>",
7
+ "homepage": "https://thepixelhouse.co.uk/docs",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/togglekit/the-pixel-house.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/togglekit/the-pixel-house/issues"
15
+ },
16
+ "keywords": [
17
+ "visual-regression",
18
+ "screenshot",
19
+ "testing",
20
+ "diff",
21
+ "pixel",
22
+ "monitoring",
23
+ "cli",
24
+ "visual-testing",
25
+ "screenshot-testing"
26
+ ],
27
+ "bin": {
28
+ "pixelhouse": "./dist/cli.js"
29
+ },
30
+ "type": "module",
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "dev": "tsup src/cli.ts --format esm --watch",
44
+ "typecheck": "tsc --noEmit",
45
+ "prepublishOnly": "npm run build"
46
+ },
47
+ "dependencies": {
48
+ "chalk": "^5.4.0",
49
+ "commander": "^13.0.0",
50
+ "ora": "^8.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@pixelhouse/shared": "workspace:*",
54
+ "@types/node": "^25.5.0",
55
+ "tsup": "^8.0.0",
56
+ "typescript": "^5.7.0"
57
+ }
58
+ }