@argos-ci/cli 4.1.5-alpha.2 → 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 +312 -21
  2. package/package.json +9 -5
package/dist/index.mjs CHANGED
@@ -1,10 +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, throwAPIError } from "@argos-ci/api-client";
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";
8
12
  //#region src/options.ts
9
13
  const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build").env("ARGOS_PARALLEL_NONCE");
10
14
  const tokenOption = new Option("--token <token>", "Repository token").env("ARGOS_TOKEN");
@@ -92,11 +96,77 @@ function skipCommand(program) {
92
96
  });
93
97
  }
94
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
95
161
  //#region src/commands/build.ts
96
- function getToken(options) {
97
- const token = options.token ?? process.env["ARGOS_TOKEN"];
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();
98
168
  if (!token) {
99
- console.error("Error: No Argos token found. Use --token or set ARGOS_TOKEN.");
169
+ console.error("Error: No Argos token found. Use --token, set ARGOS_TOKEN, or run `argos login`.");
100
170
  process.exit(1);
101
171
  }
102
172
  return token;
@@ -104,20 +174,26 @@ function getToken(options) {
104
174
  function getAPIBaseURL() {
105
175
  return process.env["ARGOS_API_BASE_URL"];
106
176
  }
107
- function createBuildsClient(options) {
108
- return createClient({
109
- authToken: getToken(options),
110
- baseUrl: getAPIBaseURL()
111
- });
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;
112
184
  }
113
185
  function isBuildPending(build) {
114
186
  return build.status === "pending" || build.status === "progress";
115
187
  }
116
- function parseBuildReferenceOrExit(buildReference) {
188
+ function parseBuildReferenceDetailsOrExit(buildReference) {
117
189
  const parsedBuildNumber = Number(buildReference);
118
- if (Number.isFinite(parsedBuildNumber) && Number.isInteger(parsedBuildNumber)) return parsedBuildNumber;
119
- const urlMatch = buildReference.match(/^https:\/\/app\.argos-ci\.(?:com|dev(?::\d+)?)\/.+\/builds\/(\d+)(?:\/?$|[?#])/);
120
- if (urlMatch) return Number(urlMatch[1]);
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
+ };
121
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}".`);
122
198
  process.exit(1);
123
199
  }
@@ -191,6 +267,20 @@ function printSnapshots(diffs, build) {
191
267
  ];
192
268
  console.log(lines.slice(0, -1).join("\n"));
193
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
+ }
194
284
  async function fetchAllDiffs(client, project, buildNumber, options) {
195
285
  const results = [];
196
286
  let page = 1;
@@ -205,7 +295,7 @@ async function fetchAllDiffs(client, project, buildNumber, options) {
205
295
  path: {
206
296
  owner: project.account.slug,
207
297
  project: project.name,
208
- buildNumber
298
+ buildNumber: String(buildNumber)
209
299
  },
210
300
  query
211
301
  } });
@@ -232,7 +322,7 @@ async function fetchBuildByNumber(client, project, buildNumber, errorLabel) {
232
322
  const { data, error, response } = await client.GET("/projects/{owner}/{project}/builds/{buildNumber}", { params: { path: {
233
323
  owner: project.account.slug,
234
324
  project: project.name,
235
- buildNumber
325
+ buildNumber: String(buildNumber)
236
326
  } } });
237
327
  if (error) {
238
328
  if (response.status === 404) {
@@ -251,8 +341,8 @@ function buildCommand(program) {
251
341
  const build = program.command("build").description("Manage Argos builds");
252
342
  const createJsonOption = () => new Option("--json", "Output machine-readable JSON instead of human-readable text");
253
343
  build.command("get").description("Fetch build metadata").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(createJsonOption()).action(async (buildReference, options) => {
254
- const buildNumber = parseBuildReferenceOrExit(buildReference);
255
- const client = createBuildsClient(options);
344
+ const { buildNumber } = parseBuildReferenceDetailsOrExit(buildReference);
345
+ const client = createProjectClient(options);
256
346
  const build = await fetchBuildByNumber(client, await fetchProject(client), buildNumber, buildReference);
257
347
  if (options.json) {
258
348
  console.log(JSON.stringify(build, null, 2));
@@ -261,8 +351,8 @@ function buildCommand(program) {
261
351
  printBuild(build);
262
352
  });
263
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) => {
264
- const buildNumber = parseBuildReferenceOrExit(buildReference);
265
- const client = createBuildsClient(options);
354
+ const { buildNumber } = parseBuildReferenceDetailsOrExit(buildReference);
355
+ const client = createProjectClient(options);
266
356
  const project = await fetchProject(client);
267
357
  const build = await fetchBuildByNumber(client, project, buildNumber, buildReference);
268
358
  if (isBuildPending(build)) {
@@ -273,13 +363,213 @@ function buildCommand(program) {
273
363
  console.log(`Build #${build.number} is still processing (${build.status}). Try again in a moment.`);
274
364
  return;
275
365
  }
276
- const diffs = await fetchAllDiffs(client, project, build.number, { needsReview: Boolean(options.needsReview) });
366
+ const diffs = await fetchAllDiffs(client, project, buildNumber, { needsReview: Boolean(options.needsReview) });
277
367
  if (options.json) {
278
368
  console.log(JSON.stringify(diffs, null, 2));
279
369
  return;
280
370
  }
281
371
  printSnapshots(diffs, build);
282
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
+ });
283
573
  }
284
574
  //#endregion
285
575
  //#region src/index.ts
@@ -290,6 +580,7 @@ uploadCommand(program);
290
580
  skipCommand(program);
291
581
  finalizeCommand(program);
292
582
  buildCommand(program);
583
+ loginCommand(program);
293
584
  if (!process.argv.slice(2).length) program.outputHelp();
294
585
  else program.parse(process.argv);
295
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.5-alpha.2+3431e7d",
4
+ "version": "4.1.5-alpha.6+74828da",
5
5
  "bin": {
6
6
  "argos": "./bin/argos-cli.js"
7
7
  },
@@ -34,18 +34,22 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@argos-ci/api-client": "0.17.1-alpha.2+3431e7d",
38
- "@argos-ci/core": "5.2.1-alpha.2+3431e7d",
37
+ "@argos-ci/api-client": "0.17.1-alpha.6+74828da",
38
+ "@argos-ci/core": "5.2.1-alpha.6+74828da",
39
39
  "commander": "^14.0.3",
40
+ "open": "^11.0.0",
40
41
  "ora": "^9.3.0",
41
42
  "update-notifier": "^7.3.1"
42
43
  },
44
+ "devDependencies": {
45
+ "vitest": "catalog:"
46
+ },
43
47
  "scripts": {
44
48
  "build": "tsdown",
45
- "e2e": "node e2e/upload.js && node e2e/skip.js && node e2e/build.js",
49
+ "e2e": "vitest",
46
50
  "check-types": "tsc",
47
51
  "check-format": "prettier --check --ignore-unknown --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .",
48
52
  "lint": "eslint ."
49
53
  },
50
- "gitHead": "3431e7d43acf6b1f043b55e53dec32318cabcd4c"
54
+ "gitHead": "74828dafbe25ef00f58c7e87721ea0ec7211fee4"
51
55
  }