@snapvisor/cli 6.4.1

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 ADDED
@@ -0,0 +1,1492 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { resolve } from "node:path";
4
+ import { Option, program } from "commander";
5
+ import { deploy, finalize, skip, upload } from "@snapvisor/core";
6
+ import ora from "ora";
7
+ import { APIError, createClient, throwAPIError } from "@snapvisor/api-client";
8
+ import { homedir } from "node:os";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { createServer } from "node:http";
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
+ //#region src/lib/api.ts
124
+ /** Optional override for the API base URL (used in tests and dev). */
125
+ function getApiBaseUrl() {
126
+ return process.env["ARGOS_API_BASE_URL"];
127
+ }
128
+ /** Create an Argos API client authenticated with the given token. */
129
+ function createApiClient(authToken) {
130
+ return createClient({
131
+ authToken,
132
+ baseUrl: getApiBaseUrl()
133
+ });
134
+ }
135
+ /**
136
+ * Unwrap an `openapi-fetch` result, returning its data or throwing a formatted
137
+ * {@link APIError} when the request failed or returned an empty body.
138
+ */
139
+ function unwrap(result) {
140
+ if (!result.response.ok) throwAPIError(result.error, result.response);
141
+ if (result.data === void 0 || result.data === null) throw new APIError("Unexpected empty response from API.", { status: result.response.status });
142
+ return result.data;
143
+ }
144
+ //#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);
150
+ }
151
+ function formatUser(user) {
152
+ if (!user) return "-";
153
+ return user.name ? `${user.name} (@${user.slug})` : `@${user.slug}`;
154
+ }
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");
163
+ }
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");
172
+ }
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(", ");
182
+ }
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;
453
+ }
454
+ /** Exchange an authorization code for a token set (`grant_type=authorization_code`). */
455
+ async function exchangeAuthorizationCode(params) {
456
+ return toTokenSet(await postToken({
457
+ grant_type: "authorization_code",
458
+ code: params.code,
459
+ redirect_uri: params.redirectUri,
460
+ client_id: OAUTH_CLIENT_ID,
461
+ code_verifier: params.codeVerifier
462
+ }));
463
+ }
464
+ /** Rotate a refresh token for a new token set (`grant_type=refresh_token`). */
465
+ async function refreshTokenSet(refreshToken) {
466
+ return toTokenSet(await postToken({
467
+ grant_type: "refresh_token",
468
+ refresh_token: refreshToken,
469
+ client_id: OAUTH_CLIENT_ID
470
+ }));
471
+ }
472
+ /** Best-effort token revocation (RFC 7009); never throws. */
473
+ async function revokeToken(token) {
474
+ const url = new URL("/oauth/revoke", getAppBaseUrl());
475
+ await fetch(url, {
476
+ method: "POST",
477
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
478
+ body: new URLSearchParams({
479
+ token,
480
+ client_id: OAUTH_CLIENT_ID
481
+ }).toString()
482
+ }).catch(() => {});
483
+ }
484
+ //#endregion
485
+ //#region src/auth.ts
486
+ function getConfigPaths() {
487
+ const configDir = resolve(homedir(), ".config", "argos-ci");
488
+ return {
489
+ configDir,
490
+ configPath: resolve(configDir, "config.json")
491
+ };
492
+ }
493
+ function parseOAuthTokenSet(value) {
494
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
495
+ const record = value;
496
+ if (typeof record.accessToken === "string" && typeof record.refreshToken === "string" && typeof record.expiresAt === "number") return {
497
+ accessToken: record.accessToken,
498
+ refreshToken: record.refreshToken,
499
+ expiresAt: record.expiresAt,
500
+ scope: typeof record.scope === "string" ? record.scope : ""
501
+ };
502
+ }
503
+ function parseConfig(raw) {
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(raw);
507
+ } catch {
508
+ return null;
509
+ }
510
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
511
+ const record = parsed;
512
+ if ("token" in record && record.token !== void 0 && typeof record.token !== "string") return null;
513
+ const config = {};
514
+ if (typeof record.token === "string") config.token = record.token;
515
+ const oauth = parseOAuthTokenSet(record.oauth);
516
+ if (oauth) config.oauth = oauth;
517
+ return config;
518
+ }
519
+ async function clearConfig() {
520
+ const { configPath } = getConfigPaths();
521
+ try {
522
+ await unlink(configPath);
523
+ } catch {}
524
+ }
525
+ async function readConfig() {
526
+ const { configPath } = getConfigPaths();
527
+ try {
528
+ const config = parseConfig(await readFile(configPath, "utf8"));
529
+ if (config === null) {
530
+ console.warn("Warning: Config file is invalid and has been cleared. Run `argos login` again.");
531
+ await clearConfig();
532
+ return null;
533
+ }
534
+ return config;
535
+ } catch (err) {
536
+ if (err instanceof Error && err.code === "ENOENT") return null;
537
+ throw err;
538
+ }
539
+ }
540
+ async function writeConfig(config) {
541
+ const { configDir, configPath } = getConfigPaths();
542
+ await mkdir(configDir, { recursive: true });
543
+ const tmpPath = configPath + ".tmp";
544
+ await writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 384 });
545
+ await rename(tmpPath, configPath);
546
+ }
547
+ /** Persist the OAuth token set from a successful login or refresh. */
548
+ async function saveOAuthTokens(tokens) {
549
+ await writeConfig({ oauth: tokens });
550
+ }
551
+ /**
552
+ * Return a valid access token for API calls, or `undefined` when not logged in.
553
+ * Transparently refreshes (and persists) an expired OAuth access token, and
554
+ * falls back to a legacy personal access token when present.
555
+ */
556
+ async function getAccessToken() {
557
+ const config = await readConfig();
558
+ if (!config) return;
559
+ if (config.oauth) {
560
+ if (Date.now() < config.oauth.expiresAt) return config.oauth.accessToken;
561
+ try {
562
+ const tokens = await refreshTokenSet(config.oauth.refreshToken);
563
+ await saveOAuthTokens(tokens);
564
+ return tokens.accessToken;
565
+ } catch (err) {
566
+ 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.");
569
+ return;
570
+ }
571
+ }
572
+ return config.token;
573
+ }
574
+ /** Read the stored credentials without refreshing (used by `logout`). */
575
+ async function getStoredCredentials() {
576
+ const config = await readConfig();
577
+ return {
578
+ legacyToken: config?.token,
579
+ oauth: config?.oauth
580
+ };
581
+ }
582
+ /** Remove all stored credentials. */
583
+ async function clearStoredCredentials() {
584
+ await writeConfig({});
585
+ }
586
+ //#endregion
587
+ //#region src/lib/build-reference.ts
588
+ const BUILD_URL_REGEXP = /^https:\/\/app\.argos-ci\.(?:com|dev(?::\d+)?)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
589
+ /**
590
+ * 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.
592
+ */
593
+ function parseBuildReference(reference) {
594
+ const asNumber = Number(reference);
595
+ if (Number.isInteger(asNumber) && asNumber > 0) return { buildNumber: asNumber };
596
+ const match = reference.match(BUILD_URL_REGEXP);
597
+ if (match?.groups) return {
598
+ owner: match.groups.owner,
599
+ project: match.groups.project,
600
+ buildNumber: Number(match.groups.buildNumber)
601
+ };
602
+ return null;
603
+ }
604
+ /**
605
+ * Like {@link parseBuildReference} but aborts the command with a clear message
606
+ * when the reference is invalid.
607
+ */
608
+ function parseBuildReferenceOrFail(reference) {
609
+ 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}".`);
611
+ return parsed;
612
+ }
613
+ //#endregion
614
+ //#region src/lib/project.ts
615
+ /**
616
+ * Parse a `owner/project` slug. Returns `null` when it is not exactly two
617
+ * non-empty segments.
618
+ */
619
+ function parseProjectPath(slug) {
620
+ const parts = slug.split("/");
621
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
622
+ return {
623
+ owner: parts[0],
624
+ project: parts[1]
625
+ };
626
+ }
627
+ /**
628
+ * Like {@link parseProjectPath} but aborts the command on an invalid slug.
629
+ */
630
+ function parseProjectPathOrFail(slug) {
631
+ const parsed = parseProjectPath(slug);
632
+ if (!parsed) fail("--project must be in the format 'owner/project'.");
633
+ return parsed;
634
+ }
635
+ //#endregion
636
+ //#region src/lib/target.ts
637
+ /**
638
+ * Resolve the API token, preferring an explicit token (`--token` /
639
+ * `ARGOS_TOKEN`) over the one stored by `argos login`.
640
+ */
641
+ async function resolveToken(options) {
642
+ 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`.");
644
+ return token;
645
+ }
646
+ function explicitProjectPath(reference, options) {
647
+ if (reference.owner && reference.project) return {
648
+ owner: reference.owner,
649
+ project: reference.project
650
+ };
651
+ const project = options.project || process.env["ARGOS_PROJECT"];
652
+ if (project) return parseProjectPathOrFail(project);
653
+ return null;
654
+ }
655
+ /**
656
+ * Resolve a build reference into a ready-to-use {@link BuildTarget}: an
657
+ * authenticated client and the `owner`, `project` and `buildNumber` needed by
658
+ * every build-scoped endpoint.
659
+ */
660
+ async function resolveBuildTarget(reference, options, { auth }) {
661
+ const parsed = parseBuildReferenceOrFail(reference);
662
+ const buildNumber = String(parsed.buildNumber);
663
+ const client = createApiClient(await resolveToken(options));
664
+ if (auth === "user") {
665
+ const projectPath = explicitProjectPath(parsed, options);
666
+ if (!projectPath) fail("--project <owner/project> is required for build-number references.");
667
+ return {
668
+ client,
669
+ ...projectPath,
670
+ buildNumber
671
+ };
672
+ }
673
+ const projectPath = explicitProjectPath(parsed, options);
674
+ if (projectPath) return {
675
+ client,
676
+ ...projectPath,
677
+ buildNumber
678
+ };
679
+ const project = unwrap(await client.GET("/project"));
680
+ return {
681
+ client,
682
+ owner: project.account.slug,
683
+ project: project.name,
684
+ buildNumber
685
+ };
686
+ }
687
+ /**
688
+ * Resolve a project-scoped command target: an authenticated client and the
689
+ * `owner`/`project` pair every project endpoint needs. The project path comes
690
+ * from `--project owner/project` or the `ARGOS_PROJECT` environment variable.
691
+ */
692
+ async function resolveProjectTarget(options) {
693
+ const client = createApiClient(await resolveToken(options));
694
+ const projectPath = options.project || process.env["ARGOS_PROJECT"];
695
+ if (!projectPath) fail("--project <owner/project> is required. Pass it or set ARGOS_PROJECT.");
696
+ return {
697
+ client,
698
+ ...parseProjectPathOrFail(projectPath)
699
+ };
700
+ }
701
+ //#endregion
702
+ //#region src/lib/run.ts
703
+ const USER_AUTH_HINT = "Ensure your token is a personal access token with access to this project. Project tokens (ARGOS_TOKEN in CI) can read build data but cannot perform review or comment actions.";
704
+ /**
705
+ * Print an error and exit. {@link CliError} and {@link APIError} messages are
706
+ * shown as-is; permission failures on user-authenticated commands get an extra
707
+ * hint about token types.
708
+ */
709
+ function handleCliError(error, auth) {
710
+ let message;
711
+ if (error instanceof CliError || error instanceof APIError) {
712
+ message = error.message;
713
+ if (auth === "user" && error instanceof APIError && (error.status === 401 || error.status === 403)) message += `\n${USER_AUTH_HINT}`;
714
+ } else if (error instanceof Error) message = error.message;
715
+ else message = String(error);
716
+ console.error(`Error: ${message}`);
717
+ process.exit(1);
718
+ }
719
+ /** Print a command result as pretty JSON or human-readable text. */
720
+ function output(data, options, format) {
721
+ console.log(options.json ? JSON.stringify(data, null, 2) : format(data));
722
+ }
723
+ /**
724
+ * Run a build-scoped command end to end: resolve the build target, run the
725
+ * handler, print the result, and turn any failure into a clean CLI error.
726
+ */
727
+ async function runBuildAction(opts) {
728
+ try {
729
+ const target = await resolveBuildTarget(opts.reference, opts.options, { auth: opts.auth });
730
+ output(await opts.handler(target), opts.options, opts.format);
731
+ } catch (error) {
732
+ handleCliError(error, opts.auth);
733
+ }
734
+ }
735
+ //#endregion
736
+ //#region src/commands/build/get.ts
737
+ function registerBuildGet(build) {
738
+ build.command("get").description("Fetch build metadata").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(jsonOption).action((reference, options) => runBuildAction({
739
+ reference,
740
+ options,
741
+ auth: "project",
742
+ handler: async ({ client, owner, project, buildNumber }) => unwrap(await client.GET("/projects/{owner}/{project}/builds/{buildNumber}", { params: { path: {
743
+ owner,
744
+ project,
745
+ buildNumber
746
+ } } })),
747
+ format: formatBuild
748
+ }));
749
+ }
750
+ //#endregion
751
+ //#region src/commands/build/snapshots.ts
752
+ const PER_PAGE = 100;
753
+ function isBuildPending(build) {
754
+ return build.status === "pending" || build.status === "progress";
755
+ }
756
+ async function fetchBuild(target) {
757
+ const { client, owner, project, buildNumber } = target;
758
+ return unwrap(await client.GET("/projects/{owner}/{project}/builds/{buildNumber}", { params: { path: {
759
+ owner,
760
+ project,
761
+ buildNumber
762
+ } } }));
763
+ }
764
+ /** Fetch every diff of a build, following pagination. */
765
+ async function fetchAllDiffs(target, options) {
766
+ const { client, owner, project, buildNumber } = target;
767
+ const results = [];
768
+ for (let page = 1;; page++) {
769
+ const data = unwrap(await client.GET("/projects/{owner}/{project}/builds/{buildNumber}/diffs", { params: {
770
+ path: {
771
+ owner,
772
+ project,
773
+ buildNumber
774
+ },
775
+ query: {
776
+ page: String(page),
777
+ perPage: String(PER_PAGE),
778
+ metricsPeriod: options.metricsPeriod,
779
+ ...options.needsReview ? { needsReview: "true" } : {}
780
+ }
781
+ } }));
782
+ results.push(...data.results);
783
+ if (results.length >= data.pageInfo.total || data.results.length === 0) break;
784
+ }
785
+ return results;
786
+ }
787
+ function registerBuildSnapshots(build) {
788
+ build.command("snapshots").description("Fetch snapshot diffs for a build").argument("<buildReference>", "Build number or Argos build URL").addOption(new Option("--needs-review", "Only include snapshot diffs that require review")).addOption(metricsPeriodOption).addOption(tokenOption).addOption(jsonOption).action(async (reference, options) => {
789
+ try {
790
+ const target = await resolveBuildTarget(reference, options, { auth: "project" });
791
+ const build = await fetchBuild(target);
792
+ if (isBuildPending(build)) {
793
+ const message = `Build #${build.number} is still processing (${build.status}). Try again in a moment.`;
794
+ if (options.json) {
795
+ console.error(`Error: ${message}`);
796
+ process.exit(1);
797
+ }
798
+ console.log(message);
799
+ return;
800
+ }
801
+ output(await fetchAllDiffs(target, {
802
+ needsReview: Boolean(options.needsReview),
803
+ metricsPeriod: toMetricsPeriod(options.metricsPeriod)
804
+ }), options, (data) => formatSnapshots(data, build));
805
+ } catch (error) {
806
+ handleCliError(error, "project");
807
+ }
808
+ });
809
+ }
810
+ //#endregion
811
+ //#region src/commands/build/index.ts
812
+ function buildCommand(program) {
813
+ const build = program.command("build").description("Inspect Snapvisor builds");
814
+ registerBuildGet(build);
815
+ registerBuildSnapshots(build);
816
+ }
817
+ //#endregion
818
+ //#region src/lib/body.ts
819
+ /**
820
+ * Resolve the Markdown body of a comment or review from the mutually-exclusive
821
+ * `--body` (inline) and `--body-file` (path) options.
822
+ *
823
+ * The Argos API also accepts a rich-text JSON document, but that form is only
824
+ * needed for `@mentions` (which require user ids); the CLI exposes Markdown
825
+ * only, which the API converts server-side.
826
+ */
827
+ async function resolveBody(options, { required = false } = {}) {
828
+ const { body, bodyFile } = options;
829
+ if (body !== void 0 && bodyFile !== void 0) fail("Use either --body or --body-file, not both.");
830
+ if (body !== void 0) return body;
831
+ if (bodyFile !== void 0) try {
832
+ return await readFile(bodyFile, "utf8");
833
+ } catch (error) {
834
+ fail(`Failed to read --body-file "${bodyFile}": ${error instanceof Error ? error.message : String(error)}`);
835
+ }
836
+ if (required) fail("A comment body is required. Use --body <markdown> or --body-file <path>.");
837
+ }
838
+ //#endregion
839
+ //#region src/commands/review/create.ts
840
+ const events = {
841
+ approve: "APPROVE",
842
+ reject: "REJECT",
843
+ comment: "COMMENT"
844
+ };
845
+ 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({
847
+ reference,
848
+ options,
849
+ auth: "user",
850
+ handler: async ({ client, owner, project, buildNumber }) => {
851
+ const body = await resolveBody(options);
852
+ return unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/reviews", {
853
+ params: { path: {
854
+ owner,
855
+ project,
856
+ buildNumber
857
+ } },
858
+ body: {
859
+ event: events[options.event],
860
+ body
861
+ }
862
+ }));
863
+ },
864
+ format: formatReview
865
+ }));
866
+ }
867
+ //#endregion
868
+ //#region src/commands/review/dismiss.ts
869
+ 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({
871
+ reference,
872
+ options,
873
+ auth: "user",
874
+ handler: async ({ client, owner, project, buildNumber }) => unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/reviews/{reviewId}/dismiss", { params: { path: {
875
+ owner,
876
+ project,
877
+ buildNumber,
878
+ reviewId
879
+ } } })),
880
+ format: formatReview
881
+ }));
882
+ }
883
+ //#endregion
884
+ //#region src/commands/review/list.ts
885
+ 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({
887
+ reference,
888
+ options,
889
+ auth: "user",
890
+ handler: async ({ client, owner, project, buildNumber }) => unwrap(await client.GET("/projects/{owner}/{project}/builds/{buildNumber}/reviews", { params: { path: {
891
+ owner,
892
+ project,
893
+ buildNumber
894
+ } } })),
895
+ format: formatReviews
896
+ }));
897
+ }
898
+ //#endregion
899
+ //#region src/commands/review/index.ts
900
+ function reviewCommand(program) {
901
+ const review = program.command("review").description("List, submit, and dismiss build reviews");
902
+ registerReviewList(review);
903
+ registerReviewCreate(review);
904
+ registerReviewDismiss(review);
905
+ }
906
+ //#endregion
907
+ //#region src/lib/anchor.ts
908
+ function parsePair(value) {
909
+ const parts = value.split(",").map((part) => Number(part.trim()));
910
+ if (parts.length !== 2 || parts.some((part) => Number.isNaN(part))) return null;
911
+ return [parts[0], parts[1]];
912
+ }
913
+ /**
914
+ * Build a comment anchor from the mutually-exclusive `--anchor-point` and
915
+ * `--anchor-lines` options. Returns `undefined` when neither is provided.
916
+ */
917
+ function parseAnchor(options) {
918
+ const { anchorPoint, anchorLines } = options;
919
+ if (anchorPoint && anchorLines) fail("Use either --anchor-point or --anchor-lines, not both.");
920
+ if (anchorPoint) {
921
+ const pair = parsePair(anchorPoint);
922
+ if (!pair) fail("--anchor-point must be two numbers, e.g. --anchor-point 0.5,0.5.");
923
+ const [x, y] = pair;
924
+ if (x < 0 || x > 1 || y < 0 || y > 1) fail("--anchor-point coordinates must be normalized between 0 and 1.");
925
+ return {
926
+ type: "point",
927
+ x,
928
+ y
929
+ };
930
+ }
931
+ if (anchorLines) {
932
+ const pair = parsePair(anchorLines);
933
+ if (!pair) fail("--anchor-lines must be two numbers, e.g. --anchor-lines 10,20.");
934
+ const [from, to] = pair;
935
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from) fail("--anchor-lines must be a 1-based range where from <= to.");
936
+ return {
937
+ type: "lines",
938
+ from,
939
+ to
940
+ };
941
+ }
942
+ }
943
+ //#endregion
944
+ //#region src/commands/comment/create.ts
945
+ 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({
947
+ reference,
948
+ options,
949
+ auth: "user",
950
+ handler: async ({ client, owner, project, buildNumber }) => {
951
+ const body = await resolveBody(options, { required: true });
952
+ const anchor = parseAnchor(options);
953
+ if (anchor && !options.diff) fail("--anchor-point and --anchor-lines require --diff <screenshotDiffId>.");
954
+ return unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/comments", {
955
+ params: { path: {
956
+ owner,
957
+ project,
958
+ buildNumber
959
+ } },
960
+ body: {
961
+ body,
962
+ threadId: options.replyTo,
963
+ screenshotDiffId: options.diff,
964
+ anchor,
965
+ addToReview: options.draft
966
+ }
967
+ }));
968
+ },
969
+ format: formatComment
970
+ }));
971
+ }
972
+ //#endregion
973
+ //#region src/commands/comment/_action.ts
974
+ /**
975
+ * Register a `comment <name> <buildReference> <commentId>` command that runs
976
+ * `perform` and prints the returned comment. Used for the single-comment
977
+ * actions that share the same shape (get, delete, resolve, subscribe, …).
978
+ */
979
+ function defineCommentAction(opts) {
980
+ 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({
982
+ reference,
983
+ options,
984
+ auth: "user",
985
+ handler: ({ client, owner, project, buildNumber }) => opts.perform({
986
+ client,
987
+ owner,
988
+ project,
989
+ buildNumber,
990
+ commentId
991
+ }),
992
+ format: formatComment
993
+ }));
994
+ };
995
+ }
996
+ //#endregion
997
+ //#region src/commands/comment/delete.ts
998
+ const registerCommentDelete = defineCommentAction({
999
+ name: "delete",
1000
+ description: "Delete a comment on a build (author only)",
1001
+ perform: async ({ client, owner, project, buildNumber, commentId }) => unwrap(await client.DELETE("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}", { params: { path: {
1002
+ owner,
1003
+ project,
1004
+ buildNumber,
1005
+ commentId
1006
+ } } }))
1007
+ });
1008
+ //#endregion
1009
+ //#region src/commands/comment/edit.ts
1010
+ 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({
1012
+ reference,
1013
+ options,
1014
+ auth: "user",
1015
+ handler: async ({ client, owner, project, buildNumber }) => {
1016
+ const body = await resolveBody(options, { required: true });
1017
+ return unwrap(await client.PATCH("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}", {
1018
+ params: { path: {
1019
+ owner,
1020
+ project,
1021
+ buildNumber,
1022
+ commentId
1023
+ } },
1024
+ body: { body }
1025
+ }));
1026
+ },
1027
+ format: formatComment
1028
+ }));
1029
+ }
1030
+ //#endregion
1031
+ //#region src/commands/comment/get.ts
1032
+ const registerCommentGet = defineCommentAction({
1033
+ name: "get",
1034
+ description: "Fetch a single comment on a build",
1035
+ perform: async ({ client, owner, project, buildNumber, commentId }) => unwrap(await client.GET("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}", { params: { path: {
1036
+ owner,
1037
+ project,
1038
+ buildNumber,
1039
+ commentId
1040
+ } } }))
1041
+ });
1042
+ //#endregion
1043
+ //#region src/commands/comment/list.ts
1044
+ 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({
1046
+ reference,
1047
+ options,
1048
+ auth: "user",
1049
+ handler: async ({ client, owner, project, buildNumber }) => unwrap(await client.GET("/projects/{owner}/{project}/builds/{buildNumber}/comments", { params: { path: {
1050
+ owner,
1051
+ project,
1052
+ buildNumber
1053
+ } } })),
1054
+ format: formatComments
1055
+ }));
1056
+ }
1057
+ //#endregion
1058
+ //#region src/commands/comment/react.ts
1059
+ 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({
1061
+ reference,
1062
+ options,
1063
+ auth: "user",
1064
+ handler: async ({ client, owner, project, buildNumber }) => unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}/reactions", {
1065
+ params: { path: {
1066
+ owner,
1067
+ project,
1068
+ buildNumber,
1069
+ commentId
1070
+ } },
1071
+ body: { emoji }
1072
+ })),
1073
+ format: formatComment
1074
+ }));
1075
+ }
1076
+ //#endregion
1077
+ //#region src/commands/comment/resolve.ts
1078
+ const registerCommentResolve = defineCommentAction({
1079
+ name: "resolve",
1080
+ description: "Mark a comment thread as resolved",
1081
+ perform: async ({ client, owner, project, buildNumber, commentId }) => unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}/resolve", { params: { path: {
1082
+ owner,
1083
+ project,
1084
+ buildNumber,
1085
+ commentId
1086
+ } } }))
1087
+ });
1088
+ //#endregion
1089
+ //#region src/commands/comment/subscribe.ts
1090
+ const registerCommentSubscribe = defineCommentAction({
1091
+ name: "subscribe",
1092
+ description: "Subscribe to a comment thread's notifications",
1093
+ perform: async ({ client, owner, project, buildNumber, commentId }) => unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}/subscription", { params: { path: {
1094
+ owner,
1095
+ project,
1096
+ buildNumber,
1097
+ commentId
1098
+ } } }))
1099
+ });
1100
+ //#endregion
1101
+ //#region src/commands/comment/unreact.ts
1102
+ 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({
1104
+ reference,
1105
+ options,
1106
+ auth: "user",
1107
+ handler: async ({ client, owner, project, buildNumber }) => unwrap(await client.DELETE("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}/reactions", { params: {
1108
+ path: {
1109
+ owner,
1110
+ project,
1111
+ buildNumber,
1112
+ commentId
1113
+ },
1114
+ query: { emoji }
1115
+ } })),
1116
+ format: formatComment
1117
+ }));
1118
+ }
1119
+ //#endregion
1120
+ //#region src/commands/comment/unresolve.ts
1121
+ const registerCommentUnresolve = defineCommentAction({
1122
+ name: "unresolve",
1123
+ description: "Reopen a resolved comment thread",
1124
+ perform: async ({ client, owner, project, buildNumber, commentId }) => unwrap(await client.POST("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}/unresolve", { params: { path: {
1125
+ owner,
1126
+ project,
1127
+ buildNumber,
1128
+ commentId
1129
+ } } }))
1130
+ });
1131
+ //#endregion
1132
+ //#region src/commands/comment/unsubscribe.ts
1133
+ const registerCommentUnsubscribe = defineCommentAction({
1134
+ name: "unsubscribe",
1135
+ description: "Unsubscribe from a comment thread's notifications",
1136
+ perform: async ({ client, owner, project, buildNumber, commentId }) => unwrap(await client.DELETE("/projects/{owner}/{project}/builds/{buildNumber}/comments/{commentId}/subscription", { params: { path: {
1137
+ owner,
1138
+ project,
1139
+ buildNumber,
1140
+ commentId
1141
+ } } }))
1142
+ });
1143
+ //#endregion
1144
+ //#region src/commands/comment/index.ts
1145
+ function commentCommand(program) {
1146
+ const comment = program.command("comment").description("List, post, and act on build comments");
1147
+ registerCommentList(comment);
1148
+ registerCommentCreate(comment);
1149
+ registerCommentGet(comment);
1150
+ registerCommentEdit(comment);
1151
+ registerCommentDelete(comment);
1152
+ registerCommentResolve(comment);
1153
+ registerCommentUnresolve(comment);
1154
+ registerCommentReact(comment);
1155
+ registerCommentUnreact(comment);
1156
+ registerCommentSubscribe(comment);
1157
+ registerCommentUnsubscribe(comment);
1158
+ }
1159
+ //#endregion
1160
+ //#region src/commands/login.ts
1161
+ const LOGIN_CLI_SUCCESS_ROUTE = `/auth/cli/success`;
1162
+ const LOGIN_TIMEOUT_MS = 300 * 1e3;
1163
+ const CALLBACK_ERROR_HTML = `<!DOCTYPE html><html lang="en">
1164
+ <head>
1165
+ <meta charset="UTF-8">
1166
+ <title>Argos CLI</title>
1167
+ </head>
1168
+ <body>
1169
+ <p>Authorization failed. Please run <code>argos login</code> again.</p>
1170
+ </body>
1171
+ </html>`;
1172
+ function color(text, code, isTTY) {
1173
+ if (!isTTY || process.env["NO_COLOR"]) return text;
1174
+ return `\x1b[${code}m${text}\x1b[0m`;
1175
+ }
1176
+ const successColor = (text) => color(text, 32, process.stdout.isTTY);
1177
+ const warningColor = (text) => color(text, 33, process.stderr.isTTY);
1178
+ const errorColor = (text) => color(text, 31, process.stderr.isTTY);
1179
+ function startCallbackServer() {
1180
+ return new Promise((resolve, reject) => {
1181
+ let resolveCallback = () => {};
1182
+ let rejectCallback = () => {};
1183
+ let timeout;
1184
+ const callbackPromise = new Promise((res, rej) => {
1185
+ resolveCallback = res;
1186
+ rejectCallback = rej;
1187
+ });
1188
+ const closeServer = () => {
1189
+ if (timeout) clearTimeout(timeout);
1190
+ server.close();
1191
+ server.closeAllConnections();
1192
+ };
1193
+ const server = createServer((req, res) => {
1194
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1195
+ if (url.pathname !== "/callback") {
1196
+ res.writeHead(404, { Connection: "close" });
1197
+ res.end();
1198
+ return;
1199
+ }
1200
+ const callbackError = url.searchParams.get("error");
1201
+ const callbackErrorDescription = url.searchParams.get("error_description");
1202
+ const code = url.searchParams.get("code");
1203
+ const state = url.searchParams.get("state");
1204
+ if (callbackError) {
1205
+ const message = callbackErrorDescription ?? callbackError;
1206
+ res.writeHead(400, {
1207
+ "Content-Type": "text/html; charset=utf-8",
1208
+ Connection: "close"
1209
+ });
1210
+ res.end(CALLBACK_ERROR_HTML, () => {
1211
+ closeServer();
1212
+ rejectCallback(new Error(message));
1213
+ });
1214
+ return;
1215
+ }
1216
+ if (!code || !state) {
1217
+ res.writeHead(400, {
1218
+ "Content-Type": "text/html; charset=utf-8",
1219
+ Connection: "close"
1220
+ });
1221
+ res.end(CALLBACK_ERROR_HTML, () => {
1222
+ closeServer();
1223
+ rejectCallback(/* @__PURE__ */ new Error("Missing code or state in callback"));
1224
+ });
1225
+ return;
1226
+ }
1227
+ res.writeHead(302, {
1228
+ Location: new URL(LOGIN_CLI_SUCCESS_ROUTE, getAppBaseUrl()).href,
1229
+ Connection: "close"
1230
+ });
1231
+ res.end(() => {
1232
+ closeServer();
1233
+ resolveCallback({
1234
+ code,
1235
+ state
1236
+ });
1237
+ });
1238
+ });
1239
+ server.on("error", reject);
1240
+ server.listen(0, "127.0.0.1", () => {
1241
+ const { port } = server.address();
1242
+ timeout = setTimeout(() => {
1243
+ closeServer();
1244
+ rejectCallback(/* @__PURE__ */ new Error("Authentication timed out"));
1245
+ }, LOGIN_TIMEOUT_MS);
1246
+ timeout.unref();
1247
+ resolve({
1248
+ port,
1249
+ waitForCallback: () => callbackPromise
1250
+ });
1251
+ });
1252
+ });
1253
+ }
1254
+ function loginCommand(program) {
1255
+ program.command("login").description("Log in to Snapvisor by opening your browser").action(async () => {
1256
+ const state = randomBytes(16).toString("hex");
1257
+ const { codeVerifier, codeChallenge } = generatePkce();
1258
+ let port;
1259
+ let waitForCallback;
1260
+ try {
1261
+ ({port, waitForCallback} = await startCallbackServer());
1262
+ } catch (err) {
1263
+ console.error(errorColor("Error: Failed to start local callback server."));
1264
+ console.error(err);
1265
+ process.exit(1);
1266
+ }
1267
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
1268
+ const authorizeUrl = buildAuthorizeUrl({
1269
+ redirectUri,
1270
+ state,
1271
+ codeChallenge
1272
+ });
1273
+ console.log("\nOpening browser for authentication…");
1274
+ console.log(`If the browser doesn't open, visit:\n ${authorizeUrl}\n`);
1275
+ if (process.env["ARGOS_CLI_DISABLE_BROWSER"] !== "1") await open(authorizeUrl).catch((err) => {
1276
+ console.warn(warningColor(`Warning: Failed to open browser — ${err instanceof Error ? err.message : String(err)}`));
1277
+ console.log("Hint: Open the URL above manually to continue authentication.");
1278
+ });
1279
+ let result;
1280
+ try {
1281
+ result = await waitForCallback();
1282
+ } catch (err) {
1283
+ console.error(errorColor(err instanceof Error ? `Error: ${err.message}. Please run \`argos login\` again.` : "Error: Authorization failed or was cancelled."));
1284
+ process.exit(1);
1285
+ }
1286
+ if (result.state !== state) {
1287
+ console.error(errorColor("Error: State mismatch. Try logging in again."));
1288
+ process.exit(1);
1289
+ }
1290
+ try {
1291
+ await saveOAuthTokens(await exchangeAuthorizationCode({
1292
+ code: result.code,
1293
+ codeVerifier,
1294
+ redirectUri
1295
+ }));
1296
+ } catch (err) {
1297
+ console.error(errorColor(`Error: Authentication failed${err instanceof Error ? ` — ${err.message}` : ""}. Please run \`argos login\` again.`));
1298
+ process.exit(1);
1299
+ }
1300
+ console.log(successColor("Logged in to Argos successfully."));
1301
+ });
1302
+ }
1303
+ //#endregion
1304
+ //#region src/commands/logout.ts
1305
+ function logoutCommand(program) {
1306
+ program.command("logout").description("Log out from Snapvisor").action(async () => {
1307
+ const { legacyToken, oauth } = await getStoredCredentials();
1308
+ if (!legacyToken && !oauth) {
1309
+ console.log("No token found. You are already logged out.");
1310
+ return;
1311
+ }
1312
+ if (oauth) await revokeToken(oauth.refreshToken);
1313
+ await clearStoredCredentials();
1314
+ console.log("Logged out from Argos.");
1315
+ });
1316
+ }
1317
+ //#endregion
1318
+ //#region src/commands/deploy.ts
1319
+ function deployCommand(program) {
1320
+ program.command("deploy").argument("<directory>", "Directory of the static build to deploy").description("Deploy a static build (Storybook or any static site) to Snapvisor").addOption(tokenOption).addOption(new Option("--prod", "Deploy as a production deployment").default(false)).action(async (directory, options) => {
1321
+ const spinner = ora("Deploying").start();
1322
+ try {
1323
+ const result = await deploy({
1324
+ token: options.token,
1325
+ root: resolve(directory),
1326
+ environment: options.prod ? "production" : void 0
1327
+ });
1328
+ spinner.succeed(`Deployed: ${result.url}`);
1329
+ } catch (error) {
1330
+ if (error instanceof Error) {
1331
+ spinner.fail(`Deploy failed: ${error.message}`);
1332
+ console.error(error.stack);
1333
+ }
1334
+ process.exit(1);
1335
+ }
1336
+ });
1337
+ }
1338
+ //#endregion
1339
+ //#region src/commands/whoami.ts
1340
+ function whoamiCommand(program) {
1341
+ program.command("whoami").description("Display the user authenticated with the current Snapvisor token").addOption(tokenOption).addOption(jsonOption).action(async (options) => {
1342
+ try {
1343
+ output(unwrap(await createApiClient(await resolveToken(options)).GET("/me")), options, formatMe);
1344
+ } catch (error) {
1345
+ handleCliError(error, "user");
1346
+ }
1347
+ });
1348
+ }
1349
+ //#endregion
1350
+ //#region src/commands/create-project.ts
1351
+ function createProjectCommand(program) {
1352
+ program.command("create-project").argument("<name>", "Name of the project to create").description("Create a new project in an account you administer").addOption(accountOption).addOption(tokenOption).addOption(jsonOption).action(async (name, options) => {
1353
+ try {
1354
+ const accountSlug = options.account || process.env["ARGOS_ACCOUNT"];
1355
+ if (!accountSlug) fail("An account is required. Use --account <slug> or set ARGOS_ACCOUNT.");
1356
+ output(unwrap(await createApiClient(await resolveToken(options)).POST("/projects", { body: {
1357
+ name,
1358
+ accountSlug
1359
+ } })), options, formatProject);
1360
+ } catch (error) {
1361
+ handleCliError(error, "user");
1362
+ }
1363
+ });
1364
+ }
1365
+ //#endregion
1366
+ //#region src/commands/analytics.ts
1367
+ const DEFAULT_PERIOD_DAYS = 30;
1368
+ const DAY_MS = 1440 * 60 * 1e3;
1369
+ /** Coerce a user-supplied date into an ISO 8601 datetime, or fail cleanly. */
1370
+ function toIsoOrFail(value, option) {
1371
+ const date = new Date(value);
1372
+ if (Number.isNaN(date.getTime())) fail(`Invalid --${option} value: "${value}". Use an ISO 8601 datetime.`);
1373
+ return date.toISOString();
1374
+ }
1375
+ /** Collect a repeatable `--project` flag into an array of project names. */
1376
+ function collectProject(value, previous = []) {
1377
+ return [...previous, value];
1378
+ }
1379
+ function analyticsCommand(program) {
1380
+ program.command("analytics").description("Fetch build and screenshot analytics for an account").option("--account <slug>", "Slug of the account (personal or team) to fetch analytics for").option("--from <datetime>", `Start of the analytics period (ISO 8601). Defaults to ${DEFAULT_PERIOD_DAYS} days ago.`).option("--to <datetime>", "End of the analytics period (ISO 8601). Defaults to now.").addOption(new Option("--group-by <period>", "Time period used to group each series data point").choices([
1381
+ "day",
1382
+ "week",
1383
+ "month"
1384
+ ]).default("day")).option("--project <name>", "Filter by project name. Repeat the flag to include multiple projects.", collectProject).addOption(tokenOption).addOption(jsonOption).action(async (options) => {
1385
+ try {
1386
+ const accountSlug = options.account || process.env["ARGOS_ACCOUNT"];
1387
+ if (!accountSlug) fail("An account is required. Use --account <slug> or set ARGOS_ACCOUNT.");
1388
+ const from = options.from ? toIsoOrFail(options.from, "from") : (/* @__PURE__ */ new Date(Date.now() - DEFAULT_PERIOD_DAYS * DAY_MS)).toISOString();
1389
+ const to = options.to ? toIsoOrFail(options.to, "to") : void 0;
1390
+ output(unwrap(await createApiClient(await resolveToken({ token: options.token })).GET("/accounts/{accountSlug}/analytics", { params: {
1391
+ path: { accountSlug },
1392
+ query: {
1393
+ from,
1394
+ to,
1395
+ groupBy: options.groupBy,
1396
+ projectNames: options.project
1397
+ }
1398
+ } })), options, (data) => formatAnalytics(data, {
1399
+ account: accountSlug,
1400
+ from,
1401
+ to,
1402
+ groupBy: options.groupBy
1403
+ }));
1404
+ } catch (error) {
1405
+ handleCliError(error, "user");
1406
+ }
1407
+ });
1408
+ }
1409
+ //#endregion
1410
+ //#region src/commands/change/_action.ts
1411
+ /**
1412
+ * Register a `change <name> <changeId>` command that runs `perform` and prints
1413
+ * the updated change. Both change actions (ignore, unignore) share the same
1414
+ * shape and differ only by the endpoint they hit. They act as a user, so they
1415
+ * need a personal access token, and the project comes from `--project` or
1416
+ * `ARGOS_PROJECT` since a change id does not carry the account slug.
1417
+ */
1418
+ function defineChangeAction(opts) {
1419
+ return (change) => {
1420
+ change.command(opts.name).description(opts.description).argument("<changeId>", "Change ID, taken from a diff's `change.id` (see `argos build snapshots`)").addOption(tokenOption).addOption(changeProjectPathOption).addOption(metricsPeriodOption).addOption(jsonOption).action(async (changeId, options) => {
1421
+ try {
1422
+ const { client, owner, project } = await resolveProjectTarget(options);
1423
+ output(await opts.perform({
1424
+ client,
1425
+ owner,
1426
+ project,
1427
+ changeId,
1428
+ metricsPeriod: toMetricsPeriod(options.metricsPeriod)
1429
+ }), options, formatChange);
1430
+ } catch (error) {
1431
+ handleCliError(error, "user");
1432
+ }
1433
+ });
1434
+ };
1435
+ }
1436
+ //#endregion
1437
+ //#region src/commands/change/ignore.ts
1438
+ const registerChangeIgnore = defineChangeAction({
1439
+ name: "ignore",
1440
+ description: "Ignore a flaky test change so its diffs stop requiring review and are auto-approved on future builds",
1441
+ perform: async ({ client, owner, project, changeId, metricsPeriod }) => unwrap(await client.POST("/projects/{owner}/{project}/changes/{changeId}/ignore", { params: {
1442
+ path: {
1443
+ owner,
1444
+ project,
1445
+ changeId
1446
+ },
1447
+ query: { metricsPeriod }
1448
+ } }))
1449
+ });
1450
+ //#endregion
1451
+ //#region src/commands/change/unignore.ts
1452
+ const registerChangeUnignore = defineChangeAction({
1453
+ name: "unignore",
1454
+ description: "Stop ignoring a test change so its diffs require review again on future builds",
1455
+ perform: async ({ client, owner, project, changeId, metricsPeriod }) => unwrap(await client.POST("/projects/{owner}/{project}/changes/{changeId}/unignore", { params: {
1456
+ path: {
1457
+ owner,
1458
+ project,
1459
+ changeId
1460
+ },
1461
+ query: { metricsPeriod }
1462
+ } }))
1463
+ });
1464
+ //#endregion
1465
+ //#region src/commands/change/index.ts
1466
+ function changeCommand(program) {
1467
+ const change = program.command("change").description("Ignore or unignore flaky test changes");
1468
+ registerChangeIgnore(change);
1469
+ registerChangeUnignore(change);
1470
+ }
1471
+ //#endregion
1472
+ //#region src/index.ts
1473
+ const rawPkg = await readFile(resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json"), "utf8");
1474
+ const pkg = JSON.parse(rawPkg);
1475
+ program.name(pkg.name).description("Interact with and upload screenshots to Snapvisor via command line.").version(pkg.version);
1476
+ uploadCommand(program);
1477
+ skipCommand(program);
1478
+ finalizeCommand(program);
1479
+ buildCommand(program);
1480
+ reviewCommand(program);
1481
+ commentCommand(program);
1482
+ loginCommand(program);
1483
+ logoutCommand(program);
1484
+ deployCommand(program);
1485
+ whoamiCommand(program);
1486
+ createProjectCommand(program);
1487
+ analyticsCommand(program);
1488
+ changeCommand(program);
1489
+ if (!process.argv.slice(2).length) program.outputHelp();
1490
+ else program.parse(process.argv);
1491
+ //#endregion
1492
+ export {};