@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/LICENSE +8 -0
- package/README.md +45 -0
- package/bin/argos-cli.js +3 -0
- package/dist/index.mjs +1492 -0
- package/package.json +59 -0
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 {};
|