@snapvisor/cli 7.0.0 → 7.1.0
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/README.md +18 -0
- package/dist/index.mjs +621 -420
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -33,6 +33,24 @@ Upload a directory of screenshots to Argos. Authenticate with your project token
|
|
|
33
33
|
ARGOS_TOKEN="<your-argos-token>" npx @snapvisor/cli upload ./screenshots
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
### Failing CI when a build needs review
|
|
37
|
+
|
|
38
|
+
By default `upload` exits right after the build is created, so a build with unreviewed changes does not fail the job (the commit status does). Pass `--wait` to make the command wait for the build to finish and report the result through its exit code:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
ARGOS_TOKEN="<your-argos-token>" npx @snapvisor/cli upload ./screenshots --wait --wait-timeout 300
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Build state | Exit code |
|
|
45
|
+
| ---------------------------------------------------------- | --------- |
|
|
46
|
+
| `accepted` / `no-changes` | `0` |
|
|
47
|
+
| `changes-detected` on a reference build (auto-approved) | `0` |
|
|
48
|
+
| `changes-detected` awaiting review | `1` |
|
|
49
|
+
| `rejected`, `expired`, `aborted`, `error` | `1` |
|
|
50
|
+
| Still running after `--wait-timeout` seconds (default 600) | `1` |
|
|
51
|
+
|
|
52
|
+
The exit code always agrees with the build's commit status: success is exit 0, pending review or failure is exit 1. A `429` from the API is retried until the timeout. The build URL and final state are printed either way. `--wait` polls the Snapvisor API, so it needs a token it can read the build with (`--token` or `ARGOS_TOKEN`): GitHub Actions OIDC and tokenless uploads, and `argos login` sessions, are not supported. It cannot be combined with `--parallel`, because a parallel build only finishes once it is finalized.
|
|
53
|
+
|
|
36
54
|
The CLI also exposes commands to `deploy` a static build, inspect and `review` builds, post a `comment`, and more. List them all with:
|
|
37
55
|
|
|
38
56
|
```sh
|
package/dist/index.mjs
CHANGED
|
@@ -1,125 +1,14 @@
|
|
|
1
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
|
-
import { Option, program } from "commander";
|
|
5
|
-
import { deploy, finalize, skip, upload } from "@snapvisor/core";
|
|
4
|
+
import { InvalidArgumentError, Option, program } from "commander";
|
|
5
|
+
import { deploy, finalize, readConfig, skip, upload } from "@snapvisor/core";
|
|
6
6
|
import ora from "ora";
|
|
7
7
|
import { APIError, createClient, throwAPIError } from "@snapvisor/api-client";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
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 Snapvisor 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 Snapvisor 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
12
|
//#region src/lib/api.ts
|
|
124
13
|
/** Optional override for the API base URL (used in tests and dev). */
|
|
125
14
|
function getApiBaseUrl() {
|
|
@@ -142,314 +31,106 @@ function unwrap(result) {
|
|
|
142
31
|
return result.data;
|
|
143
32
|
}
|
|
144
33
|
//#endregion
|
|
145
|
-
//#region src/lib/
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
34
|
+
//#region src/lib/cli-error.ts
|
|
35
|
+
/**
|
|
36
|
+
* Error whose message is meant to be shown to the user verbatim (prefixed with
|
|
37
|
+
* `Error: `) before the CLI exits with a non-zero code. Throw it — via
|
|
38
|
+
* {@link fail} — instead of calling `process.exit` directly so the failure can
|
|
39
|
+
* be unit-tested and handled in a single place ({@link handleCliError}).
|
|
40
|
+
*/
|
|
41
|
+
var CliError = class extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "CliError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Abort the current command with a user-facing message.
|
|
49
|
+
*/
|
|
50
|
+
function fail(message) {
|
|
51
|
+
throw new CliError(message);
|
|
150
52
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/lib/oauth.ts
|
|
55
|
+
/**
|
|
56
|
+
* OAuth 2.1 client configuration and helpers for the `argos login` flow
|
|
57
|
+
* (Authorization Code + PKCE with a loopback redirect, RFC 8252).
|
|
58
|
+
*
|
|
59
|
+
* The authorization server is the Snapvisor app origin; endpoints and token
|
|
60
|
+
* validation are implemented in the main Snapvisor repo (`apps/backend/src/oauth`).
|
|
61
|
+
*/
|
|
62
|
+
/** First-party public client id, seeded in the Snapvisor database. */
|
|
63
|
+
const OAUTH_CLIENT_ID = "argos-cli";
|
|
64
|
+
/**
|
|
65
|
+
* Scopes requested by `argos login`, covering the CLI's user actions (identity,
|
|
66
|
+
* projects, reviews, comments). Build upload keeps using a project token.
|
|
67
|
+
*/
|
|
68
|
+
const OAUTH_SCOPES = [
|
|
69
|
+
"profile",
|
|
70
|
+
"projects:read",
|
|
71
|
+
"projects:write",
|
|
72
|
+
"reviews:write",
|
|
73
|
+
"comments:read",
|
|
74
|
+
"comments:write"
|
|
75
|
+
];
|
|
76
|
+
/** Refresh a bit early so a token never expires mid-request. */
|
|
77
|
+
const EXPIRY_SKEW_MS = 60 * 1e3;
|
|
78
|
+
function getAppBaseUrl() {
|
|
79
|
+
return process.env["ARGOS_APP_BASE_URL"] ?? "https://app.snapvisor.io/";
|
|
154
80
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
81
|
+
/**
|
|
82
|
+
* A token endpoint request that reached the server and was rejected by it
|
|
83
|
+
* (non-2xx / OAuth error), as opposed to a transient network failure. Lets
|
|
84
|
+
* callers tell "your session is gone, log in again" from "the network is down".
|
|
85
|
+
*/
|
|
86
|
+
var OAuthTokenError = class extends Error {
|
|
87
|
+
code;
|
|
88
|
+
constructor(message, code) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.code = code;
|
|
91
|
+
this.name = "OAuthTokenError";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function toTokenSet(data) {
|
|
95
|
+
return {
|
|
96
|
+
accessToken: data.access_token,
|
|
97
|
+
refreshToken: data.refresh_token,
|
|
98
|
+
expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SKEW_MS,
|
|
99
|
+
scope: data.scope ?? ""
|
|
100
|
+
};
|
|
163
101
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
].join("\n");
|
|
102
|
+
/** Generate a PKCE `code_verifier` and its S256 `code_challenge`. */
|
|
103
|
+
function generatePkce() {
|
|
104
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
105
|
+
return {
|
|
106
|
+
codeVerifier,
|
|
107
|
+
codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url")
|
|
108
|
+
};
|
|
172
109
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
110
|
+
/** Build the `/oauth/authorize` URL to open in the browser. */
|
|
111
|
+
function buildAuthorizeUrl(params) {
|
|
112
|
+
const url = new URL("/oauth/authorize", getAppBaseUrl());
|
|
113
|
+
url.searchParams.set("response_type", "code");
|
|
114
|
+
url.searchParams.set("client_id", OAUTH_CLIENT_ID);
|
|
115
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
116
|
+
url.searchParams.set("scope", OAUTH_SCOPES.join(" "));
|
|
117
|
+
url.searchParams.set("state", params.state);
|
|
118
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
119
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
120
|
+
return url.href;
|
|
182
121
|
}
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 Snapvisor app origin; endpoints and token
|
|
379
|
-
* validation are implemented in the main Snapvisor repo (`apps/backend/src/oauth`).
|
|
380
|
-
*/
|
|
381
|
-
/** First-party public client id, seeded in the Snapvisor 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;
|
|
122
|
+
async function postToken(body) {
|
|
123
|
+
const url = new URL("/oauth/token", getAppBaseUrl());
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
127
|
+
body: new URLSearchParams(body).toString()
|
|
128
|
+
});
|
|
129
|
+
const data = await response.json().catch(() => null);
|
|
130
|
+
if (!response.ok || !data?.access_token) throw new OAuthTokenError(data?.error_description ?? data?.error ?? `HTTP ${response.status}`, data?.error);
|
|
131
|
+
if (typeof data.refresh_token !== "string" || data.refresh_token === "") throw new Error("Token endpoint response is missing a refresh token.");
|
|
132
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) throw new Error("Token endpoint response is missing a valid expiry.");
|
|
133
|
+
return data;
|
|
453
134
|
}
|
|
454
135
|
/** Exchange an authorization code for a token set (`grant_type=authorization_code`). */
|
|
455
136
|
async function exchangeAuthorizationCode(params) {
|
|
@@ -522,7 +203,7 @@ async function clearConfig() {
|
|
|
522
203
|
await unlink(configPath);
|
|
523
204
|
} catch {}
|
|
524
205
|
}
|
|
525
|
-
async function readConfig() {
|
|
206
|
+
async function readConfig$1() {
|
|
526
207
|
const { configPath } = getConfigPaths();
|
|
527
208
|
try {
|
|
528
209
|
const config = parseConfig(await readFile(configPath, "utf8"));
|
|
@@ -554,7 +235,7 @@ async function saveOAuthTokens(tokens) {
|
|
|
554
235
|
* falls back to a legacy personal access token when present.
|
|
555
236
|
*/
|
|
556
237
|
async function getAccessToken() {
|
|
557
|
-
const config = await readConfig();
|
|
238
|
+
const config = await readConfig$1();
|
|
558
239
|
if (!config) return;
|
|
559
240
|
if (config.oauth) {
|
|
560
241
|
if (Date.now() < config.oauth.expiresAt) return config.oauth.accessToken;
|
|
@@ -573,7 +254,7 @@ async function getAccessToken() {
|
|
|
573
254
|
}
|
|
574
255
|
/** Read the stored credentials without refreshing (used by `logout`). */
|
|
575
256
|
async function getStoredCredentials() {
|
|
576
|
-
const config = await readConfig();
|
|
257
|
+
const config = await readConfig$1();
|
|
577
258
|
return {
|
|
578
259
|
legacyToken: config?.token,
|
|
579
260
|
oauth: config?.oauth
|
|
@@ -585,7 +266,7 @@ async function clearStoredCredentials() {
|
|
|
585
266
|
}
|
|
586
267
|
//#endregion
|
|
587
268
|
//#region src/lib/build-reference.ts
|
|
588
|
-
const BUILD_URL_REGEXP = /^https:\/\/app\.argos-ci\.(?:com|dev(?::\d+)?)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
|
|
269
|
+
const BUILD_URL_REGEXP = /^https:\/\/app\.(?:argos-ci\.(?:com|dev(?::\d+)?)|snapvisor\.io)\/(?<owner>[^/?#]+)\/(?<project>[^/?#]+)\/builds\/(?<buildNumber>\d+)(?:[/?#]|$)/;
|
|
589
270
|
/**
|
|
590
271
|
* Parse a build reference, accepting either a bare build number (`"1234"`) or a
|
|
591
272
|
* full Snapvisor build URL. Returns `null` when the input is neither.
|
|
@@ -733,6 +414,526 @@ async function runBuildAction(opts) {
|
|
|
733
414
|
}
|
|
734
415
|
}
|
|
735
416
|
//#endregion
|
|
417
|
+
//#region src/lib/wait-for-build.ts
|
|
418
|
+
/** Commander argument parser for `--wait-timeout <seconds>`. */
|
|
419
|
+
function parseWaitTimeout(value) {
|
|
420
|
+
const seconds = Number(value);
|
|
421
|
+
if (!Number.isInteger(seconds) || seconds <= 0) throw new InvalidArgumentError("Must be a positive whole number of seconds.");
|
|
422
|
+
return seconds;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Reject option combinations `--wait` cannot honour, before anything is uploaded.
|
|
426
|
+
* Returns the token used to poll the build, or `null` when not waiting.
|
|
427
|
+
*/
|
|
428
|
+
async function prepareWait(options) {
|
|
429
|
+
if (!options.wait) {
|
|
430
|
+
if (options.waitTimeout !== void 0) fail("--wait-timeout requires --wait.");
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (options.parallel || (await readConfig()).parallel) fail("--wait cannot be combined with --parallel (or ARGOS_PARALLEL): a parallel build only finishes once every shard is uploaded and the build is finalized, so waiting on one shard would time out.");
|
|
434
|
+
const token = options.token || process.env["ARGOS_TOKEN"];
|
|
435
|
+
if (!token) fail("--wait needs a project token to read the build back: pass --token or set ARGOS_TOKEN (GitHub Actions OIDC / tokenless uploads and `argos login` are not supported with --wait).");
|
|
436
|
+
return token;
|
|
437
|
+
}
|
|
438
|
+
const POLL_INTERVAL_MS = 5e3;
|
|
439
|
+
/** Statuses a build can still leave: it is waiting for, or being processed by, the diff engine. */
|
|
440
|
+
const IN_FLIGHT_STATUSES = ["pending", "progress"];
|
|
441
|
+
function isFinalStatus(status) {
|
|
442
|
+
return !IN_FLIGHT_STATUSES.includes(status);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Map a build that reached a final status to a pass/fail outcome.
|
|
446
|
+
*
|
|
447
|
+
* Passes: `accepted`, `no-changes`, and a `changes-detected` build whose commit
|
|
448
|
+
* status is already `success` (a reference-branch build is auto-approved but
|
|
449
|
+
* keeps the `changes-detected` API status). Everything else fails: a
|
|
450
|
+
* `changes-detected` build with a pending status is waiting for a human review,
|
|
451
|
+
* which is exactly what `--wait` exists to surface to CI, so the exit code
|
|
452
|
+
* always agrees with the commit status.
|
|
453
|
+
*/
|
|
454
|
+
function evaluateFinalBuild(build) {
|
|
455
|
+
if (!isFinalStatus(build.status)) throw new Error(`Invariant: build #${build.number} is still "${build.status}", not final`);
|
|
456
|
+
const status = build.status;
|
|
457
|
+
const label = `Build #${build.number}`;
|
|
458
|
+
switch (status) {
|
|
459
|
+
case "accepted": return {
|
|
460
|
+
ok: true,
|
|
461
|
+
summary: `${label} accepted`
|
|
462
|
+
};
|
|
463
|
+
case "no-changes": return {
|
|
464
|
+
ok: true,
|
|
465
|
+
summary: `${label} has no changes`
|
|
466
|
+
};
|
|
467
|
+
case "changes-detected":
|
|
468
|
+
if (build.notification?.github.state === "success") return {
|
|
469
|
+
ok: true,
|
|
470
|
+
summary: `${label} has changes but its commit status is success (auto-approved)`
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
summary: `${label} needs review (changes-detected)`
|
|
475
|
+
};
|
|
476
|
+
case "rejected": return {
|
|
477
|
+
ok: false,
|
|
478
|
+
summary: `${label} was rejected`
|
|
479
|
+
};
|
|
480
|
+
case "expired": return {
|
|
481
|
+
ok: false,
|
|
482
|
+
summary: `${label} expired`
|
|
483
|
+
};
|
|
484
|
+
case "error": return {
|
|
485
|
+
ok: false,
|
|
486
|
+
summary: `${label} failed with an error`
|
|
487
|
+
};
|
|
488
|
+
case "aborted": return {
|
|
489
|
+
ok: false,
|
|
490
|
+
summary: `${label} was aborted`
|
|
491
|
+
};
|
|
492
|
+
default: throw new Error(`Unhandled build status: ${String(status)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Extract `owner`, `project` and the build number from the build URL returned
|
|
497
|
+
* by the API (`<app origin>/<owner>/<project>/builds/<number>`). Only the last
|
|
498
|
+
* four path segments are read, so self-hosted origins work too.
|
|
499
|
+
*/
|
|
500
|
+
function parseBuildUrl(url) {
|
|
501
|
+
const [owner, project, builds, buildNumber] = new URL(url).pathname.split("/").filter(Boolean).slice(-4).map((segment) => {
|
|
502
|
+
try {
|
|
503
|
+
return decodeURIComponent(segment);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
fail(`Cannot wait for build: malformed build URL "${url}" (${error instanceof Error ? error.message : String(error)}).`);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
if (!owner || !project || builds !== "builds" || !buildNumber) fail(`Cannot wait for build: unexpected build URL "${url}" (expected .../<owner>/<project>/builds/<number>).`);
|
|
509
|
+
return {
|
|
510
|
+
owner,
|
|
511
|
+
project,
|
|
512
|
+
buildNumber
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const realClock = {
|
|
516
|
+
now: () => Date.now(),
|
|
517
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
518
|
+
};
|
|
519
|
+
const MAX_RATE_LIMIT_BACKOFF_MS = 6e4;
|
|
520
|
+
/**
|
|
521
|
+
* How long to wait before retrying a 429: the `Retry-After` header (seconds or
|
|
522
|
+
* HTTP date) when present and valid, else exponential backoff from the poll interval.
|
|
523
|
+
*/
|
|
524
|
+
function rateLimitDelayMs(response, attempt, pollIntervalMs, nowMs) {
|
|
525
|
+
const header = response.headers.get("retry-after");
|
|
526
|
+
if (header) {
|
|
527
|
+
const seconds = Number(header);
|
|
528
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
529
|
+
const date = Date.parse(header);
|
|
530
|
+
if (!Number.isNaN(date)) return Math.max(0, date - nowMs);
|
|
531
|
+
}
|
|
532
|
+
return Math.min(MAX_RATE_LIMIT_BACKOFF_MS, pollIntervalMs * 2 ** attempt);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Poll a build until it reaches a final status and return its pass/fail
|
|
536
|
+
* outcome. Throws a {@link CliError} when `timeoutSeconds` elapses first, and
|
|
537
|
+
* lets API failures (401, 404, network errors, ...) propagate — a build we
|
|
538
|
+
* cannot read must not look green. A 429 is retried (honouring `Retry-After`)
|
|
539
|
+
* for as long as the deadline allows. Each request is aborted at the deadline,
|
|
540
|
+
* so the wait never overshoots `timeoutSeconds` by a whole request.
|
|
541
|
+
*/
|
|
542
|
+
async function waitForBuild(opts) {
|
|
543
|
+
const { client, buildUrl, timeoutSeconds, pollIntervalMs = POLL_INTERVAL_MS, clock = realClock, onPoll } = opts;
|
|
544
|
+
const { owner, project, buildNumber } = parseBuildUrl(buildUrl);
|
|
545
|
+
const deadline = clock.now() + timeoutSeconds * 1e3;
|
|
546
|
+
let lastStatus = "not yet read";
|
|
547
|
+
let rateLimitAttempt = 0;
|
|
548
|
+
const getBuild = (signal) => client.GET("/projects/{owner}/{project}/builds/{buildNumber}", {
|
|
549
|
+
params: { path: {
|
|
550
|
+
owner,
|
|
551
|
+
project,
|
|
552
|
+
buildNumber
|
|
553
|
+
} },
|
|
554
|
+
signal
|
|
555
|
+
});
|
|
556
|
+
const timeout = () => fail(`Timed out after ${timeoutSeconds}s waiting for build #${buildNumber} to finish (last status: ${lastStatus}). ${buildUrl}`);
|
|
557
|
+
for (;;) {
|
|
558
|
+
const remainingMs = deadline - clock.now();
|
|
559
|
+
if (remainingMs <= 0) timeout();
|
|
560
|
+
const signal = AbortSignal.timeout(remainingMs);
|
|
561
|
+
let result;
|
|
562
|
+
try {
|
|
563
|
+
result = await getBuild(signal);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (signal.aborted) timeout();
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
if (result.response.status === 429) {
|
|
569
|
+
const delayMs = rateLimitDelayMs(result.response, rateLimitAttempt++, pollIntervalMs, clock.now());
|
|
570
|
+
lastStatus = "rate limited by the API (HTTP 429)";
|
|
571
|
+
if (delayMs >= deadline - clock.now()) timeout();
|
|
572
|
+
await clock.sleep(delayMs);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
rateLimitAttempt = 0;
|
|
576
|
+
const build = unwrap(result);
|
|
577
|
+
lastStatus = build.status;
|
|
578
|
+
if (isFinalStatus(build.status)) return evaluateFinalBuild(build);
|
|
579
|
+
onPoll?.(build);
|
|
580
|
+
const untilDeadlineMs = deadline - clock.now();
|
|
581
|
+
if (untilDeadlineMs <= 0) timeout();
|
|
582
|
+
await clock.sleep(Math.min(pollIntervalMs, untilDeadlineMs));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/options.ts
|
|
587
|
+
const parallelNonceOption = new Option("--parallel-nonce <string>", "A unique ID for this parallel build");
|
|
588
|
+
const tokenOption = new Option("--token <token>", "Repository token");
|
|
589
|
+
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");
|
|
590
|
+
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");
|
|
591
|
+
const accountOption = new Option("--account <slug>", "Slug of the account (personal or team) that will own the project");
|
|
592
|
+
const jsonOption = new Option("--json", "Output machine-readable JSON instead of human-readable text");
|
|
593
|
+
const projectPathOption = new Option("--project <owner/project>", "Project path in owner/project format. Required for build-number references on review and comment commands");
|
|
594
|
+
const changeProjectPathOption = new Option("--project <owner/project>", "Project the change belongs to, in owner/project format. Also ARGOS_PROJECT");
|
|
595
|
+
const METRICS_PERIOD_BY_CHOICE = {
|
|
596
|
+
"24h": "LAST_24_HOURS",
|
|
597
|
+
"3d": "LAST_3_DAYS",
|
|
598
|
+
"7d": "LAST_7_DAYS",
|
|
599
|
+
"30d": "LAST_30_DAYS",
|
|
600
|
+
"90d": "LAST_90_DAYS"
|
|
601
|
+
};
|
|
602
|
+
const metricsPeriodOption = new Option("--metrics-period <period>", "Window over which test flakiness metrics are computed").choices(Object.keys(METRICS_PERIOD_BY_CHOICE)).default("7d");
|
|
603
|
+
/** Map a `--metrics-period` choice to the value the API expects. */
|
|
604
|
+
function toMetricsPeriod(choice) {
|
|
605
|
+
return METRICS_PERIOD_BY_CHOICE[choice] ?? "LAST_7_DAYS";
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/commands/upload.ts
|
|
609
|
+
function uploadCommand(program) {
|
|
610
|
+
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 Snapvisor 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.")).addOption(new Option("--wait", "Wait for the build to finish, then exit 0 if it is accepted or has no changes, and exit 1 if it needs review, is rejected, expired, aborted or errored, or if the wait times out.\nRequires a token (--token, ARGOS_TOKEN) and cannot be combined with --parallel.")).addOption(new Option("--wait-timeout <seconds>", `Maximum time to wait for the build when --wait is set. Defaults to 600.`).argParser(parseWaitTimeout)).action(async (directory, options) => {
|
|
611
|
+
let waitToken;
|
|
612
|
+
try {
|
|
613
|
+
waitToken = await prepareWait({
|
|
614
|
+
wait: options.wait,
|
|
615
|
+
waitTimeout: options.waitTimeout,
|
|
616
|
+
parallel: options.parallel,
|
|
617
|
+
token: options.token
|
|
618
|
+
});
|
|
619
|
+
} catch (error) {
|
|
620
|
+
handleCliError(error);
|
|
621
|
+
}
|
|
622
|
+
const spinner = ora("Uploading screenshots").start();
|
|
623
|
+
let buildUrl;
|
|
624
|
+
try {
|
|
625
|
+
const parallel = (() => {
|
|
626
|
+
if (!options.parallel) return;
|
|
627
|
+
if (!options.parallelNonce) {
|
|
628
|
+
spinner.fail("--parallel-nonce is required if --parallel is set");
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
if (!options.parallelTotal) {
|
|
632
|
+
spinner.fail("--parallel-total is required if --parallel is set");
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
nonce: options.parallelNonce,
|
|
637
|
+
total: options.parallelTotal,
|
|
638
|
+
index: options.parallelIndex
|
|
639
|
+
};
|
|
640
|
+
})();
|
|
641
|
+
buildUrl = (await upload({
|
|
642
|
+
token: options.token,
|
|
643
|
+
project: options.project,
|
|
644
|
+
root: directory,
|
|
645
|
+
buildName: options.buildName,
|
|
646
|
+
files: options.files,
|
|
647
|
+
ignore: options.ignore,
|
|
648
|
+
parallel,
|
|
649
|
+
referenceBranch: options.referenceBranch,
|
|
650
|
+
referenceCommit: options.referenceCommit,
|
|
651
|
+
mode: options.mode,
|
|
652
|
+
threshold: options.threshold,
|
|
653
|
+
subset: options.subset
|
|
654
|
+
})).build.url;
|
|
655
|
+
spinner.succeed(`Build created: ${buildUrl}`);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (error instanceof Error) {
|
|
658
|
+
spinner.fail(`Build failed: ${error.message}`);
|
|
659
|
+
console.error(error.stack);
|
|
660
|
+
}
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
if (waitToken === null) return;
|
|
664
|
+
const waitSpinner = ora("Waiting for the build to finish").start();
|
|
665
|
+
try {
|
|
666
|
+
const outcome = await waitForBuild({
|
|
667
|
+
client: createApiClient(waitToken),
|
|
668
|
+
buildUrl,
|
|
669
|
+
timeoutSeconds: options.waitTimeout ?? 600,
|
|
670
|
+
onPoll: (build) => {
|
|
671
|
+
waitSpinner.text = `Waiting for the build to finish (status: ${build.status})`;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
if (outcome.ok) waitSpinner.succeed(`${outcome.summary}: ${buildUrl}`);
|
|
675
|
+
else {
|
|
676
|
+
waitSpinner.fail(`${outcome.summary}: ${buildUrl}`);
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
} catch (error) {
|
|
680
|
+
waitSpinner.fail("Waiting for the build failed");
|
|
681
|
+
handleCliError(error);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/commands/finalize.ts
|
|
687
|
+
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 Snapvisor is a required status check: the skipped build reports a success status even though nothing was uploaded.");
|
|
688
|
+
function finalizeCommand(program) {
|
|
689
|
+
program.command("finalize").description("Finalize pending parallel builds").addOption(parallelNonceOption).addOption(tokenOption).addOption(skipIfEmptyOption).addOption(buildNameOption).action(async (options) => {
|
|
690
|
+
const spinner = ora("Finalizing builds").start();
|
|
691
|
+
try {
|
|
692
|
+
const result = await finalize({
|
|
693
|
+
token: options.token,
|
|
694
|
+
parallel: options.parallelNonce ? { nonce: options.parallelNonce } : void 0,
|
|
695
|
+
skipIfEmpty: options.skipIfEmpty,
|
|
696
|
+
buildName: options.buildName
|
|
697
|
+
});
|
|
698
|
+
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(", ")}`);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof Error) {
|
|
701
|
+
spinner.fail(`Failed to finalize: ${error.message}`);
|
|
702
|
+
console.error(error.stack);
|
|
703
|
+
}
|
|
704
|
+
process.exit(1);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/commands/skip.ts
|
|
710
|
+
function skipCommand(program) {
|
|
711
|
+
program.command("skip").description("Mark a build as skipped").addOption(tokenOption).addOption(buildNameOption).action(async (options) => {
|
|
712
|
+
const spinner = ora("Creating skipped build").start();
|
|
713
|
+
try {
|
|
714
|
+
const result = await skip({
|
|
715
|
+
token: options.token,
|
|
716
|
+
buildName: options.buildName
|
|
717
|
+
});
|
|
718
|
+
spinner.succeed(`Build created: ${result.build.url}`);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error instanceof Error) {
|
|
721
|
+
spinner.fail(`Build failed: ${error.message}`);
|
|
722
|
+
console.error(error.stack);
|
|
723
|
+
}
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/lib/format.ts
|
|
730
|
+
/** Render a scalar, using `-` for empty values. */
|
|
731
|
+
function formatValue(value) {
|
|
732
|
+
if (value === null || value === void 0 || value === "") return "-";
|
|
733
|
+
return String(value);
|
|
734
|
+
}
|
|
735
|
+
function formatUser(user) {
|
|
736
|
+
if (!user) return "-";
|
|
737
|
+
return user.name ? `${user.name} (@${user.slug})` : `@${user.slug}`;
|
|
738
|
+
}
|
|
739
|
+
function formatMe(me) {
|
|
740
|
+
const lines = [
|
|
741
|
+
`Logged in to Snapvisor as ${formatValue(me.user.name)}.`,
|
|
742
|
+
`Name: ${formatValue(me.user.name)}`,
|
|
743
|
+
`Email: ${formatValue(me.user.email)}`
|
|
744
|
+
];
|
|
745
|
+
if (me.accounts.length > 0) lines.push(`Accounts: ${me.accounts.map((a) => `@${a.slug}`).join(", ")}`);
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
function formatProject(project) {
|
|
749
|
+
return [
|
|
750
|
+
`Created project ${project.account.slug}/${project.name}.`,
|
|
751
|
+
`ID: ${project.id}`,
|
|
752
|
+
`Name: ${project.name}`,
|
|
753
|
+
`Account: ${project.account.slug}`,
|
|
754
|
+
`Default base branch: ${formatValue(project.defaultBaseBranch)}`
|
|
755
|
+
].join("\n");
|
|
756
|
+
}
|
|
757
|
+
function formatStats(stats) {
|
|
758
|
+
if (!stats) return "-";
|
|
759
|
+
return [
|
|
760
|
+
`total ${stats.total}`,
|
|
761
|
+
`changed ${stats.changed}`,
|
|
762
|
+
`added ${stats.added}`,
|
|
763
|
+
`removed ${stats.removed}`,
|
|
764
|
+
`unchanged ${stats.unchanged}`
|
|
765
|
+
].join(", ");
|
|
766
|
+
}
|
|
767
|
+
const SNAPSHOT_STATUS_ORDER = [
|
|
768
|
+
"changed",
|
|
769
|
+
"added",
|
|
770
|
+
"removed",
|
|
771
|
+
"unchanged",
|
|
772
|
+
"ignored",
|
|
773
|
+
"pending",
|
|
774
|
+
"failure",
|
|
775
|
+
"retryFailure"
|
|
776
|
+
];
|
|
777
|
+
function formatSnapshotSummary(diffs) {
|
|
778
|
+
const counts = /* @__PURE__ */ new Map();
|
|
779
|
+
for (const diff of diffs) counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1);
|
|
780
|
+
return SNAPSHOT_STATUS_ORDER.map((status) => {
|
|
781
|
+
const count = counts.get(status);
|
|
782
|
+
return count ? `${status} ${count}` : null;
|
|
783
|
+
}).filter((part) => Boolean(part)).join(", ");
|
|
784
|
+
}
|
|
785
|
+
function formatBuild(build) {
|
|
786
|
+
return [
|
|
787
|
+
`Build #${build.number}`,
|
|
788
|
+
`Status: ${build.status}`,
|
|
789
|
+
`Snapshots: ${formatStats(build.stats)}`,
|
|
790
|
+
`Conclusion: ${formatValue(build.conclusion)}`,
|
|
791
|
+
`Branch: ${formatValue(build.head?.branch)}`,
|
|
792
|
+
`Commit: ${formatValue(build.head?.sha)}`,
|
|
793
|
+
`Base branch: ${formatValue(build.base?.branch)}`,
|
|
794
|
+
`Base commit: ${formatValue(build.base?.sha)}`,
|
|
795
|
+
`URL: ${build.url}`
|
|
796
|
+
].join("\n");
|
|
797
|
+
}
|
|
798
|
+
/** Render a 0–1 metric to two decimals, or `-` when absent. */
|
|
799
|
+
function formatRatio(value) {
|
|
800
|
+
return value === null || value === void 0 ? "-" : value.toFixed(2);
|
|
801
|
+
}
|
|
802
|
+
/** Compact one-line summary of a test's flakiness metrics. */
|
|
803
|
+
function formatFlakiness(metrics) {
|
|
804
|
+
return `${formatRatio(metrics.flakiness)} (stability ${formatRatio(metrics.stability)}, consistency ${formatRatio(metrics.consistency)})`;
|
|
805
|
+
}
|
|
806
|
+
/** Human-readable lines for a single snapshot diff. */
|
|
807
|
+
function formatSnapshotDiff(diff, build) {
|
|
808
|
+
const lines = [
|
|
809
|
+
`${diff.name} [${diff.status}]`,
|
|
810
|
+
` Review: ${build.url}/${diff.id}`,
|
|
811
|
+
` Mask: ${formatValue(diff.url)}`,
|
|
812
|
+
` Base file: ${formatValue(diff.base?.url)}`,
|
|
813
|
+
` Head file: ${formatValue(diff.head?.url)}`,
|
|
814
|
+
` Score: ${formatValue(diff.score)}`,
|
|
815
|
+
` Group: ${formatValue(diff.group)}`
|
|
816
|
+
];
|
|
817
|
+
if (diff.test) lines.push(` Flakiness: ${formatFlakiness(diff.test.metrics)}`);
|
|
818
|
+
if (diff.change) {
|
|
819
|
+
const ignored = diff.change.ignored ? " [ignored]" : "";
|
|
820
|
+
lines.push(` Change: ${diff.change.id}${ignored} · ${diff.change.occurrences} occurrences`);
|
|
821
|
+
}
|
|
822
|
+
return lines;
|
|
823
|
+
}
|
|
824
|
+
function formatSnapshots(diffs, build) {
|
|
825
|
+
if (diffs.length === 0) return "No snapshots found.";
|
|
826
|
+
return [
|
|
827
|
+
`Snapshots for build #${build.number}`,
|
|
828
|
+
`Count: ${diffs.length}`,
|
|
829
|
+
`Summary: ${formatSnapshotSummary(diffs)}`,
|
|
830
|
+
"",
|
|
831
|
+
...diffs.flatMap((diff) => [...formatSnapshotDiff(diff, build), ""])
|
|
832
|
+
].slice(0, -1).join("\n");
|
|
833
|
+
}
|
|
834
|
+
function formatChange(change) {
|
|
835
|
+
return [
|
|
836
|
+
`Change ${change.id}`,
|
|
837
|
+
`Ignored: ${change.ignored ? "yes" : "no"}`,
|
|
838
|
+
`Occurrences: ${change.occurrences}`
|
|
839
|
+
].join("\n");
|
|
840
|
+
}
|
|
841
|
+
function formatReview(review) {
|
|
842
|
+
const lines = [
|
|
843
|
+
`Review #${review.id}`,
|
|
844
|
+
`State: ${review.state}`,
|
|
845
|
+
`Author: ${formatUser(review.user)}`,
|
|
846
|
+
`Date: ${review.date}`
|
|
847
|
+
];
|
|
848
|
+
if (review.dismissedAt) lines.push(`Dismissed: ${review.dismissedAt} by ${formatUser(review.dismissedBy)}`);
|
|
849
|
+
return lines.join("\n");
|
|
850
|
+
}
|
|
851
|
+
function formatReviews(reviews) {
|
|
852
|
+
if (reviews.length === 0) return "No reviews found.";
|
|
853
|
+
return [
|
|
854
|
+
`Reviews (${reviews.length})`,
|
|
855
|
+
"",
|
|
856
|
+
...reviews.flatMap((review) => [
|
|
857
|
+
`#${review.id} [${review.state}${review.dismissedAt ? ", dismissed" : ""}]`,
|
|
858
|
+
` Author: ${formatUser(review.user)}`,
|
|
859
|
+
` Date: ${review.date}`,
|
|
860
|
+
""
|
|
861
|
+
])
|
|
862
|
+
].slice(0, -1).join("\n");
|
|
863
|
+
}
|
|
864
|
+
function formatAnalytics(analytics, context) {
|
|
865
|
+
const { screenshots, builds } = analytics;
|
|
866
|
+
const lines = [
|
|
867
|
+
`Analytics for ${context.account}`,
|
|
868
|
+
`Period: ${context.from} → ${context.to ?? "now"} (grouped by ${context.groupBy})`,
|
|
869
|
+
"",
|
|
870
|
+
`Builds: ${builds.all.total}`,
|
|
871
|
+
` Changes detected: ${builds.all.changesDetected}`,
|
|
872
|
+
` No changes: ${builds.all.noChanges}`,
|
|
873
|
+
` Accepted: ${builds.all.accepted}`,
|
|
874
|
+
` Rejected: ${builds.all.rejected}`,
|
|
875
|
+
`Screenshots: ${screenshots.all.total}`
|
|
876
|
+
];
|
|
877
|
+
const names = /* @__PURE__ */ new Map();
|
|
878
|
+
for (const project of [...builds.projects, ...screenshots.projects]) names.set(project.id, project.name);
|
|
879
|
+
const ids = /* @__PURE__ */ new Set([...Object.keys(builds.all.projects), ...Object.keys(screenshots.all.projects)]);
|
|
880
|
+
if (ids.size > 0) {
|
|
881
|
+
const rows = [...ids].map((id) => ({
|
|
882
|
+
name: names.get(id) ?? id,
|
|
883
|
+
builds: builds.all.projects[id] ?? 0,
|
|
884
|
+
screenshots: screenshots.all.projects[id] ?? 0
|
|
885
|
+
})).sort((a, b) => b.builds - a.builds || b.screenshots - a.screenshots);
|
|
886
|
+
lines.push("", `Projects (${rows.length}):`);
|
|
887
|
+
for (const row of rows) lines.push(` ${row.name}: ${row.builds} builds, ${row.screenshots} screenshots`);
|
|
888
|
+
}
|
|
889
|
+
return lines.join("\n");
|
|
890
|
+
}
|
|
891
|
+
function indent(text) {
|
|
892
|
+
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
893
|
+
}
|
|
894
|
+
function formatAnchor(anchor) {
|
|
895
|
+
if (!anchor) return null;
|
|
896
|
+
return anchor.type === "point" ? `point ${anchor.x},${anchor.y}` : `lines ${anchor.from}-${anchor.to}`;
|
|
897
|
+
}
|
|
898
|
+
function formatReactions(reactions) {
|
|
899
|
+
if (reactions.length === 0) return null;
|
|
900
|
+
return reactions.map((r) => `${r.emoji} ${r.count}`).join(" ");
|
|
901
|
+
}
|
|
902
|
+
function formatComment(comment) {
|
|
903
|
+
const lines = [`Comment #${comment.id}`, `Author: ${formatUser(comment.author)}`];
|
|
904
|
+
if (comment.threadId) lines.push(`Reply to: ${comment.threadId}`);
|
|
905
|
+
if (comment.screenshotDiffId) {
|
|
906
|
+
const anchor = formatAnchor(comment.anchor);
|
|
907
|
+
lines.push(`Diff: ${comment.screenshotDiffId}${anchor ? ` (${anchor})` : ""}`);
|
|
908
|
+
}
|
|
909
|
+
if (comment.pending) lines.push("Pending: draft (only visible to you)");
|
|
910
|
+
if (comment.resolvedAt) lines.push(`Resolved: ${comment.resolvedAt}`);
|
|
911
|
+
const reactions = formatReactions(comment.reactions);
|
|
912
|
+
if (reactions) lines.push(`Reactions: ${reactions}`);
|
|
913
|
+
lines.push(`Date: ${comment.createdAt}${comment.editedAt ? " (edited)" : ""}`);
|
|
914
|
+
lines.push("", indent(comment.text));
|
|
915
|
+
return lines.join("\n");
|
|
916
|
+
}
|
|
917
|
+
function formatComments(comments) {
|
|
918
|
+
if (comments.length === 0) return "No comments found.";
|
|
919
|
+
return [
|
|
920
|
+
`Comments (${comments.length})`,
|
|
921
|
+
"",
|
|
922
|
+
...comments.flatMap((comment) => {
|
|
923
|
+
const tags = [
|
|
924
|
+
comment.threadId ? "reply" : "thread",
|
|
925
|
+
comment.pending ? "draft" : null,
|
|
926
|
+
comment.resolvedAt ? "resolved" : null
|
|
927
|
+
].filter(Boolean);
|
|
928
|
+
return [
|
|
929
|
+
`#${comment.id} [${tags.join(", ")}] ${formatUser(comment.author)}`,
|
|
930
|
+
indent(comment.text),
|
|
931
|
+
""
|
|
932
|
+
];
|
|
933
|
+
})
|
|
934
|
+
].slice(0, -1).join("\n");
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
736
937
|
//#region src/commands/build/get.ts
|
|
737
938
|
function registerBuildGet(build) {
|
|
738
939
|
build.command("get").description("Fetch build metadata").argument("<buildReference>", "Build number or Argos build URL").addOption(tokenOption).addOption(jsonOption).action((reference, options) => runBuildAction({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snapvisor/cli",
|
|
3
3
|
"description": "Command-line (CLI) for visual testing with Snapvisor.",
|
|
4
|
-
"version": "7.
|
|
4
|
+
"version": "7.1.0",
|
|
5
5
|
"bin": {
|
|
6
6
|
"snapvisor": "./bin/argos-cli.js",
|
|
7
7
|
"argos": "./bin/argos-cli.js"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@snapvisor/api-client": "0.27.0",
|
|
39
|
-
"@snapvisor/core": "7.0.
|
|
39
|
+
"@snapvisor/core": "7.0.1",
|
|
40
40
|
"commander": "^15.0.0",
|
|
41
41
|
"open": "^11.0.0",
|
|
42
42
|
"ora": "^9.4.0",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"check-format": "prettier --check --ignore-unknown --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .",
|
|
56
56
|
"lint": "eslint ."
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "60252cc8da90a38220fd44333e3ac88470bb63ab"
|
|
59
59
|
}
|