@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.
- package/dist/index.mjs +484 -1
- 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
|
+
"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/
|
|
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": "
|
|
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": "
|
|
54
|
+
"gitHead": "74828dafbe25ef00f58c7e87721ea0ec7211fee4"
|
|
50
55
|
}
|