@argos-ci/cli 4.1.4 → 4.1.5-alpha.6

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.
Files changed (2) hide show
  1. package/dist/index.mjs +484 -1
  2. package/package.json +9 -4
package/dist/index.mjs CHANGED
@@ -1,9 +1,14 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { resolve } from "node:path";
4
4
  import { Option, program } from "commander";
5
5
  import { finalize, skip, upload } from "@argos-ci/core";
6
6
  import ora from "ora";
7
+ import { createClient, formatAPIError, throwAPIError } from "@argos-ci/api-client";
8
+ import { homedir } from "node:os";
9
+ import { createServer } from "node:http";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import open from "open";
7
12
  //#region src/options.ts
8
13
  const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build").env("ARGOS_PARALLEL_NONCE");
9
14
  const tokenOption = new Option("--token <token>", "Repository token").env("ARGOS_TOKEN");
@@ -91,6 +96,482 @@ function skipCommand(program) {
91
96
  });
92
97
  }
93
98
  //#endregion
99
+ //#region src/auth.ts
100
+ function getConfigPaths() {
101
+ const configDir = resolve(homedir(), ".config", "argos-ci");
102
+ return {
103
+ configDir,
104
+ configPath: resolve(configDir, "config.json")
105
+ };
106
+ }
107
+ function parseConfig(raw) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ } catch {
112
+ return null;
113
+ }
114
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
115
+ const record = parsed;
116
+ if (!("token" in record) || record.token === void 0) return {};
117
+ if (typeof record.token !== "string") return null;
118
+ return { token: record.token };
119
+ }
120
+ async function clearConfig() {
121
+ const { configPath } = getConfigPaths();
122
+ try {
123
+ await unlink(configPath);
124
+ } catch {}
125
+ }
126
+ async function readConfig() {
127
+ const { configPath } = getConfigPaths();
128
+ try {
129
+ const config = parseConfig(await readFile(configPath, "utf8"));
130
+ if (config === null) {
131
+ console.warn("Warning: Config file is invalid and has been cleared. Run `argos login` again.");
132
+ await clearConfig();
133
+ return null;
134
+ }
135
+ return config;
136
+ } catch (err) {
137
+ if (err instanceof Error && err.code === "ENOENT") return null;
138
+ throw err;
139
+ }
140
+ }
141
+ async function writeConfig(config) {
142
+ const { configDir, configPath } = getConfigPaths();
143
+ await mkdir(configDir, { recursive: true });
144
+ const tmpPath = configPath + ".tmp";
145
+ await writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 384 });
146
+ await rename(tmpPath, configPath);
147
+ }
148
+ async function getStoredToken() {
149
+ return (await readConfig())?.token;
150
+ }
151
+ async function saveToken(token) {
152
+ await writeConfig({
153
+ ...await readConfig() ?? {},
154
+ token
155
+ });
156
+ }
157
+ async function removeToken() {
158
+ await writeConfig({});
159
+ }
160
+ //#endregion
161
+ //#region src/commands/build.ts
162
+ const reviewConclusions = {
163
+ approve: "APPROVE",
164
+ "request-changes": "REQUEST_CHANGES"
165
+ };
166
+ async function getTokenOrExit(options) {
167
+ const token = options.token || process.env["ARGOS_TOKEN"] || await getStoredToken();
168
+ if (!token) {
169
+ console.error("Error: No Argos token found. Use --token, set ARGOS_TOKEN, or run `argos login`.");
170
+ process.exit(1);
171
+ }
172
+ return token;
173
+ }
174
+ function getAPIBaseURL() {
175
+ return process.env["ARGOS_API_BASE_URL"];
176
+ }
177
+ function getProjectTokenOrExit(options) {
178
+ const projectToken = options.token || process.env["ARGOS_TOKEN"];
179
+ if (!projectToken) {
180
+ console.error("Error: No Argos project token found. Use --token or set ARGOS_TOKEN.");
181
+ process.exit(1);
182
+ }
183
+ return projectToken;
184
+ }
185
+ function isBuildPending(build) {
186
+ return build.status === "pending" || build.status === "progress";
187
+ }
188
+ function parseBuildReferenceDetailsOrExit(buildReference) {
189
+ const parsedBuildNumber = Number(buildReference);
190
+ if (Number.isFinite(parsedBuildNumber) && Number.isInteger(parsedBuildNumber)) return { buildNumber: parsedBuildNumber };
191
+ const urlMatch = buildReference.match(/^https:\/\/app\.argos-ci\.(?:com|dev(?::\d+)?)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:\/?$|[?#])/);
192
+ if (urlMatch?.groups) return {
193
+ owner: urlMatch.groups.owner,
194
+ project: urlMatch.groups.project,
195
+ buildNumber: Number(urlMatch.groups.buildNumber)
196
+ };
197
+ console.error(`Error: Build reference must be a valid build number or Argos build URL (https://app.argos-ci.com/.../builds/<number>), got "${buildReference}".`);
198
+ process.exit(1);
199
+ }
200
+ function formatValue(value) {
201
+ if (value === null || value === void 0 || value === "") return "-";
202
+ return String(value);
203
+ }
204
+ function formatStats(stats) {
205
+ if (!stats) return "-";
206
+ return [
207
+ `total ${stats.total}`,
208
+ `changed ${stats.changed}`,
209
+ `added ${stats.added}`,
210
+ `removed ${stats.removed}`,
211
+ `unchanged ${stats.unchanged}`
212
+ ].join(", ");
213
+ }
214
+ function formatSnapshotSummary(diffs) {
215
+ const counts = /* @__PURE__ */ new Map();
216
+ for (const diff of diffs) counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1);
217
+ return [
218
+ "changed",
219
+ "added",
220
+ "removed",
221
+ "unchanged",
222
+ "ignored",
223
+ "pending",
224
+ "failure",
225
+ "retryFailure"
226
+ ].map((status) => {
227
+ const count = counts.get(status);
228
+ return count ? `${status} ${count}` : null;
229
+ }).filter((part) => Boolean(part)).join(", ");
230
+ }
231
+ function printBuild(build) {
232
+ const lines = [
233
+ `Build #${build.number}`,
234
+ `Status: ${build.status}`,
235
+ `Snapshots: ${formatStats(build.stats)}`,
236
+ `Conclusion: ${formatValue(build.conclusion)}`,
237
+ `Branch: ${formatValue(build.head?.branch)}`,
238
+ `Commit: ${formatValue(build.head?.sha)}`,
239
+ `Base branch: ${formatValue(build.base?.branch)}`,
240
+ `Base commit: ${formatValue(build.base?.sha)}`,
241
+ `URL: ${build.url}`
242
+ ];
243
+ console.log(lines.join("\n"));
244
+ }
245
+ function printSnapshots(diffs, build) {
246
+ if (diffs.length === 0) {
247
+ console.log("No snapshots found.");
248
+ return;
249
+ }
250
+ const lines = [
251
+ `Snapshots for build #${build.number}`,
252
+ `Count: ${diffs.length}`,
253
+ `Summary: ${formatSnapshotSummary(diffs)}`,
254
+ "",
255
+ ...diffs.flatMap((diff) => {
256
+ return [
257
+ `${diff.name} [${diff.status}]`,
258
+ ` Review: ${build.url}/${diff.id}`,
259
+ ` Mask: ${formatValue(diff.url)}`,
260
+ ` Base file: ${formatValue(diff.base?.url)}`,
261
+ ` Head file: ${formatValue(diff.head?.url)}`,
262
+ ` Score: ${formatValue(diff.score)}`,
263
+ ` Group: ${formatValue(diff.group)}`,
264
+ ""
265
+ ];
266
+ })
267
+ ];
268
+ console.log(lines.slice(0, -1).join("\n"));
269
+ }
270
+ function printReview(review, buildNumber) {
271
+ const lines = [
272
+ `Review #${review.id}`,
273
+ `State: ${review.state}`,
274
+ `Build: #${buildNumber}`
275
+ ];
276
+ console.log(lines.join("\n"));
277
+ }
278
+ function createProjectClient(options) {
279
+ return createClient({
280
+ authToken: getProjectTokenOrExit(options),
281
+ baseUrl: getAPIBaseURL()
282
+ });
283
+ }
284
+ async function fetchAllDiffs(client, project, buildNumber, options) {
285
+ const results = [];
286
+ let page = 1;
287
+ const perPage = 100;
288
+ while (true) {
289
+ const query = {
290
+ page: String(page),
291
+ perPage: String(perPage),
292
+ ...options?.needsReview ? { needsReview: true } : {}
293
+ };
294
+ const { data, error } = await client.GET("/projects/{owner}/{project}/builds/{buildNumber}/diffs", { params: {
295
+ path: {
296
+ owner: project.account.slug,
297
+ project: project.name,
298
+ buildNumber: String(buildNumber)
299
+ },
300
+ query
301
+ } });
302
+ if (error || !data) {
303
+ if (error) throwAPIError(error);
304
+ throw new Error("Unexpected empty response from API.");
305
+ }
306
+ results.push(...data.results);
307
+ if (results.length >= data.pageInfo.total) break;
308
+ page++;
309
+ }
310
+ return results;
311
+ }
312
+ async function fetchProject(client) {
313
+ const { data, error } = await client.GET("/project");
314
+ if (error) throwAPIError(error);
315
+ if (!data) {
316
+ console.error("Error: Unexpected empty response from API.");
317
+ process.exit(1);
318
+ }
319
+ return data;
320
+ }
321
+ async function fetchBuildByNumber(client, project, buildNumber, errorLabel) {
322
+ const { data, error, response } = await client.GET("/projects/{owner}/{project}/builds/{buildNumber}", { params: { path: {
323
+ owner: project.account.slug,
324
+ project: project.name,
325
+ buildNumber: String(buildNumber)
326
+ } } });
327
+ if (error) {
328
+ if (response.status === 404) {
329
+ console.error(`Error: Build number ${errorLabel} not found.`);
330
+ process.exit(1);
331
+ }
332
+ throwAPIError(error);
333
+ }
334
+ if (!data) {
335
+ console.error("Error: Unexpected empty response from API.");
336
+ process.exit(1);
337
+ }
338
+ return data;
339
+ }
340
+ function buildCommand(program) {
341
+ const build = program.command("build").description("Manage Argos builds");
342
+ const createJsonOption = () => new Option("--json", "Output machine-readable JSON instead of human-readable text");
343
+ build.command("get").description("Fetch build metadata").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(createJsonOption()).action(async (buildReference, options) => {
344
+ const { buildNumber } = parseBuildReferenceDetailsOrExit(buildReference);
345
+ const client = createProjectClient(options);
346
+ const build = await fetchBuildByNumber(client, await fetchProject(client), buildNumber, buildReference);
347
+ if (options.json) {
348
+ console.log(JSON.stringify(build, null, 2));
349
+ return;
350
+ }
351
+ printBuild(build);
352
+ });
353
+ build.command("snapshots").description("Fetch snapshot diffs for a build").argument("<buildReference>", "Build number or Argos build URL").option("--needs-review", "Only include snapshot diffs that require review").addOption(tokenOption).addOption(createJsonOption()).action(async (buildReference, options) => {
354
+ const { buildNumber } = parseBuildReferenceDetailsOrExit(buildReference);
355
+ const client = createProjectClient(options);
356
+ const project = await fetchProject(client);
357
+ const build = await fetchBuildByNumber(client, project, buildNumber, buildReference);
358
+ if (isBuildPending(build)) {
359
+ if (options.json) {
360
+ console.error(`Error: Build #${build.number} is still processing (${build.status}). Try again in a moment.`);
361
+ process.exit(1);
362
+ }
363
+ console.log(`Build #${build.number} is still processing (${build.status}). Try again in a moment.`);
364
+ return;
365
+ }
366
+ const diffs = await fetchAllDiffs(client, project, buildNumber, { needsReview: Boolean(options.needsReview) });
367
+ if (options.json) {
368
+ console.log(JSON.stringify(diffs, null, 2));
369
+ return;
370
+ }
371
+ printSnapshots(diffs, build);
372
+ });
373
+ build.command("review").description("Create a review on a build").argument("<buildReference>", "Build number or Argos build URL").addOption(new Option("--conclusion <conclusion>", "Overall review conclusion for the build: \"approve\" or \"request-changes\"").choices(Object.keys(reviewConclusions)).makeOptionMandatory()).option("--project <project>", "Project path in owner/project format").addOption(tokenOption).addOption(createJsonOption()).action(async (buildReference, options) => {
374
+ const conclusion = reviewConclusions[options.conclusion];
375
+ const buildReferenceDetails = parseBuildReferenceDetailsOrExit(buildReference);
376
+ let owner;
377
+ let project;
378
+ if (buildReferenceDetails.owner && buildReferenceDetails.project) {
379
+ owner = buildReferenceDetails.owner;
380
+ project = buildReferenceDetails.project;
381
+ } else if (options.project) {
382
+ const parts = options.project.split("/");
383
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
384
+ console.error("Error: --project must be in the format 'owner/project'.");
385
+ process.exit(1);
386
+ }
387
+ [owner, project] = parts;
388
+ } else {
389
+ console.error("Error: --project <owner/project> is required for build-number references.");
390
+ process.exit(1);
391
+ }
392
+ const { data, error } = await createClient({
393
+ authToken: await getTokenOrExit(options),
394
+ baseUrl: getAPIBaseURL()
395
+ }).POST("/projects/{owner}/{project}/builds/{buildNumber}/reviews", {
396
+ params: { path: {
397
+ owner,
398
+ project,
399
+ buildNumber: String(buildReferenceDetails.buildNumber)
400
+ } },
401
+ body: { conclusion }
402
+ });
403
+ if (error) {
404
+ const message = formatAPIError(error);
405
+ throw new Error([
406
+ `Failed to create review for ${owner}/${project} build #${buildReferenceDetails.buildNumber}: ${message}`,
407
+ "Make sure the token passed with --token is a user access token with access to this project.",
408
+ "Project tokens from ARGOS_TOKEN can read build data, but they cannot create reviews."
409
+ ].join("\n"));
410
+ }
411
+ if (!data) {
412
+ console.error("Error: Unexpected empty response from API.");
413
+ process.exit(1);
414
+ }
415
+ if (options.json) {
416
+ console.log(JSON.stringify(data, null, 2));
417
+ return;
418
+ }
419
+ printReview(data, buildReferenceDetails.buildNumber);
420
+ });
421
+ }
422
+ //#endregion
423
+ //#region src/commands/login.ts
424
+ const APP_BASE_URL = process.env["ARGOS_APP_BASE_URL"] ?? "https://app.argos-ci.com/";
425
+ const API_BASE_URL = process.env["ARGOS_API_BASE_URL"] ?? "https://api.argos-ci.com/v2/";
426
+ const LOGIN_CLI_SUCCESS_ROUTE = `/auth/cli/success`;
427
+ const LOGIN_TIMEOUT_MS = 300 * 1e3;
428
+ const CALLBACK_ERROR_HTML = `<!DOCTYPE html><html lang="en">
429
+ <head>
430
+ <meta charset="UTF-8">
431
+ <title>Argos CLI</title>
432
+ </head>
433
+ <body>
434
+ <p>Authorization failed. Please run <code>argos login</code> again.</p>
435
+ </body>
436
+ </html>`;
437
+ function color(text, code) {
438
+ if (!process.stderr.isTTY || process.env["NO_COLOR"]) return text;
439
+ return `\u001B[${code}m${text}\u001B[0m`;
440
+ }
441
+ const successColor = (text) => color(text, 32);
442
+ const warningColor = (text) => color(text, 33);
443
+ const errorColor = (text) => color(text, 31);
444
+ function startCallbackServer() {
445
+ return new Promise((resolve, reject) => {
446
+ let resolveCallback = () => {};
447
+ let rejectCallback = () => {};
448
+ let timeout;
449
+ const callbackPromise = new Promise((res, rej) => {
450
+ resolveCallback = res;
451
+ rejectCallback = rej;
452
+ });
453
+ const closeServer = () => {
454
+ if (timeout) clearTimeout(timeout);
455
+ server.close();
456
+ server.closeAllConnections();
457
+ };
458
+ const server = createServer((req, res) => {
459
+ const url = new URL(req.url ?? "/", "http://localhost");
460
+ if (url.pathname !== "/callback") {
461
+ res.writeHead(404, { Connection: "close" });
462
+ res.end();
463
+ return;
464
+ }
465
+ const callbackError = url.searchParams.get("error");
466
+ const callbackErrorDescription = url.searchParams.get("error_description");
467
+ const code = url.searchParams.get("code");
468
+ const state = url.searchParams.get("state");
469
+ if (callbackError) {
470
+ const message = callbackErrorDescription ?? callbackError;
471
+ res.writeHead(400, {
472
+ "Content-Type": "text/html; charset=utf-8",
473
+ Connection: "close"
474
+ });
475
+ res.end(CALLBACK_ERROR_HTML, () => {
476
+ closeServer();
477
+ rejectCallback(new Error(message));
478
+ });
479
+ return;
480
+ }
481
+ if (!code || !state) {
482
+ res.writeHead(400, {
483
+ "Content-Type": "text/html; charset=utf-8",
484
+ Connection: "close"
485
+ });
486
+ res.end(CALLBACK_ERROR_HTML, () => {
487
+ closeServer();
488
+ rejectCallback(/* @__PURE__ */ new Error("Missing code or state in callback"));
489
+ });
490
+ return;
491
+ }
492
+ res.writeHead(302, {
493
+ Location: new URL(LOGIN_CLI_SUCCESS_ROUTE, APP_BASE_URL).href,
494
+ Connection: "close"
495
+ });
496
+ res.end(() => {
497
+ closeServer();
498
+ resolveCallback({
499
+ code,
500
+ state
501
+ });
502
+ });
503
+ });
504
+ server.on("error", reject);
505
+ server.listen(0, "127.0.0.1", () => {
506
+ const { port } = server.address();
507
+ timeout = setTimeout(() => {
508
+ closeServer();
509
+ rejectCallback(/* @__PURE__ */ new Error("Authentication timed out"));
510
+ }, LOGIN_TIMEOUT_MS);
511
+ timeout.unref();
512
+ resolve({
513
+ port,
514
+ waitForCallback: () => callbackPromise
515
+ });
516
+ });
517
+ });
518
+ }
519
+ function loginCommand(program) {
520
+ program.command("login").description("Log in to Argos by opening your browser").action(async () => {
521
+ const state = randomBytes(16).toString("hex");
522
+ const codeVerifier = randomBytes(32).toString("base64url");
523
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
524
+ let port;
525
+ let waitForCallback;
526
+ try {
527
+ ({port, waitForCallback} = await startCallbackServer());
528
+ } catch (err) {
529
+ console.error(errorColor("Error: Failed to start local callback server."));
530
+ console.error(err);
531
+ process.exit(1);
532
+ }
533
+ const loginUrl = new URL("/auth/cli", APP_BASE_URL);
534
+ loginUrl.searchParams.set("port", port.toString());
535
+ loginUrl.searchParams.set("state", state);
536
+ loginUrl.searchParams.set("pkce", codeChallenge);
537
+ console.log("\nOpening browser for authentication…");
538
+ console.log(`If the browser doesn't open, visit:\n ${loginUrl.href}\n`);
539
+ if (process.env["ARGOS_CLI_DISABLE_BROWSER"] !== "1") await open(loginUrl.href).catch((err) => {
540
+ console.warn(warningColor(`Warning: Failed to open browser — ${err instanceof Error ? err.message : String(err)}`));
541
+ console.log("Hint: Open the URL above manually to continue authentication.");
542
+ });
543
+ let result;
544
+ try {
545
+ result = await waitForCallback();
546
+ } catch (err) {
547
+ console.error(errorColor(err instanceof Error ? `Error: ${err.message}. Please run \`argos login\` again.` : "Error: Authorization failed or was cancelled."));
548
+ process.exit(1);
549
+ }
550
+ if (result.state !== state) {
551
+ console.error(errorColor("Error: State mismatch. Try logging in again."));
552
+ process.exit(1);
553
+ }
554
+ const { data, error } = await createClient({ baseUrl: API_BASE_URL }).POST("/auth/cli/token", { body: {
555
+ code: result.code,
556
+ code_verifier: codeVerifier
557
+ } });
558
+ if (error || !data?.token) {
559
+ console.error(errorColor("Error: Authentication failed. Please run `argos login` again."));
560
+ process.exit(1);
561
+ }
562
+ await saveToken(data.token);
563
+ console.log(successColor("Logged in to Argos successfully."));
564
+ });
565
+ program.command("logout").description("Log out from Argos").action(async () => {
566
+ if (!await getStoredToken()) {
567
+ console.log("No token found. You are already logged out.");
568
+ return;
569
+ }
570
+ await removeToken();
571
+ console.log("Logged out from Argos.");
572
+ });
573
+ }
574
+ //#endregion
94
575
  //#region src/index.ts
95
576
  const rawPkg = await readFile(resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json"), "utf8");
96
577
  const pkg = JSON.parse(rawPkg);
@@ -98,6 +579,8 @@ program.name(pkg.name).description("Interact with and upload screenshots to Argo
98
579
  uploadCommand(program);
99
580
  skipCommand(program);
100
581
  finalizeCommand(program);
582
+ buildCommand(program);
583
+ loginCommand(program);
101
584
  if (!process.argv.slice(2).length) program.outputHelp();
102
585
  else program.parse(process.argv);
103
586
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@argos-ci/cli",
3
3
  "description": "Command-line (CLI) for visual testing with Argos.",
4
- "version": "4.1.4",
4
+ "version": "4.1.5-alpha.6+74828da",
5
5
  "bin": {
6
6
  "argos": "./bin/argos-cli.js"
7
7
  },
@@ -34,17 +34,22 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@argos-ci/core": "5.2.0",
37
+ "@argos-ci/api-client": "0.17.1-alpha.6+74828da",
38
+ "@argos-ci/core": "5.2.1-alpha.6+74828da",
38
39
  "commander": "^14.0.3",
40
+ "open": "^11.0.0",
39
41
  "ora": "^9.3.0",
40
42
  "update-notifier": "^7.3.1"
41
43
  },
44
+ "devDependencies": {
45
+ "vitest": "catalog:"
46
+ },
42
47
  "scripts": {
43
48
  "build": "tsdown",
44
- "e2e": "node e2e/upload.js && node e2e/skip.js",
49
+ "e2e": "vitest",
45
50
  "check-types": "tsc",
46
51
  "check-format": "prettier --check --ignore-unknown --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .",
47
52
  "lint": "eslint ."
48
53
  },
49
- "gitHead": "82add1422f689642095d43a9e3bb0d2a008728af"
54
+ "gitHead": "74828dafbe25ef00f58c7e87721ea0ec7211fee4"
50
55
  }