@snapvisor/cli 6.4.1 → 7.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/index.mjs CHANGED
@@ -1,131 +1,20 @@
1
1
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { resolve } from "node:path";
4
- import { Option, program } from "commander";
5
- import { deploy, finalize, skip, upload } from "@snapvisor/core";
4
+ import { InvalidArgumentError, Option, program } from "commander";
5
+ import { deploy, finalize, readConfig, skip, upload } from "@snapvisor/core";
6
6
  import ora from "ora";
7
7
  import { APIError, createClient, throwAPIError } from "@snapvisor/api-client";
8
8
  import { homedir } from "node:os";
9
9
  import { createHash, randomBytes } from "node:crypto";
10
10
  import { createServer } from "node:http";
11
11
  import open from "open";
12
- //#region src/options.ts
13
- const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build");
14
- const tokenOption = new Option("--token <token>", "Repository token");
15
- const projectOption = new Option("--project <slug>", "Snapvisor project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository");
16
- const buildNameOption = new Option("--build-name <string>", "Name of the build, in case you want to run multiple Snapvisor builds in a single CI job");
17
- const accountOption = new Option("--account <slug>", "Slug of the account (personal or team) that will own the project");
18
- const jsonOption = new Option("--json", "Output machine-readable JSON instead of human-readable text");
19
- const projectPathOption = new Option("--project <owner/project>", "Project path in owner/project format. Required for build-number references on review and comment commands");
20
- const changeProjectPathOption = new Option("--project <owner/project>", "Project the change belongs to, in owner/project format. Also ARGOS_PROJECT");
21
- const METRICS_PERIOD_BY_CHOICE = {
22
- "24h": "LAST_24_HOURS",
23
- "3d": "LAST_3_DAYS",
24
- "7d": "LAST_7_DAYS",
25
- "30d": "LAST_30_DAYS",
26
- "90d": "LAST_90_DAYS"
27
- };
28
- const metricsPeriodOption = new Option("--metrics-period <period>", "Window over which test flakiness metrics are computed").choices(Object.keys(METRICS_PERIOD_BY_CHOICE)).default("7d");
29
- /** Map a `--metrics-period` choice to the value the API expects. */
30
- function toMetricsPeriod(choice) {
31
- return METRICS_PERIOD_BY_CHOICE[choice] ?? "LAST_7_DAYS";
32
- }
33
- //#endregion
34
- //#region src/commands/upload.ts
35
- function uploadCommand(program) {
36
- program.command("upload").argument("<directory>", "Directory to upload").description("Upload screenshots to Snapvisor").option("-f, --files <patterns...>", "One or more globs matching image file paths to upload", "**/*.{png,jpg,jpeg}").option("-i, --ignore <patterns...>", "One or more globs matching image file paths to ignore (ex: \"**/*.png **/diff.jpg\")").addOption(tokenOption).addOption(projectOption).addOption(buildNameOption).addOption(new Option("--mode <string>", "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.").default("ci").choices(["ci", "monitoring"])).addOption(new Option("--parallel", "Enable parallel mode. Run multiple Argos builds and combine them at the end.\nCan also be set via the ARGOS_PARALLEL environment variable.")).addOption(new Option("--parallel-total <number>", "The number of parallel nodes being ran")).addOption(parallelNonceOption).addOption(new Option("--parallel-index <number>", "The index of the parallel node being ran (must be at least 1)")).addOption(new Option("--reference-branch <string>", "Branch used as baseline for screenshot comparison")).addOption(new Option("--reference-commit <string>", "Commit used as baseline for screenshot comparison")).addOption(new Option("--threshold <number>", "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5")).addOption(new Option("--subset", "Whether this build contains only a subset of screenshots.\nThis is useful when a build is created from an incomplete test suite where some tests are skipped.\nCan also be set via the ARGOS_SUBSET environment variable.")).action(async (directory, options) => {
37
- const spinner = ora("Uploading screenshots").start();
38
- try {
39
- const parallel = (() => {
40
- if (!options.parallel) return;
41
- if (!options.parallelNonce) {
42
- spinner.fail("--parallel-nonce is required if --parallel is set");
43
- process.exit(1);
44
- }
45
- if (!options.parallelTotal) {
46
- spinner.fail("--parallel-total is required if --parallel is set");
47
- process.exit(1);
48
- }
49
- return {
50
- nonce: options.parallelNonce,
51
- total: options.parallelTotal,
52
- index: options.parallelIndex
53
- };
54
- })();
55
- const result = await upload({
56
- token: options.token,
57
- project: options.project,
58
- root: directory,
59
- buildName: options.buildName,
60
- files: options.files,
61
- ignore: options.ignore,
62
- parallel,
63
- referenceBranch: options.referenceBranch,
64
- referenceCommit: options.referenceCommit,
65
- mode: options.mode,
66
- threshold: options.threshold,
67
- subset: options.subset
68
- });
69
- spinner.succeed(`Build created: ${result.build.url}`);
70
- } catch (error) {
71
- if (error instanceof Error) {
72
- spinner.fail(`Build failed: ${error.message}`);
73
- console.error(error.stack);
74
- }
75
- process.exit(1);
76
- }
77
- });
78
- }
79
- //#endregion
80
- //#region src/commands/finalize.ts
81
- const skipIfEmptyOption = new Option("--skip-if-empty", "Create a skipped build when no parallel build matches the nonce.\nUseful when uploads are conditional (e.g. skipped by a CI cache) and Argos is a required status check: the skipped build reports a success status even though nothing was uploaded.");
82
- function finalizeCommand(program) {
83
- program.command("finalize").description("Finalize pending parallel builds").addOption(parallelNonceOption).addOption(tokenOption).addOption(skipIfEmptyOption).addOption(buildNameOption).action(async (options) => {
84
- const spinner = ora("Finalizing builds").start();
85
- try {
86
- const result = await finalize({
87
- token: options.token,
88
- parallel: options.parallelNonce ? { nonce: options.parallelNonce } : void 0,
89
- skipIfEmpty: options.skipIfEmpty,
90
- buildName: options.buildName
91
- });
92
- spinner.succeed(result.skippedBuild ? `No builds to finalize — skipped build created: ${result.skippedBuild.url}` : result.builds.length === 0 ? "No builds to finalize" : `Builds finalized: ${result.builds.map((b) => b.url).join(", ")}`);
93
- } catch (error) {
94
- if (error instanceof Error) {
95
- spinner.fail(`Failed to finalize: ${error.message}`);
96
- console.error(error.stack);
97
- }
98
- process.exit(1);
99
- }
100
- });
101
- }
102
- //#endregion
103
- //#region src/commands/skip.ts
104
- function skipCommand(program) {
105
- program.command("skip").description("Mark a build as skipped").addOption(tokenOption).addOption(buildNameOption).action(async (options) => {
106
- const spinner = ora("Creating skipped build").start();
107
- try {
108
- const result = await skip({
109
- token: options.token,
110
- buildName: options.buildName
111
- });
112
- spinner.succeed(`Build created: ${result.build.url}`);
113
- } catch (error) {
114
- if (error instanceof Error) {
115
- spinner.fail(`Build failed: ${error.message}`);
116
- console.error(error.stack);
117
- }
118
- process.exit(1);
119
- }
120
- });
121
- }
122
- //#endregion
123
12
  //#region src/lib/api.ts
124
13
  /** Optional override for the API base URL (used in tests and dev). */
125
14
  function getApiBaseUrl() {
126
15
  return process.env["ARGOS_API_BASE_URL"];
127
16
  }
128
- /** Create an Argos API client authenticated with the given token. */
17
+ /** Create an Snapvisor API client authenticated with the given token. */
129
18
  function createApiClient(authToken) {
130
19
  return createClient({
131
20
  authToken,
@@ -142,314 +31,106 @@ function unwrap(result) {
142
31
  return result.data;
143
32
  }
144
33
  //#endregion
145
- //#region src/lib/format.ts
146
- /** Render a scalar, using `-` for empty values. */
147
- function formatValue(value) {
148
- if (value === null || value === void 0 || value === "") return "-";
149
- return String(value);
34
+ //#region src/lib/cli-error.ts
35
+ /**
36
+ * Error whose message is meant to be shown to the user verbatim (prefixed with
37
+ * `Error: `) before the CLI exits with a non-zero code. Throw it — via
38
+ * {@link fail} — instead of calling `process.exit` directly so the failure can
39
+ * be unit-tested and handled in a single place ({@link handleCliError}).
40
+ */
41
+ var CliError = class extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "CliError";
45
+ }
46
+ };
47
+ /**
48
+ * Abort the current command with a user-facing message.
49
+ */
50
+ function fail(message) {
51
+ throw new CliError(message);
150
52
  }
151
- function formatUser(user) {
152
- if (!user) return "-";
153
- return user.name ? `${user.name} (@${user.slug})` : `@${user.slug}`;
53
+ //#endregion
54
+ //#region src/lib/oauth.ts
55
+ /**
56
+ * OAuth 2.1 client configuration and helpers for the `argos login` flow
57
+ * (Authorization Code + PKCE with a loopback redirect, RFC 8252).
58
+ *
59
+ * The authorization server is the Snapvisor app origin; endpoints and token
60
+ * validation are implemented in the main Snapvisor repo (`apps/backend/src/oauth`).
61
+ */
62
+ /** First-party public client id, seeded in the Snapvisor database. */
63
+ const OAUTH_CLIENT_ID = "argos-cli";
64
+ /**
65
+ * Scopes requested by `argos login`, covering the CLI's user actions (identity,
66
+ * projects, reviews, comments). Build upload keeps using a project token.
67
+ */
68
+ const OAUTH_SCOPES = [
69
+ "profile",
70
+ "projects:read",
71
+ "projects:write",
72
+ "reviews:write",
73
+ "comments:read",
74
+ "comments:write"
75
+ ];
76
+ /** Refresh a bit early so a token never expires mid-request. */
77
+ const EXPIRY_SKEW_MS = 60 * 1e3;
78
+ function getAppBaseUrl() {
79
+ return process.env["ARGOS_APP_BASE_URL"] ?? "https://app.snapvisor.io/";
154
80
  }
155
- function formatMe(me) {
156
- const lines = [
157
- `Logged in to Argos as ${formatValue(me.user.name)}.`,
158
- `Name: ${formatValue(me.user.name)}`,
159
- `Email: ${formatValue(me.user.email)}`
160
- ];
161
- if (me.accounts.length > 0) lines.push(`Accounts: ${me.accounts.map((a) => `@${a.slug}`).join(", ")}`);
162
- return lines.join("\n");
81
+ /**
82
+ * A token endpoint request that reached the server and was rejected by it
83
+ * (non-2xx / OAuth error), as opposed to a transient network failure. Lets
84
+ * callers tell "your session is gone, log in again" from "the network is down".
85
+ */
86
+ var OAuthTokenError = class extends Error {
87
+ code;
88
+ constructor(message, code) {
89
+ super(message);
90
+ this.code = code;
91
+ this.name = "OAuthTokenError";
92
+ }
93
+ };
94
+ function toTokenSet(data) {
95
+ return {
96
+ accessToken: data.access_token,
97
+ refreshToken: data.refresh_token,
98
+ expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SKEW_MS,
99
+ scope: data.scope ?? ""
100
+ };
163
101
  }
164
- function formatProject(project) {
165
- return [
166
- `Created project ${project.account.slug}/${project.name}.`,
167
- `ID: ${project.id}`,
168
- `Name: ${project.name}`,
169
- `Account: ${project.account.slug}`,
170
- `Default base branch: ${formatValue(project.defaultBaseBranch)}`
171
- ].join("\n");
102
+ /** Generate a PKCE `code_verifier` and its S256 `code_challenge`. */
103
+ function generatePkce() {
104
+ const codeVerifier = randomBytes(32).toString("base64url");
105
+ return {
106
+ codeVerifier,
107
+ codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url")
108
+ };
172
109
  }
173
- function formatStats(stats) {
174
- if (!stats) return "-";
175
- return [
176
- `total ${stats.total}`,
177
- `changed ${stats.changed}`,
178
- `added ${stats.added}`,
179
- `removed ${stats.removed}`,
180
- `unchanged ${stats.unchanged}`
181
- ].join(", ");
110
+ /** Build the `/oauth/authorize` URL to open in the browser. */
111
+ function buildAuthorizeUrl(params) {
112
+ const url = new URL("/oauth/authorize", getAppBaseUrl());
113
+ url.searchParams.set("response_type", "code");
114
+ url.searchParams.set("client_id", OAUTH_CLIENT_ID);
115
+ url.searchParams.set("redirect_uri", params.redirectUri);
116
+ url.searchParams.set("scope", OAUTH_SCOPES.join(" "));
117
+ url.searchParams.set("state", params.state);
118
+ url.searchParams.set("code_challenge", params.codeChallenge);
119
+ url.searchParams.set("code_challenge_method", "S256");
120
+ return url.href;
182
121
  }
183
- const SNAPSHOT_STATUS_ORDER = [
184
- "changed",
185
- "added",
186
- "removed",
187
- "unchanged",
188
- "ignored",
189
- "pending",
190
- "failure",
191
- "retryFailure"
192
- ];
193
- function formatSnapshotSummary(diffs) {
194
- const counts = /* @__PURE__ */ new Map();
195
- for (const diff of diffs) counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1);
196
- return SNAPSHOT_STATUS_ORDER.map((status) => {
197
- const count = counts.get(status);
198
- return count ? `${status} ${count}` : null;
199
- }).filter((part) => Boolean(part)).join(", ");
200
- }
201
- function formatBuild(build) {
202
- return [
203
- `Build #${build.number}`,
204
- `Status: ${build.status}`,
205
- `Snapshots: ${formatStats(build.stats)}`,
206
- `Conclusion: ${formatValue(build.conclusion)}`,
207
- `Branch: ${formatValue(build.head?.branch)}`,
208
- `Commit: ${formatValue(build.head?.sha)}`,
209
- `Base branch: ${formatValue(build.base?.branch)}`,
210
- `Base commit: ${formatValue(build.base?.sha)}`,
211
- `URL: ${build.url}`
212
- ].join("\n");
213
- }
214
- /** Render a 0–1 metric to two decimals, or `-` when absent. */
215
- function formatRatio(value) {
216
- return value === null || value === void 0 ? "-" : value.toFixed(2);
217
- }
218
- /** Compact one-line summary of a test's flakiness metrics. */
219
- function formatFlakiness(metrics) {
220
- return `${formatRatio(metrics.flakiness)} (stability ${formatRatio(metrics.stability)}, consistency ${formatRatio(metrics.consistency)})`;
221
- }
222
- /** Human-readable lines for a single snapshot diff. */
223
- function formatSnapshotDiff(diff, build) {
224
- const lines = [
225
- `${diff.name} [${diff.status}]`,
226
- ` Review: ${build.url}/${diff.id}`,
227
- ` Mask: ${formatValue(diff.url)}`,
228
- ` Base file: ${formatValue(diff.base?.url)}`,
229
- ` Head file: ${formatValue(diff.head?.url)}`,
230
- ` Score: ${formatValue(diff.score)}`,
231
- ` Group: ${formatValue(diff.group)}`
232
- ];
233
- if (diff.test) lines.push(` Flakiness: ${formatFlakiness(diff.test.metrics)}`);
234
- if (diff.change) {
235
- const ignored = diff.change.ignored ? " [ignored]" : "";
236
- lines.push(` Change: ${diff.change.id}${ignored} · ${diff.change.occurrences} occurrences`);
237
- }
238
- return lines;
239
- }
240
- function formatSnapshots(diffs, build) {
241
- if (diffs.length === 0) return "No snapshots found.";
242
- return [
243
- `Snapshots for build #${build.number}`,
244
- `Count: ${diffs.length}`,
245
- `Summary: ${formatSnapshotSummary(diffs)}`,
246
- "",
247
- ...diffs.flatMap((diff) => [...formatSnapshotDiff(diff, build), ""])
248
- ].slice(0, -1).join("\n");
249
- }
250
- function formatChange(change) {
251
- return [
252
- `Change ${change.id}`,
253
- `Ignored: ${change.ignored ? "yes" : "no"}`,
254
- `Occurrences: ${change.occurrences}`
255
- ].join("\n");
256
- }
257
- function formatReview(review) {
258
- const lines = [
259
- `Review #${review.id}`,
260
- `State: ${review.state}`,
261
- `Author: ${formatUser(review.user)}`,
262
- `Date: ${review.date}`
263
- ];
264
- if (review.dismissedAt) lines.push(`Dismissed: ${review.dismissedAt} by ${formatUser(review.dismissedBy)}`);
265
- return lines.join("\n");
266
- }
267
- function formatReviews(reviews) {
268
- if (reviews.length === 0) return "No reviews found.";
269
- return [
270
- `Reviews (${reviews.length})`,
271
- "",
272
- ...reviews.flatMap((review) => [
273
- `#${review.id} [${review.state}${review.dismissedAt ? ", dismissed" : ""}]`,
274
- ` Author: ${formatUser(review.user)}`,
275
- ` Date: ${review.date}`,
276
- ""
277
- ])
278
- ].slice(0, -1).join("\n");
279
- }
280
- function formatAnalytics(analytics, context) {
281
- const { screenshots, builds } = analytics;
282
- const lines = [
283
- `Analytics for ${context.account}`,
284
- `Period: ${context.from} → ${context.to ?? "now"} (grouped by ${context.groupBy})`,
285
- "",
286
- `Builds: ${builds.all.total}`,
287
- ` Changes detected: ${builds.all.changesDetected}`,
288
- ` No changes: ${builds.all.noChanges}`,
289
- ` Accepted: ${builds.all.accepted}`,
290
- ` Rejected: ${builds.all.rejected}`,
291
- `Screenshots: ${screenshots.all.total}`
292
- ];
293
- const names = /* @__PURE__ */ new Map();
294
- for (const project of [...builds.projects, ...screenshots.projects]) names.set(project.id, project.name);
295
- const ids = /* @__PURE__ */ new Set([...Object.keys(builds.all.projects), ...Object.keys(screenshots.all.projects)]);
296
- if (ids.size > 0) {
297
- const rows = [...ids].map((id) => ({
298
- name: names.get(id) ?? id,
299
- builds: builds.all.projects[id] ?? 0,
300
- screenshots: screenshots.all.projects[id] ?? 0
301
- })).sort((a, b) => b.builds - a.builds || b.screenshots - a.screenshots);
302
- lines.push("", `Projects (${rows.length}):`);
303
- for (const row of rows) lines.push(` ${row.name}: ${row.builds} builds, ${row.screenshots} screenshots`);
304
- }
305
- return lines.join("\n");
306
- }
307
- function indent(text) {
308
- return text.split("\n").map((line) => ` ${line}`).join("\n");
309
- }
310
- function formatAnchor(anchor) {
311
- if (!anchor) return null;
312
- return anchor.type === "point" ? `point ${anchor.x},${anchor.y}` : `lines ${anchor.from}-${anchor.to}`;
313
- }
314
- function formatReactions(reactions) {
315
- if (reactions.length === 0) return null;
316
- return reactions.map((r) => `${r.emoji} ${r.count}`).join(" ");
317
- }
318
- function formatComment(comment) {
319
- const lines = [`Comment #${comment.id}`, `Author: ${formatUser(comment.author)}`];
320
- if (comment.threadId) lines.push(`Reply to: ${comment.threadId}`);
321
- if (comment.screenshotDiffId) {
322
- const anchor = formatAnchor(comment.anchor);
323
- lines.push(`Diff: ${comment.screenshotDiffId}${anchor ? ` (${anchor})` : ""}`);
324
- }
325
- if (comment.pending) lines.push("Pending: draft (only visible to you)");
326
- if (comment.resolvedAt) lines.push(`Resolved: ${comment.resolvedAt}`);
327
- const reactions = formatReactions(comment.reactions);
328
- if (reactions) lines.push(`Reactions: ${reactions}`);
329
- lines.push(`Date: ${comment.createdAt}${comment.editedAt ? " (edited)" : ""}`);
330
- lines.push("", indent(comment.text));
331
- return lines.join("\n");
332
- }
333
- function formatComments(comments) {
334
- if (comments.length === 0) return "No comments found.";
335
- return [
336
- `Comments (${comments.length})`,
337
- "",
338
- ...comments.flatMap((comment) => {
339
- const tags = [
340
- comment.threadId ? "reply" : "thread",
341
- comment.pending ? "draft" : null,
342
- comment.resolvedAt ? "resolved" : null
343
- ].filter(Boolean);
344
- return [
345
- `#${comment.id} [${tags.join(", ")}] ${formatUser(comment.author)}`,
346
- indent(comment.text),
347
- ""
348
- ];
349
- })
350
- ].slice(0, -1).join("\n");
351
- }
352
- //#endregion
353
- //#region src/lib/cli-error.ts
354
- /**
355
- * Error whose message is meant to be shown to the user verbatim (prefixed with
356
- * `Error: `) before the CLI exits with a non-zero code. Throw it — via
357
- * {@link fail} — instead of calling `process.exit` directly so the failure can
358
- * be unit-tested and handled in a single place ({@link handleCliError}).
359
- */
360
- var CliError = class extends Error {
361
- constructor(message) {
362
- super(message);
363
- this.name = "CliError";
364
- }
365
- };
366
- /**
367
- * Abort the current command with a user-facing message.
368
- */
369
- function fail(message) {
370
- throw new CliError(message);
371
- }
372
- //#endregion
373
- //#region src/lib/oauth.ts
374
- /**
375
- * OAuth 2.1 client configuration and helpers for the `argos login` flow
376
- * (Authorization Code + PKCE with a loopback redirect, RFC 8252).
377
- *
378
- * The authorization server is the Argos app origin; endpoints and token
379
- * validation are implemented in the main Argos repo (`apps/backend/src/oauth`).
380
- */
381
- /** First-party public client id, seeded in the Argos database. */
382
- const OAUTH_CLIENT_ID = "argos-cli";
383
- /**
384
- * Scopes requested by `argos login`, covering the CLI's user actions (identity,
385
- * projects, reviews, comments). Build upload keeps using a project token.
386
- */
387
- const OAUTH_SCOPES = [
388
- "profile",
389
- "projects:read",
390
- "projects:write",
391
- "reviews:write",
392
- "comments:read",
393
- "comments:write"
394
- ];
395
- /** Refresh a bit early so a token never expires mid-request. */
396
- const EXPIRY_SKEW_MS = 60 * 1e3;
397
- function getAppBaseUrl() {
398
- return process.env["ARGOS_APP_BASE_URL"] ?? "https://app.snapvisor.io/";
399
- }
400
- /**
401
- * A token endpoint request that reached the server and was rejected by it
402
- * (non-2xx / OAuth error), as opposed to a transient network failure. Lets
403
- * callers tell "your session is gone, log in again" from "the network is down".
404
- */
405
- var OAuthTokenError = class extends Error {
406
- code;
407
- constructor(message, code) {
408
- super(message);
409
- this.code = code;
410
- this.name = "OAuthTokenError";
411
- }
412
- };
413
- function toTokenSet(data) {
414
- return {
415
- accessToken: data.access_token,
416
- refreshToken: data.refresh_token,
417
- expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SKEW_MS,
418
- scope: data.scope ?? ""
419
- };
420
- }
421
- /** Generate a PKCE `code_verifier` and its S256 `code_challenge`. */
422
- function generatePkce() {
423
- const codeVerifier = randomBytes(32).toString("base64url");
424
- return {
425
- codeVerifier,
426
- codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url")
427
- };
428
- }
429
- /** Build the `/oauth/authorize` URL to open in the browser. */
430
- function buildAuthorizeUrl(params) {
431
- const url = new URL("/oauth/authorize", getAppBaseUrl());
432
- url.searchParams.set("response_type", "code");
433
- url.searchParams.set("client_id", OAUTH_CLIENT_ID);
434
- url.searchParams.set("redirect_uri", params.redirectUri);
435
- url.searchParams.set("scope", OAUTH_SCOPES.join(" "));
436
- url.searchParams.set("state", params.state);
437
- url.searchParams.set("code_challenge", params.codeChallenge);
438
- url.searchParams.set("code_challenge_method", "S256");
439
- return url.href;
440
- }
441
- async function postToken(body) {
442
- const url = new URL("/oauth/token", getAppBaseUrl());
443
- const response = await fetch(url, {
444
- method: "POST",
445
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
446
- body: new URLSearchParams(body).toString()
447
- });
448
- const data = await response.json().catch(() => null);
449
- if (!response.ok || !data?.access_token) throw new OAuthTokenError(data?.error_description ?? data?.error ?? `HTTP ${response.status}`, data?.error);
450
- if (typeof data.refresh_token !== "string" || data.refresh_token === "") throw new Error("Token endpoint response is missing a refresh token.");
451
- if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) throw new Error("Token endpoint response is missing a valid expiry.");
452
- return data;
122
+ async function postToken(body) {
123
+ const url = new URL("/oauth/token", getAppBaseUrl());
124
+ const response = await fetch(url, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
127
+ body: new URLSearchParams(body).toString()
128
+ });
129
+ const data = await response.json().catch(() => null);
130
+ if (!response.ok || !data?.access_token) throw new OAuthTokenError(data?.error_description ?? data?.error ?? `HTTP ${response.status}`, data?.error);
131
+ if (typeof data.refresh_token !== "string" || data.refresh_token === "") throw new Error("Token endpoint response is missing a refresh token.");
132
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) throw new Error("Token endpoint response is missing a valid expiry.");
133
+ return data;
453
134
  }
454
135
  /** Exchange an authorization code for a token set (`grant_type=authorization_code`). */
455
136
  async function exchangeAuthorizationCode(params) {
@@ -522,7 +203,7 @@ async function clearConfig() {
522
203
  await unlink(configPath);
523
204
  } catch {}
524
205
  }
525
- async function readConfig() {
206
+ async function readConfig$1() {
526
207
  const { configPath } = getConfigPaths();
527
208
  try {
528
209
  const config = parseConfig(await readFile(configPath, "utf8"));
@@ -554,7 +235,7 @@ async function saveOAuthTokens(tokens) {
554
235
  * falls back to a legacy personal access token when present.
555
236
  */
556
237
  async function getAccessToken() {
557
- const config = await readConfig();
238
+ const config = await readConfig$1();
558
239
  if (!config) return;
559
240
  if (config.oauth) {
560
241
  if (Date.now() < config.oauth.expiresAt) return config.oauth.accessToken;
@@ -564,8 +245,8 @@ async function getAccessToken() {
564
245
  return tokens.accessToken;
565
246
  } catch (err) {
566
247
  if (config.token) return config.token;
567
- if (err instanceof OAuthTokenError) console.warn("Warning: Your Argos session has expired. Run `argos login` again.");
568
- else console.warn("Warning: Could not reach Argos to refresh your session. Check your connection and try again.");
248
+ if (err instanceof OAuthTokenError) console.warn("Warning: Your Snapvisor session has expired. Run `argos login` again.");
249
+ else console.warn("Warning: Could not reach Snapvisor to refresh your session. Check your connection and try again.");
569
250
  return;
570
251
  }
571
252
  }
@@ -573,7 +254,7 @@ async function getAccessToken() {
573
254
  }
574
255
  /** Read the stored credentials without refreshing (used by `logout`). */
575
256
  async function getStoredCredentials() {
576
- const config = await readConfig();
257
+ const config = await readConfig$1();
577
258
  return {
578
259
  legacyToken: config?.token,
579
260
  oauth: config?.oauth
@@ -585,10 +266,10 @@ async function clearStoredCredentials() {
585
266
  }
586
267
  //#endregion
587
268
  //#region src/lib/build-reference.ts
588
- const BUILD_URL_REGEXP = /^https:\/\/app\.argos-ci\.(?:com|dev(?::\d+)?)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
269
+ const BUILD_URL_REGEXP = /^https:\/\/app\.(?:argos-ci\.(?:com|dev(?::\d+)?)|snapvisor\.io)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
589
270
  /**
590
271
  * Parse a build reference, accepting either a bare build number (`"1234"`) or a
591
- * full Argos build URL. Returns `null` when the input is neither.
272
+ * full Snapvisor build URL. Returns `null` when the input is neither.
592
273
  */
593
274
  function parseBuildReference(reference) {
594
275
  const asNumber = Number(reference);
@@ -607,7 +288,7 @@ function parseBuildReference(reference) {
607
288
  */
608
289
  function parseBuildReferenceOrFail(reference) {
609
290
  const parsed = parseBuildReference(reference);
610
- if (!parsed) fail(`Build reference must be a valid build number or Argos build URL (https://app.argos-ci.com/.../builds/<number>), got "${reference}".`);
291
+ if (!parsed) fail(`Build reference must be a valid build number or Snapvisor build URL (https://app.snapvisor.io/.../builds/<number>), got "${reference}".`);
611
292
  return parsed;
612
293
  }
613
294
  //#endregion
@@ -640,7 +321,7 @@ function parseProjectPathOrFail(slug) {
640
321
  */
641
322
  async function resolveToken(options) {
642
323
  const token = options.token || process.env["ARGOS_TOKEN"] || await getAccessToken();
643
- if (!token) fail("No Argos token found. Use --token, set ARGOS_TOKEN, or run `argos login`.");
324
+ if (!token) fail("No Snapvisor token found. Use --token, set ARGOS_TOKEN, or run `argos login`.");
644
325
  return token;
645
326
  }
646
327
  function explicitProjectPath(reference, options) {
@@ -733,6 +414,526 @@ async function runBuildAction(opts) {
733
414
  }
734
415
  }
735
416
  //#endregion
417
+ //#region src/lib/wait-for-build.ts
418
+ /** Commander argument parser for `--wait-timeout <seconds>`. */
419
+ function parseWaitTimeout(value) {
420
+ const seconds = Number(value);
421
+ if (!Number.isInteger(seconds) || seconds <= 0) throw new InvalidArgumentError("Must be a positive whole number of seconds.");
422
+ return seconds;
423
+ }
424
+ /**
425
+ * Reject option combinations `--wait` cannot honour, before anything is uploaded.
426
+ * Returns the token used to poll the build, or `null` when not waiting.
427
+ */
428
+ async function prepareWait(options) {
429
+ if (!options.wait) {
430
+ if (options.waitTimeout !== void 0) fail("--wait-timeout requires --wait.");
431
+ return null;
432
+ }
433
+ if (options.parallel || (await readConfig()).parallel) fail("--wait cannot be combined with --parallel (or ARGOS_PARALLEL): a parallel build only finishes once every shard is uploaded and the build is finalized, so waiting on one shard would time out.");
434
+ const token = options.token || process.env["ARGOS_TOKEN"];
435
+ if (!token) fail("--wait needs a project token to read the build back: pass --token or set ARGOS_TOKEN (GitHub Actions OIDC / tokenless uploads and `argos login` are not supported with --wait).");
436
+ return token;
437
+ }
438
+ const POLL_INTERVAL_MS = 5e3;
439
+ /** Statuses a build can still leave: it is waiting for, or being processed by, the diff engine. */
440
+ const IN_FLIGHT_STATUSES = ["pending", "progress"];
441
+ function isFinalStatus(status) {
442
+ return !IN_FLIGHT_STATUSES.includes(status);
443
+ }
444
+ /**
445
+ * Map a build that reached a final status to a pass/fail outcome.
446
+ *
447
+ * Passes: `accepted`, `no-changes`, and a `changes-detected` build whose commit
448
+ * status is already `success` (a reference-branch build is auto-approved but
449
+ * keeps the `changes-detected` API status). Everything else fails: a
450
+ * `changes-detected` build with a pending status is waiting for a human review,
451
+ * which is exactly what `--wait` exists to surface to CI, so the exit code
452
+ * always agrees with the commit status.
453
+ */
454
+ function evaluateFinalBuild(build) {
455
+ if (!isFinalStatus(build.status)) throw new Error(`Invariant: build #${build.number} is still "${build.status}", not final`);
456
+ const status = build.status;
457
+ const label = `Build #${build.number}`;
458
+ switch (status) {
459
+ case "accepted": return {
460
+ ok: true,
461
+ summary: `${label} accepted`
462
+ };
463
+ case "no-changes": return {
464
+ ok: true,
465
+ summary: `${label} has no changes`
466
+ };
467
+ case "changes-detected":
468
+ if (build.notification?.github.state === "success") return {
469
+ ok: true,
470
+ summary: `${label} has changes but its commit status is success (auto-approved)`
471
+ };
472
+ return {
473
+ ok: false,
474
+ summary: `${label} needs review (changes-detected)`
475
+ };
476
+ case "rejected": return {
477
+ ok: false,
478
+ summary: `${label} was rejected`
479
+ };
480
+ case "expired": return {
481
+ ok: false,
482
+ summary: `${label} expired`
483
+ };
484
+ case "error": return {
485
+ ok: false,
486
+ summary: `${label} failed with an error`
487
+ };
488
+ case "aborted": return {
489
+ ok: false,
490
+ summary: `${label} was aborted`
491
+ };
492
+ default: throw new Error(`Unhandled build status: ${String(status)}`);
493
+ }
494
+ }
495
+ /**
496
+ * Extract `owner`, `project` and the build number from the build URL returned
497
+ * by the API (`<app origin>/<owner>/<project>/builds/<number>`). Only the last
498
+ * four path segments are read, so self-hosted origins work too.
499
+ */
500
+ function parseBuildUrl(url) {
501
+ const [owner, project, builds, buildNumber] = new URL(url).pathname.split("/").filter(Boolean).slice(-4).map((segment) => {
502
+ try {
503
+ return decodeURIComponent(segment);
504
+ } catch (error) {
505
+ fail(`Cannot wait for build: malformed build URL "${url}" (${error instanceof Error ? error.message : String(error)}).`);
506
+ }
507
+ });
508
+ if (!owner || !project || builds !== "builds" || !buildNumber) fail(`Cannot wait for build: unexpected build URL "${url}" (expected .../<owner>/<project>/builds/<number>).`);
509
+ return {
510
+ owner,
511
+ project,
512
+ buildNumber
513
+ };
514
+ }
515
+ const realClock = {
516
+ now: () => Date.now(),
517
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
518
+ };
519
+ const MAX_RATE_LIMIT_BACKOFF_MS = 6e4;
520
+ /**
521
+ * How long to wait before retrying a 429: the `Retry-After` header (seconds or
522
+ * HTTP date) when present and valid, else exponential backoff from the poll interval.
523
+ */
524
+ function rateLimitDelayMs(response, attempt, pollIntervalMs, nowMs) {
525
+ const header = response.headers.get("retry-after");
526
+ if (header) {
527
+ const seconds = Number(header);
528
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
529
+ const date = Date.parse(header);
530
+ if (!Number.isNaN(date)) return Math.max(0, date - nowMs);
531
+ }
532
+ return Math.min(MAX_RATE_LIMIT_BACKOFF_MS, pollIntervalMs * 2 ** attempt);
533
+ }
534
+ /**
535
+ * Poll a build until it reaches a final status and return its pass/fail
536
+ * outcome. Throws a {@link CliError} when `timeoutSeconds` elapses first, and
537
+ * lets API failures (401, 404, network errors, ...) propagate — a build we
538
+ * cannot read must not look green. A 429 is retried (honouring `Retry-After`)
539
+ * for as long as the deadline allows. Each request is aborted at the deadline,
540
+ * so the wait never overshoots `timeoutSeconds` by a whole request.
541
+ */
542
+ async function waitForBuild(opts) {
543
+ const { client, buildUrl, timeoutSeconds, pollIntervalMs = POLL_INTERVAL_MS, clock = realClock, onPoll } = opts;
544
+ const { owner, project, buildNumber } = parseBuildUrl(buildUrl);
545
+ const deadline = clock.now() + timeoutSeconds * 1e3;
546
+ let lastStatus = "not yet read";
547
+ let rateLimitAttempt = 0;
548
+ const getBuild = (signal) => client.GET("/projects/{owner}/{project}/builds/{buildNumber}", {
549
+ params: { path: {
550
+ owner,
551
+ project,
552
+ buildNumber
553
+ } },
554
+ signal
555
+ });
556
+ const timeout = () => fail(`Timed out after ${timeoutSeconds}s waiting for build #${buildNumber} to finish (last status: ${lastStatus}). ${buildUrl}`);
557
+ for (;;) {
558
+ const remainingMs = deadline - clock.now();
559
+ if (remainingMs <= 0) timeout();
560
+ const signal = AbortSignal.timeout(remainingMs);
561
+ let result;
562
+ try {
563
+ result = await getBuild(signal);
564
+ } catch (error) {
565
+ if (signal.aborted) timeout();
566
+ throw error;
567
+ }
568
+ if (result.response.status === 429) {
569
+ const delayMs = rateLimitDelayMs(result.response, rateLimitAttempt++, pollIntervalMs, clock.now());
570
+ lastStatus = "rate limited by the API (HTTP 429)";
571
+ if (delayMs >= deadline - clock.now()) timeout();
572
+ await clock.sleep(delayMs);
573
+ continue;
574
+ }
575
+ rateLimitAttempt = 0;
576
+ const build = unwrap(result);
577
+ lastStatus = build.status;
578
+ if (isFinalStatus(build.status)) return evaluateFinalBuild(build);
579
+ onPoll?.(build);
580
+ const untilDeadlineMs = deadline - clock.now();
581
+ if (untilDeadlineMs <= 0) timeout();
582
+ await clock.sleep(Math.min(pollIntervalMs, untilDeadlineMs));
583
+ }
584
+ }
585
+ //#endregion
586
+ //#region src/options.ts
587
+ const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build");
588
+ const tokenOption = new Option("--token <token>", "Repository token");
589
+ const projectOption = new Option("--project <slug>", "Snapvisor project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository");
590
+ const buildNameOption = new Option("--build-name <string>", "Name of the build, in case you want to run multiple Snapvisor builds in a single CI job");
591
+ const accountOption = new Option("--account <slug>", "Slug of the account (personal or team) that will own the project");
592
+ const jsonOption = new Option("--json", "Output machine-readable JSON instead of human-readable text");
593
+ const projectPathOption = new Option("--project <owner/project>", "Project path in owner/project format. Required for build-number references on review and comment commands");
594
+ const changeProjectPathOption = new Option("--project <owner/project>", "Project the change belongs to, in owner/project format. Also ARGOS_PROJECT");
595
+ const METRICS_PERIOD_BY_CHOICE = {
596
+ "24h": "LAST_24_HOURS",
597
+ "3d": "LAST_3_DAYS",
598
+ "7d": "LAST_7_DAYS",
599
+ "30d": "LAST_30_DAYS",
600
+ "90d": "LAST_90_DAYS"
601
+ };
602
+ const metricsPeriodOption = new Option("--metrics-period <period>", "Window over which test flakiness metrics are computed").choices(Object.keys(METRICS_PERIOD_BY_CHOICE)).default("7d");
603
+ /** Map a `--metrics-period` choice to the value the API expects. */
604
+ function toMetricsPeriod(choice) {
605
+ return METRICS_PERIOD_BY_CHOICE[choice] ?? "LAST_7_DAYS";
606
+ }
607
+ //#endregion
608
+ //#region src/commands/upload.ts
609
+ function uploadCommand(program) {
610
+ program.command("upload").argument("<directory>", "Directory to upload").description("Upload screenshots to Snapvisor").option("-f, --files <patterns...>", "One or more globs matching image file paths to upload", "**/*.{png,jpg,jpeg}").option("-i, --ignore <patterns...>", "One or more globs matching image file paths to ignore (ex: \"**/*.png **/diff.jpg\")").addOption(tokenOption).addOption(projectOption).addOption(buildNameOption).addOption(new Option("--mode <string>", "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.").default("ci").choices(["ci", "monitoring"])).addOption(new Option("--parallel", "Enable parallel mode. Run multiple Snapvisor builds and combine them at the end.\nCan also be set via the ARGOS_PARALLEL environment variable.")).addOption(new Option("--parallel-total <number>", "The number of parallel nodes being ran")).addOption(parallelNonceOption).addOption(new Option("--parallel-index <number>", "The index of the parallel node being ran (must be at least 1)")).addOption(new Option("--reference-branch <string>", "Branch used as baseline for screenshot comparison")).addOption(new Option("--reference-commit <string>", "Commit used as baseline for screenshot comparison")).addOption(new Option("--threshold <number>", "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5")).addOption(new Option("--subset", "Whether this build contains only a subset of screenshots.\nThis is useful when a build is created from an incomplete test suite where some tests are skipped.\nCan also be set via the ARGOS_SUBSET environment variable.")).addOption(new Option("--wait", "Wait for the build to finish, then exit 0 if it is accepted or has no changes, and exit 1 if it needs review, is rejected, expired, aborted or errored, or if the wait times out.\nRequires a token (--token, ARGOS_TOKEN) and cannot be combined with --parallel.")).addOption(new Option("--wait-timeout <seconds>", `Maximum time to wait for the build when --wait is set. Defaults to 600.`).argParser(parseWaitTimeout)).action(async (directory, options) => {
611
+ let waitToken;
612
+ try {
613
+ waitToken = await prepareWait({
614
+ wait: options.wait,
615
+ waitTimeout: options.waitTimeout,
616
+ parallel: options.parallel,
617
+ token: options.token
618
+ });
619
+ } catch (error) {
620
+ handleCliError(error);
621
+ }
622
+ const spinner = ora("Uploading screenshots").start();
623
+ let buildUrl;
624
+ try {
625
+ const parallel = (() => {
626
+ if (!options.parallel) return;
627
+ if (!options.parallelNonce) {
628
+ spinner.fail("--parallel-nonce is required if --parallel is set");
629
+ process.exit(1);
630
+ }
631
+ if (!options.parallelTotal) {
632
+ spinner.fail("--parallel-total is required if --parallel is set");
633
+ process.exit(1);
634
+ }
635
+ return {
636
+ nonce: options.parallelNonce,
637
+ total: options.parallelTotal,
638
+ index: options.parallelIndex
639
+ };
640
+ })();
641
+ buildUrl = (await upload({
642
+ token: options.token,
643
+ project: options.project,
644
+ root: directory,
645
+ buildName: options.buildName,
646
+ files: options.files,
647
+ ignore: options.ignore,
648
+ parallel,
649
+ referenceBranch: options.referenceBranch,
650
+ referenceCommit: options.referenceCommit,
651
+ mode: options.mode,
652
+ threshold: options.threshold,
653
+ subset: options.subset
654
+ })).build.url;
655
+ spinner.succeed(`Build created: ${buildUrl}`);
656
+ } catch (error) {
657
+ if (error instanceof Error) {
658
+ spinner.fail(`Build failed: ${error.message}`);
659
+ console.error(error.stack);
660
+ }
661
+ process.exit(1);
662
+ }
663
+ if (waitToken === null) return;
664
+ const waitSpinner = ora("Waiting for the build to finish").start();
665
+ try {
666
+ const outcome = await waitForBuild({
667
+ client: createApiClient(waitToken),
668
+ buildUrl,
669
+ timeoutSeconds: options.waitTimeout ?? 600,
670
+ onPoll: (build) => {
671
+ waitSpinner.text = `Waiting for the build to finish (status: ${build.status})`;
672
+ }
673
+ });
674
+ if (outcome.ok) waitSpinner.succeed(`${outcome.summary}: ${buildUrl}`);
675
+ else {
676
+ waitSpinner.fail(`${outcome.summary}: ${buildUrl}`);
677
+ process.exit(1);
678
+ }
679
+ } catch (error) {
680
+ waitSpinner.fail("Waiting for the build failed");
681
+ handleCliError(error);
682
+ }
683
+ });
684
+ }
685
+ //#endregion
686
+ //#region src/commands/finalize.ts
687
+ const skipIfEmptyOption = new Option("--skip-if-empty", "Create a skipped build when no parallel build matches the nonce.\nUseful when uploads are conditional (e.g. skipped by a CI cache) and Snapvisor is a required status check: the skipped build reports a success status even though nothing was uploaded.");
688
+ function finalizeCommand(program) {
689
+ program.command("finalize").description("Finalize pending parallel builds").addOption(parallelNonceOption).addOption(tokenOption).addOption(skipIfEmptyOption).addOption(buildNameOption).action(async (options) => {
690
+ const spinner = ora("Finalizing builds").start();
691
+ try {
692
+ const result = await finalize({
693
+ token: options.token,
694
+ parallel: options.parallelNonce ? { nonce: options.parallelNonce } : void 0,
695
+ skipIfEmpty: options.skipIfEmpty,
696
+ buildName: options.buildName
697
+ });
698
+ spinner.succeed(result.skippedBuild ? `No builds to finalize — skipped build created: ${result.skippedBuild.url}` : result.builds.length === 0 ? "No builds to finalize" : `Builds finalized: ${result.builds.map((b) => b.url).join(", ")}`);
699
+ } catch (error) {
700
+ if (error instanceof Error) {
701
+ spinner.fail(`Failed to finalize: ${error.message}`);
702
+ console.error(error.stack);
703
+ }
704
+ process.exit(1);
705
+ }
706
+ });
707
+ }
708
+ //#endregion
709
+ //#region src/commands/skip.ts
710
+ function skipCommand(program) {
711
+ program.command("skip").description("Mark a build as skipped").addOption(tokenOption).addOption(buildNameOption).action(async (options) => {
712
+ const spinner = ora("Creating skipped build").start();
713
+ try {
714
+ const result = await skip({
715
+ token: options.token,
716
+ buildName: options.buildName
717
+ });
718
+ spinner.succeed(`Build created: ${result.build.url}`);
719
+ } catch (error) {
720
+ if (error instanceof Error) {
721
+ spinner.fail(`Build failed: ${error.message}`);
722
+ console.error(error.stack);
723
+ }
724
+ process.exit(1);
725
+ }
726
+ });
727
+ }
728
+ //#endregion
729
+ //#region src/lib/format.ts
730
+ /** Render a scalar, using `-` for empty values. */
731
+ function formatValue(value) {
732
+ if (value === null || value === void 0 || value === "") return "-";
733
+ return String(value);
734
+ }
735
+ function formatUser(user) {
736
+ if (!user) return "-";
737
+ return user.name ? `${user.name} (@${user.slug})` : `@${user.slug}`;
738
+ }
739
+ function formatMe(me) {
740
+ const lines = [
741
+ `Logged in to Snapvisor as ${formatValue(me.user.name)}.`,
742
+ `Name: ${formatValue(me.user.name)}`,
743
+ `Email: ${formatValue(me.user.email)}`
744
+ ];
745
+ if (me.accounts.length > 0) lines.push(`Accounts: ${me.accounts.map((a) => `@${a.slug}`).join(", ")}`);
746
+ return lines.join("\n");
747
+ }
748
+ function formatProject(project) {
749
+ return [
750
+ `Created project ${project.account.slug}/${project.name}.`,
751
+ `ID: ${project.id}`,
752
+ `Name: ${project.name}`,
753
+ `Account: ${project.account.slug}`,
754
+ `Default base branch: ${formatValue(project.defaultBaseBranch)}`
755
+ ].join("\n");
756
+ }
757
+ function formatStats(stats) {
758
+ if (!stats) return "-";
759
+ return [
760
+ `total ${stats.total}`,
761
+ `changed ${stats.changed}`,
762
+ `added ${stats.added}`,
763
+ `removed ${stats.removed}`,
764
+ `unchanged ${stats.unchanged}`
765
+ ].join(", ");
766
+ }
767
+ const SNAPSHOT_STATUS_ORDER = [
768
+ "changed",
769
+ "added",
770
+ "removed",
771
+ "unchanged",
772
+ "ignored",
773
+ "pending",
774
+ "failure",
775
+ "retryFailure"
776
+ ];
777
+ function formatSnapshotSummary(diffs) {
778
+ const counts = /* @__PURE__ */ new Map();
779
+ for (const diff of diffs) counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1);
780
+ return SNAPSHOT_STATUS_ORDER.map((status) => {
781
+ const count = counts.get(status);
782
+ return count ? `${status} ${count}` : null;
783
+ }).filter((part) => Boolean(part)).join(", ");
784
+ }
785
+ function formatBuild(build) {
786
+ return [
787
+ `Build #${build.number}`,
788
+ `Status: ${build.status}`,
789
+ `Snapshots: ${formatStats(build.stats)}`,
790
+ `Conclusion: ${formatValue(build.conclusion)}`,
791
+ `Branch: ${formatValue(build.head?.branch)}`,
792
+ `Commit: ${formatValue(build.head?.sha)}`,
793
+ `Base branch: ${formatValue(build.base?.branch)}`,
794
+ `Base commit: ${formatValue(build.base?.sha)}`,
795
+ `URL: ${build.url}`
796
+ ].join("\n");
797
+ }
798
+ /** Render a 0–1 metric to two decimals, or `-` when absent. */
799
+ function formatRatio(value) {
800
+ return value === null || value === void 0 ? "-" : value.toFixed(2);
801
+ }
802
+ /** Compact one-line summary of a test's flakiness metrics. */
803
+ function formatFlakiness(metrics) {
804
+ return `${formatRatio(metrics.flakiness)} (stability ${formatRatio(metrics.stability)}, consistency ${formatRatio(metrics.consistency)})`;
805
+ }
806
+ /** Human-readable lines for a single snapshot diff. */
807
+ function formatSnapshotDiff(diff, build) {
808
+ const lines = [
809
+ `${diff.name} [${diff.status}]`,
810
+ ` Review: ${build.url}/${diff.id}`,
811
+ ` Mask: ${formatValue(diff.url)}`,
812
+ ` Base file: ${formatValue(diff.base?.url)}`,
813
+ ` Head file: ${formatValue(diff.head?.url)}`,
814
+ ` Score: ${formatValue(diff.score)}`,
815
+ ` Group: ${formatValue(diff.group)}`
816
+ ];
817
+ if (diff.test) lines.push(` Flakiness: ${formatFlakiness(diff.test.metrics)}`);
818
+ if (diff.change) {
819
+ const ignored = diff.change.ignored ? " [ignored]" : "";
820
+ lines.push(` Change: ${diff.change.id}${ignored} · ${diff.change.occurrences} occurrences`);
821
+ }
822
+ return lines;
823
+ }
824
+ function formatSnapshots(diffs, build) {
825
+ if (diffs.length === 0) return "No snapshots found.";
826
+ return [
827
+ `Snapshots for build #${build.number}`,
828
+ `Count: ${diffs.length}`,
829
+ `Summary: ${formatSnapshotSummary(diffs)}`,
830
+ "",
831
+ ...diffs.flatMap((diff) => [...formatSnapshotDiff(diff, build), ""])
832
+ ].slice(0, -1).join("\n");
833
+ }
834
+ function formatChange(change) {
835
+ return [
836
+ `Change ${change.id}`,
837
+ `Ignored: ${change.ignored ? "yes" : "no"}`,
838
+ `Occurrences: ${change.occurrences}`
839
+ ].join("\n");
840
+ }
841
+ function formatReview(review) {
842
+ const lines = [
843
+ `Review #${review.id}`,
844
+ `State: ${review.state}`,
845
+ `Author: ${formatUser(review.user)}`,
846
+ `Date: ${review.date}`
847
+ ];
848
+ if (review.dismissedAt) lines.push(`Dismissed: ${review.dismissedAt} by ${formatUser(review.dismissedBy)}`);
849
+ return lines.join("\n");
850
+ }
851
+ function formatReviews(reviews) {
852
+ if (reviews.length === 0) return "No reviews found.";
853
+ return [
854
+ `Reviews (${reviews.length})`,
855
+ "",
856
+ ...reviews.flatMap((review) => [
857
+ `#${review.id} [${review.state}${review.dismissedAt ? ", dismissed" : ""}]`,
858
+ ` Author: ${formatUser(review.user)}`,
859
+ ` Date: ${review.date}`,
860
+ ""
861
+ ])
862
+ ].slice(0, -1).join("\n");
863
+ }
864
+ function formatAnalytics(analytics, context) {
865
+ const { screenshots, builds } = analytics;
866
+ const lines = [
867
+ `Analytics for ${context.account}`,
868
+ `Period: ${context.from} → ${context.to ?? "now"} (grouped by ${context.groupBy})`,
869
+ "",
870
+ `Builds: ${builds.all.total}`,
871
+ ` Changes detected: ${builds.all.changesDetected}`,
872
+ ` No changes: ${builds.all.noChanges}`,
873
+ ` Accepted: ${builds.all.accepted}`,
874
+ ` Rejected: ${builds.all.rejected}`,
875
+ `Screenshots: ${screenshots.all.total}`
876
+ ];
877
+ const names = /* @__PURE__ */ new Map();
878
+ for (const project of [...builds.projects, ...screenshots.projects]) names.set(project.id, project.name);
879
+ const ids = /* @__PURE__ */ new Set([...Object.keys(builds.all.projects), ...Object.keys(screenshots.all.projects)]);
880
+ if (ids.size > 0) {
881
+ const rows = [...ids].map((id) => ({
882
+ name: names.get(id) ?? id,
883
+ builds: builds.all.projects[id] ?? 0,
884
+ screenshots: screenshots.all.projects[id] ?? 0
885
+ })).sort((a, b) => b.builds - a.builds || b.screenshots - a.screenshots);
886
+ lines.push("", `Projects (${rows.length}):`);
887
+ for (const row of rows) lines.push(` ${row.name}: ${row.builds} builds, ${row.screenshots} screenshots`);
888
+ }
889
+ return lines.join("\n");
890
+ }
891
+ function indent(text) {
892
+ return text.split("\n").map((line) => ` ${line}`).join("\n");
893
+ }
894
+ function formatAnchor(anchor) {
895
+ if (!anchor) return null;
896
+ return anchor.type === "point" ? `point ${anchor.x},${anchor.y}` : `lines ${anchor.from}-${anchor.to}`;
897
+ }
898
+ function formatReactions(reactions) {
899
+ if (reactions.length === 0) return null;
900
+ return reactions.map((r) => `${r.emoji} ${r.count}`).join(" ");
901
+ }
902
+ function formatComment(comment) {
903
+ const lines = [`Comment #${comment.id}`, `Author: ${formatUser(comment.author)}`];
904
+ if (comment.threadId) lines.push(`Reply to: ${comment.threadId}`);
905
+ if (comment.screenshotDiffId) {
906
+ const anchor = formatAnchor(comment.anchor);
907
+ lines.push(`Diff: ${comment.screenshotDiffId}${anchor ? ` (${anchor})` : ""}`);
908
+ }
909
+ if (comment.pending) lines.push("Pending: draft (only visible to you)");
910
+ if (comment.resolvedAt) lines.push(`Resolved: ${comment.resolvedAt}`);
911
+ const reactions = formatReactions(comment.reactions);
912
+ if (reactions) lines.push(`Reactions: ${reactions}`);
913
+ lines.push(`Date: ${comment.createdAt}${comment.editedAt ? " (edited)" : ""}`);
914
+ lines.push("", indent(comment.text));
915
+ return lines.join("\n");
916
+ }
917
+ function formatComments(comments) {
918
+ if (comments.length === 0) return "No comments found.";
919
+ return [
920
+ `Comments (${comments.length})`,
921
+ "",
922
+ ...comments.flatMap((comment) => {
923
+ const tags = [
924
+ comment.threadId ? "reply" : "thread",
925
+ comment.pending ? "draft" : null,
926
+ comment.resolvedAt ? "resolved" : null
927
+ ].filter(Boolean);
928
+ return [
929
+ `#${comment.id} [${tags.join(", ")}] ${formatUser(comment.author)}`,
930
+ indent(comment.text),
931
+ ""
932
+ ];
933
+ })
934
+ ].slice(0, -1).join("\n");
935
+ }
936
+ //#endregion
736
937
  //#region src/commands/build/get.ts
737
938
  function registerBuildGet(build) {
738
939
  build.command("get").description("Fetch build metadata").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(jsonOption).action((reference, options) => runBuildAction({
@@ -820,7 +1021,7 @@ function buildCommand(program) {
820
1021
  * Resolve the Markdown body of a comment or review from the mutually-exclusive
821
1022
  * `--body` (inline) and `--body-file` (path) options.
822
1023
  *
823
- * The Argos API also accepts a rich-text JSON document, but that form is only
1024
+ * The Snapvisor API also accepts a rich-text JSON document, but that form is only
824
1025
  * needed for `@mentions` (which require user ids); the CLI exposes Markdown
825
1026
  * only, which the API converts server-side.
826
1027
  */
@@ -843,7 +1044,7 @@ const events = {
843
1044
  comment: "COMMENT"
844
1045
  };
845
1046
  function registerReviewCreate(review) {
846
- review.command("create").description("Submit a review on a build").argument("<buildReference>", "Build number or Argos build URL").addOption(new Option("--event <event>", "Review event to apply to the build").choices(Object.keys(events)).makeOptionMandatory()).option("--body <markdown>", "Markdown comment to attach to the review").option("--body-file <path>", "Read the review comment from a Markdown file").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
1047
+ review.command("create").description("Submit a review on a build").argument("<buildReference>", "Build number or Snapvisor build URL").addOption(new Option("--event <event>", "Review event to apply to the build").choices(Object.keys(events)).makeOptionMandatory()).option("--body <markdown>", "Markdown comment to attach to the review").option("--body-file <path>", "Read the review comment from a Markdown file").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
847
1048
  reference,
848
1049
  options,
849
1050
  auth: "user",
@@ -867,7 +1068,7 @@ function registerReviewCreate(review) {
867
1068
  //#endregion
868
1069
  //#region src/commands/review/dismiss.ts
869
1070
  function registerReviewDismiss(review) {
870
- review.command("dismiss").description("Dismiss a submitted review on a build").argument("<buildReference>", "Build number or Argos build URL").argument("<reviewId>", "ID of the review to dismiss").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, reviewId, options) => runBuildAction({
1071
+ review.command("dismiss").description("Dismiss a submitted review on a build").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<reviewId>", "ID of the review to dismiss").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, reviewId, options) => runBuildAction({
871
1072
  reference,
872
1073
  options,
873
1074
  auth: "user",
@@ -883,7 +1084,7 @@ function registerReviewDismiss(review) {
883
1084
  //#endregion
884
1085
  //#region src/commands/review/list.ts
885
1086
  function registerReviewList(review) {
886
- review.command("list").description("List the reviews submitted on a build").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
1087
+ review.command("list").description("List the reviews submitted on a build").argument("<buildReference>", "Build number or Snapvisor build URL").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
887
1088
  reference,
888
1089
  options,
889
1090
  auth: "user",
@@ -943,7 +1144,7 @@ function parseAnchor(options) {
943
1144
  //#endregion
944
1145
  //#region src/commands/comment/create.ts
945
1146
  function registerCommentCreate(comment) {
946
- comment.command("create").description("Post a comment (or reply) on a build").argument("<buildReference>", "Build number or Argos build URL").option("--body <markdown>", "Markdown body of the comment").option("--body-file <path>", "Read the comment body from a Markdown file").option("--reply-to <threadId>", "Reply to an existing thread (its root comment ID)").option("--diff <screenshotDiffId>", "Anchor the comment to a screenshot diff").option("--anchor-point <x,y>", "Point on the diff in normalized 0-1 coordinates (requires --diff)").option("--anchor-lines <from,to>", "1-based inclusive line range on the diff (requires --diff)").option("--draft", "Attach to your pending review instead of posting immediately").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
1147
+ comment.command("create").description("Post a comment (or reply) on a build").argument("<buildReference>", "Build number or Snapvisor build URL").option("--body <markdown>", "Markdown body of the comment").option("--body-file <path>", "Read the comment body from a Markdown file").option("--reply-to <threadId>", "Reply to an existing thread (its root comment ID)").option("--diff <screenshotDiffId>", "Anchor the comment to a screenshot diff").option("--anchor-point <x,y>", "Point on the diff in normalized 0-1 coordinates (requires --diff)").option("--anchor-lines <from,to>", "1-based inclusive line range on the diff (requires --diff)").option("--draft", "Attach to your pending review instead of posting immediately").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
947
1148
  reference,
948
1149
  options,
949
1150
  auth: "user",
@@ -978,7 +1179,7 @@ function registerCommentCreate(comment) {
978
1179
  */
979
1180
  function defineCommentAction(opts) {
980
1181
  return (comment) => {
981
- comment.command(opts.name).description(opts.description).argument("<buildReference>", "Build number or Argos build URL").argument("<commentId>", "ID of the comment").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, options) => runBuildAction({
1182
+ comment.command(opts.name).description(opts.description).argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, options) => runBuildAction({
982
1183
  reference,
983
1184
  options,
984
1185
  auth: "user",
@@ -1008,7 +1209,7 @@ const registerCommentDelete = defineCommentAction({
1008
1209
  //#endregion
1009
1210
  //#region src/commands/comment/edit.ts
1010
1211
  function registerCommentEdit(comment) {
1011
- comment.command("edit").description("Update the body of a comment (author only)").argument("<buildReference>", "Build number or Argos build URL").argument("<commentId>", "ID of the comment").option("--body <markdown>", "New Markdown body of the comment").option("--body-file <path>", "Read the new body from a Markdown file").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, options) => runBuildAction({
1212
+ comment.command("edit").description("Update the body of a comment (author only)").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").option("--body <markdown>", "New Markdown body of the comment").option("--body-file <path>", "Read the new body from a Markdown file").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, options) => runBuildAction({
1012
1213
  reference,
1013
1214
  options,
1014
1215
  auth: "user",
@@ -1042,7 +1243,7 @@ const registerCommentGet = defineCommentAction({
1042
1243
  //#endregion
1043
1244
  //#region src/commands/comment/list.ts
1044
1245
  function registerCommentList(comment) {
1045
- comment.command("list").description("List the comments on a build").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
1246
+ comment.command("list").description("List the comments on a build").argument("<buildReference>", "Build number or Snapvisor build URL").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, options) => runBuildAction({
1046
1247
  reference,
1047
1248
  options,
1048
1249
  auth: "user",
@@ -1057,7 +1258,7 @@ function registerCommentList(comment) {
1057
1258
  //#endregion
1058
1259
  //#region src/commands/comment/react.ts
1059
1260
  function registerCommentReact(comment) {
1060
- comment.command("react").description("Add an emoji reaction to a comment").argument("<buildReference>", "Build number or Argos build URL").argument("<commentId>", "ID of the comment").argument("<emoji>", "Emoji to react with").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, emoji, options) => runBuildAction({
1261
+ comment.command("react").description("Add an emoji reaction to a comment").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").argument("<emoji>", "Emoji to react with").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, emoji, options) => runBuildAction({
1061
1262
  reference,
1062
1263
  options,
1063
1264
  auth: "user",
@@ -1100,7 +1301,7 @@ const registerCommentSubscribe = defineCommentAction({
1100
1301
  //#endregion
1101
1302
  //#region src/commands/comment/unreact.ts
1102
1303
  function registerCommentUnreact(comment) {
1103
- comment.command("unreact").description("Remove one of your emoji reactions from a comment").argument("<buildReference>", "Build number or Argos build URL").argument("<commentId>", "ID of the comment").argument("<emoji>", "Emoji reaction to remove").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, emoji, options) => runBuildAction({
1304
+ comment.command("unreact").description("Remove one of your emoji reactions from a comment").argument("<buildReference>", "Build number or Snapvisor build URL").argument("<commentId>", "ID of the comment").argument("<emoji>", "Emoji reaction to remove").addOption(tokenOption).addOption(projectPathOption).addOption(jsonOption).action((reference, commentId, emoji, options) => runBuildAction({
1104
1305
  reference,
1105
1306
  options,
1106
1307
  auth: "user",
@@ -1163,7 +1364,7 @@ const LOGIN_TIMEOUT_MS = 300 * 1e3;
1163
1364
  const CALLBACK_ERROR_HTML = `<!DOCTYPE html><html lang="en">
1164
1365
  <head>
1165
1366
  <meta charset="UTF-8">
1166
- <title>Argos CLI</title>
1367
+ <title>Snapvisor CLI</title>
1167
1368
  </head>
1168
1369
  <body>
1169
1370
  <p>Authorization failed. Please run <code>argos login</code> again.</p>
@@ -1297,7 +1498,7 @@ function loginCommand(program) {
1297
1498
  console.error(errorColor(`Error: Authentication failed${err instanceof Error ? ` — ${err.message}` : ""}. Please run \`argos login\` again.`));
1298
1499
  process.exit(1);
1299
1500
  }
1300
- console.log(successColor("Logged in to Argos successfully."));
1501
+ console.log(successColor("Logged in to Snapvisor successfully."));
1301
1502
  });
1302
1503
  }
1303
1504
  //#endregion
@@ -1311,7 +1512,7 @@ function logoutCommand(program) {
1311
1512
  }
1312
1513
  if (oauth) await revokeToken(oauth.refreshToken);
1313
1514
  await clearStoredCredentials();
1314
- console.log("Logged out from Argos.");
1515
+ console.log("Logged out from Snapvisor.");
1315
1516
  });
1316
1517
  }
1317
1518
  //#endregion