checkly 8.22.0 → 8.23.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/dist/ai-context/skills-command/references/configure-playwright-checks.md +24 -19
- package/dist/commands/debug/parse-project.js +3 -0
- package/dist/commands/debug/parse-project.js.map +1 -1
- package/dist/commands/deploy.js +3 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/pw-test.js +3 -0
- package/dist/commands/pw-test.js.map +1 -1
- package/dist/commands/test.js +3 -0
- package/dist/commands/test.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js.map +1 -1
- package/dist/constructs/playwright-check-bundle.d.ts +5 -5
- package/dist/constructs/playwright-check-bundle.js.map +1 -1
- package/dist/constructs/project.js +5 -2
- package/dist/constructs/project.js.map +1 -1
- package/dist/constructs/session.d.ts +2 -1
- package/dist/constructs/session.js +2 -1
- package/dist/constructs/session.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/services/check-parser/bundler.d.ts +79 -8
- package/dist/services/check-parser/bundler.js +567 -9
- package/dist/services/check-parser/bundler.js.map +1 -1
- package/dist/services/check-parser/cache-hash.d.ts +141 -17
- package/dist/services/check-parser/cache-hash.js +78 -12
- package/dist/services/check-parser/cache-hash.js.map +1 -1
- package/dist/services/check-parser/faux-package.d.ts +5 -0
- package/dist/services/check-parser/faux-package.js +9 -1
- package/dist/services/check-parser/faux-package.js.map +1 -1
- package/dist/services/check-parser/lockfile-pruner.d.ts +104 -0
- package/dist/services/check-parser/lockfile-pruner.js +1107 -0
- package/dist/services/check-parser/lockfile-pruner.js.map +1 -0
- package/dist/services/check-parser/package-files/package-manager.d.ts +34 -0
- package/dist/services/check-parser/package-files/package-manager.js +131 -3
- package/dist/services/check-parser/package-files/package-manager.js.map +1 -1
- package/dist/services/check-parser/package-files/pnpmfile.d.ts +40 -0
- package/dist/services/check-parser/package-files/pnpmfile.js +322 -0
- package/dist/services/check-parser/package-files/pnpmfile.js.map +1 -0
- package/dist/services/check-parser/package-files/resolver.d.ts +6 -1
- package/dist/services/check-parser/package-files/resolver.js +34 -0
- package/dist/services/check-parser/package-files/resolver.js.map +1 -1
- package/dist/services/check-parser/package-files/workspace.d.ts +16 -1
- package/dist/services/check-parser/package-files/workspace.js +17 -2
- package/dist/services/check-parser/package-files/workspace.js.map +1 -1
- package/dist/services/check-parser/package-prune.d.ts +68 -0
- package/dist/services/check-parser/package-prune.js +274 -0
- package/dist/services/check-parser/package-prune.js.map +1 -0
- package/dist/services/check-parser/parser.js +9 -0
- package/dist/services/check-parser/parser.js.map +1 -1
- package/dist/services/check-parser/patched-dependencies.d.ts +133 -0
- package/dist/services/check-parser/patched-dependencies.js +428 -0
- package/dist/services/check-parser/patched-dependencies.js.map +1 -0
- package/dist/services/checkly-config-loader.d.ts +164 -10
- package/dist/services/checkly-config-loader.js +39 -0
- package/dist/services/checkly-config-loader.js.map +1 -1
- package/dist/services/embedded-packages/cache.d.ts +8 -4
- package/dist/services/embedded-packages/cache.js +27 -6
- package/dist/services/embedded-packages/cache.js.map +1 -1
- package/dist/services/embedded-packages/diagnostics.d.ts +129 -0
- package/dist/services/embedded-packages/diagnostics.js +212 -0
- package/dist/services/embedded-packages/diagnostics.js.map +1 -0
- package/dist/services/embedded-packages/lockfile-filter.d.ts +20 -0
- package/dist/services/embedded-packages/lockfile-filter.js +40 -0
- package/dist/services/embedded-packages/lockfile-filter.js.map +1 -0
- package/dist/services/embedded-packages/lockfile-packages.d.ts +31 -2
- package/dist/services/embedded-packages/lockfile-packages.js +234 -30
- package/dist/services/embedded-packages/lockfile-packages.js.map +1 -1
- package/dist/services/embedded-packages/materializer.d.ts +19 -5
- package/dist/services/embedded-packages/materializer.js +445 -91
- package/dist/services/embedded-packages/materializer.js.map +1 -1
- package/dist/services/embedded-packages/npmrc.d.ts +163 -21
- package/dist/services/embedded-packages/npmrc.js +440 -75
- package/dist/services/embedded-packages/npmrc.js.map +1 -1
- package/dist/services/embedded-packages/spec.d.ts +89 -17
- package/dist/services/embedded-packages/spec.js +199 -30
- package/dist/services/embedded-packages/spec.js.map +1 -1
- package/dist/services/embedded-packages/url.d.ts +39 -0
- package/dist/services/embedded-packages/url.js +60 -0
- package/dist/services/embedded-packages/url.js.map +1 -0
- package/dist/services/playwright-project-bundler.js +27 -23
- package/dist/services/playwright-project-bundler.js.map +1 -1
- package/dist/services/runner/registries.d.ts +144 -0
- package/dist/services/runner/registries.js +232 -0
- package/dist/services/runner/registries.js.map +1 -0
- package/oclif.manifest.json +1 -1
- package/package.json +5 -5
|
@@ -6,10 +6,12 @@ import Debug from 'debug';
|
|
|
6
6
|
import PQueue from 'p-queue';
|
|
7
7
|
import { assignProxy } from '../proxy.js';
|
|
8
8
|
import { TarballCache, lookupNpmCacache } from './cache.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
9
|
+
import { downloadFailureHint, capList, describeConfigKeys, describeUnreadableConfig, describeUnusableUrlOrigin, redactUrl, } from './diagnostics.js';
|
|
10
|
+
import { integrityHashToHex, strongestIntegrityHash, verifyIntegrity } from './integrity.js';
|
|
11
|
+
import { UnsupportedLockfileError, isPnpmLockfile, loadLockfilePackages, } from './lockfile-packages.js';
|
|
12
|
+
import { defaultNpmrcPaths, loadNpmrcConfig, pnpmAuthIniPath, resolveAuthHeader, resolveRegistry, } from './npmrc.js';
|
|
13
|
+
import { InvalidEmbeddedPackageSpecError, parseEmbeddedPackageSpec, specLooselyMatchesPackage, specMatchesPackage, specMatchesPackageName, } from './spec.js';
|
|
14
|
+
import { COMPOSABLE_URL_REQUIREMENT, parseFetchableUrl } from './url.js';
|
|
13
15
|
const debug = Debug('checkly:cli:services:embedded-packages');
|
|
14
16
|
/**
|
|
15
17
|
* The directory inside the code bundle where embedded package tarballs
|
|
@@ -27,40 +29,39 @@ const DOWNLOAD_CONCURRENCY = 5;
|
|
|
27
29
|
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
28
30
|
const MAX_TARBALL_BYTES = 1024 * 1024 * 1024;
|
|
29
31
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* Wraps an axios error from a registry request in an EmbeddedPackageError,
|
|
33
|
+
* appending the HTTP status and whatever the caller's hint makes of it.
|
|
34
|
+
* `message` is the action-specific prefix (e.g. "Failed to download …").
|
|
33
35
|
*/
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
|
|
36
|
+
function registryHttpError(err, message, hint = () => '') {
|
|
37
|
+
const status = err?.response?.status;
|
|
38
|
+
const statusHint = status !== undefined ? ` (HTTP ${status})` : '';
|
|
39
|
+
return new EmbeddedPackageError(`${message}${statusHint}.${hint(status)}`, { cause: err });
|
|
37
40
|
}
|
|
38
41
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
42
|
+
* Whether a URL carries credentials in its userinfo component. axios sends
|
|
43
|
+
* those itself — and drops any `Authorization` header when it does — so a
|
|
44
|
+
* failure hint that only consulted the npm config would contradict what was
|
|
45
|
+
* actually on the wire.
|
|
46
|
+
*
|
|
47
|
+
* Only called with a URL the caller has already parsed successfully.
|
|
41
48
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
parsed.username = '';
|
|
46
|
-
parsed.password = '';
|
|
47
|
-
return parsed.toString();
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
// Not parseable as a URL (e.g. a scheme-less registry entry) — strip
|
|
51
|
-
// anything that looks like a userinfo segment before displaying it.
|
|
52
|
-
return url.replace(/(^|\/\/)[^/@\s]+@/, '$1');
|
|
53
|
-
}
|
|
49
|
+
function hasUrlCredentials(url) {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
return parsed.username !== '' || parsed.password !== '';
|
|
54
52
|
}
|
|
55
53
|
/**
|
|
56
54
|
* Resolves the configured `bundle.packages.embed` specs against the
|
|
57
55
|
* workspace lockfile (plan) and sources the selected tarballs into the CLI
|
|
58
|
-
* cache (
|
|
56
|
+
* cache (materializeTarballs), through a chain of CLI cache → npm cacache →
|
|
59
57
|
* registry download, always verified against the lockfile integrity.
|
|
60
58
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* The plan memoizes its in-flight promise: validation and bundling share
|
|
60
|
+
* one instance per parsed project, so the (purely local) resolution runs
|
|
61
|
+
* exactly once. Materialization has a single caller — the Bundler, at
|
|
62
|
+
* finalize time, after the bundled lockfile has been pruned — which passes
|
|
63
|
+
* the subset of the plan the shipped lockfile still references, so
|
|
64
|
+
* pruned-away tarballs are never downloaded.
|
|
64
65
|
*/
|
|
65
66
|
export class EmbeddedPackagesMaterializer {
|
|
66
67
|
#options;
|
|
@@ -68,7 +69,18 @@ export class EmbeddedPackagesMaterializer {
|
|
|
68
69
|
#env;
|
|
69
70
|
#homedir;
|
|
70
71
|
#plan;
|
|
71
|
-
|
|
72
|
+
/**
|
|
73
|
+
* In-flight tarball sourcing, keyed by the strongest integrity hash —
|
|
74
|
+
* the same addressing the cache stores under. Plan entries can share
|
|
75
|
+
* tarball content (two versions published with identical bytes), and the
|
|
76
|
+
* download queue runs them concurrently; sharing one sourcing promise
|
|
77
|
+
* per distinct hash fetches and writes such content once instead of
|
|
78
|
+
* racing identical writes onto one cache path. Entries are dropped as
|
|
79
|
+
* soon as sourcing settles: later materializations must read through the
|
|
80
|
+
* cache, whose reads re-verify content, rather than trust a memoized
|
|
81
|
+
* path.
|
|
82
|
+
*/
|
|
83
|
+
#inFlightTarballs = new Map();
|
|
72
84
|
constructor(options) {
|
|
73
85
|
this.#options = options;
|
|
74
86
|
this.#env = options.env ?? process.env;
|
|
@@ -83,9 +95,48 @@ export class EmbeddedPackagesMaterializer {
|
|
|
83
95
|
this.#plan ??= this.#createPlan();
|
|
84
96
|
return this.#plan;
|
|
85
97
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Sources the given subset of the planned tarballs (CLI cache → npm
|
|
100
|
+
* cacache → registry download, verified against the lockfile integrity).
|
|
101
|
+
* Lets the caller materialize only the tarballs a pruned bundled lockfile
|
|
102
|
+
* still references, so pruned-away packages are never downloaded.
|
|
103
|
+
*/
|
|
104
|
+
async materializeTarballs(tarballs) {
|
|
105
|
+
const { issues } = await this.plan();
|
|
106
|
+
// Commands validate before bundling and exit on fatal diagnostics, so
|
|
107
|
+
// this is a defensive backstop for direct/programmatic use. Checked
|
|
108
|
+
// before the empty-list short-circuit: an invalid configuration must
|
|
109
|
+
// not pass silently just because nothing was requested.
|
|
110
|
+
if (issues.length > 0) {
|
|
111
|
+
throw new EmbeddedPackageError(`Cannot embed packages due to configuration issues:\n\n`
|
|
112
|
+
+ issues.map(issue => ` ${issue.message}`).join('\n'));
|
|
113
|
+
}
|
|
114
|
+
if (tarballs.length === 0) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
// Safe to assert both: a missing lockfile is a plan issue, and issues
|
|
118
|
+
// abort above.
|
|
119
|
+
const lockfilePath = this.#options.lockfilePath;
|
|
120
|
+
const pnpmAuthFile = pnpmAuthIniPath(this.#env, process.platform, this.#homedir);
|
|
121
|
+
const pnpmAuthFilePreferred = isPnpmLockfile(lockfilePath);
|
|
122
|
+
debug('pnpm auth file %s (preferred: %s)', pnpmAuthFile, pnpmAuthFilePreferred);
|
|
123
|
+
const npmrc = await loadNpmrcConfig(defaultNpmrcPaths({
|
|
124
|
+
workspaceRoot: this.#projectRoot,
|
|
125
|
+
homedir: this.#homedir,
|
|
126
|
+
contextDir: this.#options.contextDir,
|
|
127
|
+
pnpmAuthFile,
|
|
128
|
+
pnpmAuthFilePreferred,
|
|
129
|
+
}), this.#env);
|
|
130
|
+
const queue = new PQueue({ concurrency: DOWNLOAD_CONCURRENCY });
|
|
131
|
+
return await queue.addAll(tarballs.map(tarball => async () => {
|
|
132
|
+
const { filePath, integrity } = await this.#obtainTarball(tarball, npmrc);
|
|
133
|
+
return {
|
|
134
|
+
...tarball,
|
|
135
|
+
integrity,
|
|
136
|
+
filePath,
|
|
137
|
+
archivePath: `${EMBEDDED_PACKAGES_ARCHIVE_DIR}/${tarball.archiveFilename}`,
|
|
138
|
+
};
|
|
139
|
+
}));
|
|
89
140
|
}
|
|
90
141
|
async #createPlan() {
|
|
91
142
|
const issues = [];
|
|
@@ -137,12 +188,74 @@ export class EmbeddedPackagesMaterializer {
|
|
|
137
188
|
const registryKeys = new Set(packages.registry.map(entry => `${entry.name}@${entry.version}`));
|
|
138
189
|
const relevantExcluded = packages.excluded.filter(entry => entry.version === undefined || !registryKeys.has(`${entry.name}@${entry.version}`));
|
|
139
190
|
const tarballs = new Map();
|
|
140
|
-
for (const spec of specs) {
|
|
191
|
+
for (const [index, spec] of specs.entries()) {
|
|
192
|
+
// Exclusions select nothing themselves; they subtract from the entries
|
|
193
|
+
// before them, via the kept() filter below. One that removes nothing is
|
|
194
|
+
// a valid no-op rather than an error, unlike an unresolvable inclusion.
|
|
195
|
+
if (spec.exclude) {
|
|
196
|
+
// Matching nothing is a valid outcome, so a misspelled `!` entry
|
|
197
|
+
// cannot be an error — but it silently fails to keep a package out,
|
|
198
|
+
// which is worth a line on the debug channel.
|
|
199
|
+
if (!packages.registry.some(entry => specMatchesPackageName(spec, entry.name))
|
|
200
|
+
&& !relevantExcluded.some(entry => specMatchesPackageName(spec, entry.name))) {
|
|
201
|
+
debug('exclusion %s matches no package in the lockfile', spec.raw);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Entries apply in order, so only the `!` entries that come after this
|
|
206
|
+
// one take anything away from it. Filtering the matches up front,
|
|
207
|
+
// rather than pruning the finished plan, keeps the diagnostics below in
|
|
208
|
+
// step with what actually ships: an entry never warns about, or fails
|
|
209
|
+
// over, a package the configuration goes on to exclude. This applies
|
|
210
|
+
// the ordered rule `patternsSelectName` (spec.ts) defines canonically,
|
|
211
|
+
// generalized to embed's version pins via specMatchesPackage — the
|
|
212
|
+
// name-only helper cannot express `!bar@2.0.0`. A change to the
|
|
213
|
+
// exclusion semantics must land in both.
|
|
214
|
+
const laterExclusions = specs.slice(index + 1).filter(other => other.exclude);
|
|
215
|
+
const kept = (entries) => entries.filter(entry => !laterExclusions.some(other => specMatchesPackage(other, entry)));
|
|
216
|
+
// nameMatches stays unfiltered: it only feeds the diagnostics below,
|
|
217
|
+
// which describe the lockfile as it is — a mistyped pin should still be
|
|
218
|
+
// told which versions exist, even when an unrelated exclusion removed
|
|
219
|
+
// them from what this entry embeds.
|
|
141
220
|
const nameMatches = packages.registry.filter(entry => specMatchesPackageName(spec, entry.name));
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
221
|
+
const allCandidates = packages.registry.filter(entry => specMatchesPackage(spec, entry));
|
|
222
|
+
// The two sets differ only in version-less entries (workspace links,
|
|
223
|
+
// git resolutions), which the loose one keeps: such an entry matches
|
|
224
|
+
// any pin, so it can describe a pinned spec's failure but must not be
|
|
225
|
+
// what silences it. Both feed the diagnostics below, at different
|
|
226
|
+
// rungs of the ladder.
|
|
227
|
+
const allLooseExcluded = relevantExcluded.filter(entry => specLooselyMatchesPackage(spec, entry));
|
|
228
|
+
const allStrictExcluded = relevantExcluded.filter(entry => specMatchesPackage(spec, entry));
|
|
229
|
+
const candidates = kept(allCandidates);
|
|
230
|
+
const looseExcluded = kept(allLooseExcluded);
|
|
231
|
+
const strictExcluded = kept(allStrictExcluded);
|
|
232
|
+
// Everything this entry could have embedded was removed by a later `!`
|
|
233
|
+
// entry, which is the configured outcome: it embeds nothing and reports
|
|
234
|
+
// nothing instead of looking unresolvable. Two ways to get there, and
|
|
235
|
+
// both require the exclusions to be the whole reason: an entry that had
|
|
236
|
+
// embeddable matches is silent once every one of them is excluded, and
|
|
237
|
+
// an entry that only ever reached un-embeddable matches is silent only
|
|
238
|
+
// once every one of *those* is excluded — one that survives still
|
|
239
|
+
// carries the not-embeddable error it would raise on its own.
|
|
240
|
+
//
|
|
241
|
+
// Comparing at the version-filtered level matters: an entry disabled by
|
|
242
|
+
// appending its own pin as an exclusion ('bar@2.0.0', '!bar@2.0.0')
|
|
243
|
+
// would otherwise stay alive on the package's other versions and fail
|
|
244
|
+
// with a version-not-found error that names them as the only ones in
|
|
245
|
+
// the lockfile.
|
|
246
|
+
//
|
|
247
|
+
// An entry emptied this way also drops the skip warning for any
|
|
248
|
+
// un-embeddable package it reached but did not exclude. That is a
|
|
249
|
+
// deliberate trade: keeping the warning means keeping the entry alive
|
|
250
|
+
// past this point, where an un-embeddable match with nothing left to
|
|
251
|
+
// embed alongside it is a fatal error. The debug line below is what
|
|
252
|
+
// explains an entry that embedded nothing.
|
|
253
|
+
if (candidates.length === 0
|
|
254
|
+
&& (allCandidates.length > 0
|
|
255
|
+
|| (allStrictExcluded.length > 0 && strictExcluded.length === 0))) {
|
|
256
|
+
debug('spec %s: embeds nothing, later exclusions removed every embeddable match (reached: %j)', spec.raw, [...allCandidates, ...allStrictExcluded].map(entry => `${entry.name}@${entry.version}`));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
146
259
|
if (candidates.length === 0) {
|
|
147
260
|
// Excluded entries matching the exact pin (or any entry, when
|
|
148
261
|
// unpinned) carry the most actionable reason and win; a version
|
|
@@ -150,10 +263,14 @@ export class EmbeddedPackagesMaterializer {
|
|
|
150
263
|
// Version-less excluded entries (e.g. workspace links) are a last
|
|
151
264
|
// resort, so a pinned spec is never blamed on one while a better
|
|
152
265
|
// explanation exists.
|
|
153
|
-
|
|
266
|
+
// The fallback reads the unfiltered set, for the same reason
|
|
267
|
+
// nameMatches is unfiltered: an entry that still has to fail should
|
|
268
|
+
// fail with the most accurate reason the lockfile offers, and a
|
|
269
|
+
// version-less excluded entry (a git resolution, a workspace link)
|
|
270
|
+
// is often the only thing that explains it.
|
|
154
271
|
const excludedMatches = strictExcluded.length > 0
|
|
155
272
|
? strictExcluded
|
|
156
|
-
: nameMatches.length === 0 ?
|
|
273
|
+
: nameMatches.length === 0 ? allLooseExcluded : [];
|
|
157
274
|
if (excludedMatches.length > 0) {
|
|
158
275
|
const reasons = capList([...new Set(excludedMatches.map(entry => entry.reason))], '; ', '; and ');
|
|
159
276
|
issues.push({
|
|
@@ -175,7 +292,7 @@ export class EmbeddedPackagesMaterializer {
|
|
|
175
292
|
});
|
|
176
293
|
}
|
|
177
294
|
else {
|
|
178
|
-
const hint = spec.
|
|
295
|
+
const hint = spec.wildcard
|
|
179
296
|
? `pattern matches its name${spec.version !== undefined ? ' and the version is spelled correctly' : ''}`
|
|
180
297
|
: `name ${spec.version !== undefined ? 'and version are' : 'is'} spelled correctly`;
|
|
181
298
|
issues.push({
|
|
@@ -203,7 +320,7 @@ export class EmbeddedPackagesMaterializer {
|
|
|
203
320
|
+ ` be embedded as registry tarballs and were skipped: ${capList(names, ', ', ' and ')}.`
|
|
204
321
|
+ ` The runner must be able to fetch these itself.`);
|
|
205
322
|
}
|
|
206
|
-
if (spec.
|
|
323
|
+
if (spec.wildcard) {
|
|
207
324
|
// Wildcards select invisibly, but only the debug log says what they
|
|
208
325
|
// selected. Selections that need attention surface louder: a
|
|
209
326
|
// pattern matching nothing is a fatal validation issue, and matches
|
|
@@ -217,6 +334,16 @@ export class EmbeddedPackagesMaterializer {
|
|
|
217
334
|
});
|
|
218
335
|
}
|
|
219
336
|
}
|
|
337
|
+
// Without exclusions every entry either embeds something or raises an
|
|
338
|
+
// issue, so an empty plan with nothing to report can only come from `!`
|
|
339
|
+
// entries — most likely a config that reads them as gitignore's implicit
|
|
340
|
+
// "everything except" rather than as a subtraction from what came
|
|
341
|
+
// before. Embedding nothing is not an error, but saying so beats letting
|
|
342
|
+
// the user find out from an install failure on the runner.
|
|
343
|
+
if (specs.length > 0 && tarballs.size === 0 && issues.length === 0) {
|
|
344
|
+
warnings.push(`No packages matched 'bundle.packages.embed', so nothing will be embedded into the code bundle.`
|
|
345
|
+
+ ` An exclusion entry ('!...') only removes packages that the entries before it selected.`);
|
|
346
|
+
}
|
|
220
347
|
debug('plan: %d tarballs, %d issues, %d warnings', tarballs.size, issues.length, warnings.length);
|
|
221
348
|
return {
|
|
222
349
|
tarballs: [...tarballs.values()].sort((a, b) => a.archiveFilename.localeCompare(b.archiveFilename)),
|
|
@@ -225,65 +352,281 @@ export class EmbeddedPackagesMaterializer {
|
|
|
225
352
|
lockfilePath,
|
|
226
353
|
};
|
|
227
354
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Resolves the registry a package comes from, refusing one nothing can be
|
|
357
|
+
* fetched from.
|
|
358
|
+
*
|
|
359
|
+
* The registry URL is checked before anything is composed onto it:
|
|
360
|
+
* `registry=https://` composes into `https://<package name>/...`, which
|
|
361
|
+
* parses cleanly with the package name as its HOST, so the request would
|
|
362
|
+
* go to whatever host bears that name. A query or fragment is refused for
|
|
363
|
+
* the mirror-image reason — it absorbs the path instead of the host.
|
|
364
|
+
*/
|
|
365
|
+
#resolveFetchableRegistry(tarball, npmrc) {
|
|
366
|
+
const registry = resolveRegistry(npmrc.config, tarball.name, this.#env);
|
|
367
|
+
if (registry.usable) {
|
|
368
|
+
return registry;
|
|
235
369
|
}
|
|
236
|
-
|
|
237
|
-
|
|
370
|
+
// One sentence covering every way it can fail — it parses or it does
|
|
371
|
+
// not, it has a host or it does not, its scheme is fetchable or it is
|
|
372
|
+
// not, it carries a query or it does not — because splitting them
|
|
373
|
+
// produced advice that was wrong for the case it did not cover:
|
|
374
|
+
// `file:///srv/mirror/` is absolute and has a protocol, and being told
|
|
375
|
+
// to add one sends the reader nowhere.
|
|
376
|
+
//
|
|
377
|
+
// The value is not echoed, for the same reason a composed URL is not:
|
|
378
|
+
// one this malformed could carry a credential anywhere in it.
|
|
379
|
+
throw new EmbeddedPackageError(`The registry URL for embedded package '${tarball.name}@${tarball.version}' is not usable:`
|
|
380
|
+
+ ` it must be ${COMPOSABLE_URL_REQUIREMENT}.`
|
|
381
|
+
+ ` It is configured by ${describeConfigKeys([registry.key], npmrc)}.`);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Rejects a tarball URL nothing can be fetched from, naming whoever
|
|
385
|
+
* handed it over. Only a URL this CLI did not compose can get here — see
|
|
386
|
+
* `RecordedUrlOrigin`.
|
|
387
|
+
*
|
|
388
|
+
* The offending value is deliberately not echoed: it is unusable by
|
|
389
|
+
* definition here, so nothing can reliably tell a credential in it from a
|
|
390
|
+
* path. Naming the source is both safe and more useful — that is where
|
|
391
|
+
* the reader goes to fix it.
|
|
392
|
+
*/
|
|
393
|
+
#assertFetchableTarballUrl(url, tarball, npmrc, origin) {
|
|
394
|
+
if (parseFetchableUrl(url) !== undefined) {
|
|
395
|
+
return;
|
|
238
396
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
397
|
+
throw new EmbeddedPackageError(`The tarball URL for embedded package '${tarball.name}@${tarball.version}'`
|
|
398
|
+
+ ` is not a valid URL. ${describeUnusableUrlOrigin(origin, npmrc)}`);
|
|
399
|
+
}
|
|
400
|
+
async #obtainTarball(tarball, npmrc) {
|
|
401
|
+
let { integrity, tarballUrl } = tarball;
|
|
402
|
+
// Set when the URL below came from package metadata rather than the
|
|
403
|
+
// lockfile, so a failure blames the registry that served it instead of
|
|
404
|
+
// a lockfile that never mentioned it.
|
|
405
|
+
let metadataOrigin;
|
|
406
|
+
if (integrity === undefined) {
|
|
407
|
+
// yarn.lock plans carry no SRI tarball integrity (Berry checksums
|
|
408
|
+
// hash yarn's own cache archive); resolve it from the registry's
|
|
409
|
+
// per-version metadata before the caches can be consulted.
|
|
410
|
+
const dist = await this.#resolveDistFromRegistry(tarball, npmrc);
|
|
411
|
+
integrity = dist.integrity;
|
|
412
|
+
if (tarballUrl === undefined && dist.tarballUrl !== undefined) {
|
|
413
|
+
tarballUrl = dist.tarballUrl;
|
|
414
|
+
metadataOrigin = { metadata: { registryKey: dist.registryKey } };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// An integrity without a supported hash has no cache address to key
|
|
418
|
+
// the deduplication on, so it sources directly (and fails at download
|
|
419
|
+
// verification, since no supported hash can match the content).
|
|
420
|
+
const hash = strongestIntegrityHash(integrity);
|
|
421
|
+
if (hash === undefined) {
|
|
422
|
+
return { filePath: await this.#sourceTarball(tarball, integrity, tarballUrl, metadataOrigin, npmrc), integrity };
|
|
423
|
+
}
|
|
424
|
+
const key = `${hash.algorithm}:${integrityHashToHex(hash)}`;
|
|
425
|
+
let pending = this.#inFlightTarballs.get(key);
|
|
426
|
+
if (pending === undefined) {
|
|
427
|
+
pending = this.#sourceTarball(tarball, integrity, tarballUrl, metadataOrigin, npmrc);
|
|
428
|
+
this.#inFlightTarballs.set(key, pending);
|
|
429
|
+
// Dropped as soon as sourcing settles, whichever way: a later
|
|
430
|
+
// materialization must read through the cache's own verification,
|
|
431
|
+
// and a failed attempt must not poison a retry with its stored
|
|
432
|
+
// rejection.
|
|
433
|
+
pending.then(() => this.#inFlightTarballs.delete(key), () => this.#inFlightTarballs.delete(key));
|
|
434
|
+
}
|
|
435
|
+
return { filePath: await pending, integrity };
|
|
252
436
|
}
|
|
253
|
-
|
|
254
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Sources one tarball's verified content into the CLI cache — CLI cache
|
|
439
|
+
* → npm cacache → registry download, verified against `integrity` — and
|
|
440
|
+
* returns its cache path.
|
|
441
|
+
*
|
|
442
|
+
* When concurrent plan entries share an integrity, only the first
|
|
443
|
+
* entry's identity reaches this method: the URL, registry, and any
|
|
444
|
+
* failure's wording all follow that entry, not whichever entry awaited
|
|
445
|
+
* the shared sourcing.
|
|
446
|
+
*/
|
|
447
|
+
async #sourceTarball(tarball, integrity, tarballUrl, metadataOrigin, npmrc) {
|
|
448
|
+
const cached = await this.#cache.get(integrity);
|
|
255
449
|
if (cached !== undefined) {
|
|
256
450
|
debug('%s@%s: CLI cache hit', tarball.name, tarball.version);
|
|
257
451
|
return cached;
|
|
258
452
|
}
|
|
259
|
-
const fromNpmCacache = await lookupNpmCacache(
|
|
453
|
+
const fromNpmCacache = await lookupNpmCacache(integrity, this.#env, process.platform, this.#homedir);
|
|
260
454
|
if (fromNpmCacache !== undefined) {
|
|
261
455
|
debug('%s@%s: npm cache hit', tarball.name, tarball.version);
|
|
262
|
-
return await this.#cache.put(
|
|
456
|
+
return await this.#cache.put(integrity, fromNpmCacache);
|
|
457
|
+
}
|
|
458
|
+
// Where the URL came from decides who to blame for credentials embedded
|
|
459
|
+
// in it: a lockfile-recorded URL is the lockfile's, a derived one
|
|
460
|
+
// belongs to whichever config key configured the registry.
|
|
461
|
+
let url;
|
|
462
|
+
let urlOrigin;
|
|
463
|
+
if (tarballUrl !== undefined) {
|
|
464
|
+
url = tarballUrl;
|
|
465
|
+
// Safe to assert: a missing lockfile is a plan issue, and materialize
|
|
466
|
+
// aborts on issues before any tarball is obtained.
|
|
467
|
+
const recorded = metadataOrigin ?? { lockfile: this.#options.lockfilePath };
|
|
468
|
+
// Only a URL handed over already formed can be unusable: the one the
|
|
469
|
+
// else-branch composes is built on a registry checked beforehand.
|
|
470
|
+
this.#assertFetchableTarballUrl(url, tarball, npmrc, recorded);
|
|
471
|
+
urlOrigin = recorded;
|
|
263
472
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
473
|
+
else {
|
|
474
|
+
const registry = this.#resolveFetchableRegistry(tarball, npmrc);
|
|
475
|
+
const basename = tarball.name.split('/').pop();
|
|
476
|
+
url = `${registry.url}${tarball.name}/-/${basename}-${tarball.version}.tgz`;
|
|
477
|
+
urlOrigin = { registryKey: registry.key };
|
|
269
478
|
}
|
|
270
479
|
debug('%s@%s: downloading from %s', tarball.name, tarball.version, redactUrl(url));
|
|
271
|
-
const content = await this.#download(tarball, url,
|
|
272
|
-
if (!verifyIntegrity(content,
|
|
480
|
+
const content = await this.#download(tarball, url, npmrc, urlOrigin);
|
|
481
|
+
if (!verifyIntegrity(content, integrity)) {
|
|
482
|
+
// For yarn.lock plans the integrity came from the registry's own
|
|
483
|
+
// metadata, not the lockfile, so name the right source to check.
|
|
484
|
+
const source = tarball.integrity === undefined
|
|
485
|
+
? `the integrity hash the registry's metadata reported`
|
|
486
|
+
: `the integrity hash recorded in the lockfile`;
|
|
273
487
|
throw new EmbeddedPackageError(`The tarball downloaded for embedded package '${tarball.name}@${tarball.version}'`
|
|
274
|
-
+ ` from '${redactUrl(url)}' does not match
|
|
275
|
-
+ ` ('${
|
|
488
|
+
+ ` from '${redactUrl(url)}' does not match ${source}`
|
|
489
|
+
+ ` ('${integrity}'). The registry may be serving a different artifact`
|
|
276
490
|
+ ` than the one the lockfile was created against.`);
|
|
277
491
|
}
|
|
278
|
-
return await this.#cache.put(
|
|
492
|
+
return await this.#cache.put(integrity, content);
|
|
279
493
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
494
|
+
/**
|
|
495
|
+
* Resolves the npm tarball integrity (and canonical tarball URL) for a
|
|
496
|
+
* package whose lockfile cannot provide one. Tries the abbreviated
|
|
497
|
+
* per-version metadata route (`GET <registry>/<name>/<version>`) first,
|
|
498
|
+
* then falls back to the full packument (`GET <registry>/<name>`, whose
|
|
499
|
+
* `versions[version].dist` carries the same fields) — some private
|
|
500
|
+
* registry proxies serve only one of the two. The scope slash stays
|
|
501
|
+
* unencoded, matching npm's own use of these routes. The requests use
|
|
502
|
+
* the same registry resolution and credentials as the tarball download
|
|
503
|
+
* itself, so they add no trust beyond the download; the end-to-end
|
|
504
|
+
* content pin still holds because the package manager re-verifies its
|
|
505
|
+
* own lockfile checksums against the served content at install time.
|
|
506
|
+
*/
|
|
507
|
+
async #resolveDistFromRegistry(tarball, npmrc) {
|
|
508
|
+
// Both routes are this URL plus the package name, so checking it covers
|
|
509
|
+
// them and the composed forms need no guard of their own.
|
|
510
|
+
const registry = this.#resolveFetchableRegistry(tarball, npmrc);
|
|
511
|
+
const versionUrl = `${registry.url}${tarball.name}/${tarball.version}`;
|
|
512
|
+
const packumentUrl = `${registry.url}${tarball.name}`;
|
|
513
|
+
// Per-version route: dist is at the document root.
|
|
514
|
+
const perVersion = await this.#fetchMetadataDist(tarball, npmrc, versionUrl, registry.key, data => data?.dist);
|
|
515
|
+
// Packument fallback (only when the per-version route yielded no dist,
|
|
516
|
+
// whether it 404'd or answered without one): dist is nested per version.
|
|
517
|
+
const packument = perVersion?.dist !== undefined
|
|
518
|
+
? undefined
|
|
519
|
+
: await this.#fetchMetadataDist(tarball, npmrc, packumentUrl, registry.key, data => data?.versions?.[tarball.version]?.dist);
|
|
520
|
+
const dist = perVersion?.dist ?? packument?.dist;
|
|
521
|
+
if (dist === undefined) {
|
|
522
|
+
// Distinguish "the registry has nothing for us" from "it answered but
|
|
523
|
+
// this version is not in it": only the first can be an authorization
|
|
524
|
+
// failure, since a private registry hides packages the caller may not
|
|
525
|
+
// see behind a 404, and claiming so for the second sends the reader to
|
|
526
|
+
// rotate a token the registry just accepted.
|
|
527
|
+
const answered = perVersion !== undefined || packument !== undefined;
|
|
528
|
+
if (answered) {
|
|
529
|
+
throw new EmbeddedPackageError(`The registry metadata at '${redactUrl(versionUrl)}' does not describe embedded package`
|
|
530
|
+
+ ` '${tarball.name}@${tarball.version}', so its integrity could not be resolved.`
|
|
531
|
+
+ ` The version may have been unpublished, the registry may serve only some versions, or`
|
|
532
|
+
+ ` something in front of it — a proxy or an SSO gateway — may have answered instead of`
|
|
533
|
+
+ ` the registry.${describeUnreadableConfig(npmrc)}`);
|
|
534
|
+
}
|
|
535
|
+
const auth = resolveAuthHeader(npmrc.config, versionUrl, tarball.name, this.#env);
|
|
536
|
+
const sent = this.#sentCredentials(versionUrl, { registryKey: registry.key }, auth);
|
|
537
|
+
throw new EmbeddedPackageError(`The registry at '${redactUrl(versionUrl)}' has no metadata for embedded package`
|
|
538
|
+
+ ` '${tarball.name}@${tarball.version}', so its integrity could not be resolved.`
|
|
539
|
+
+ downloadFailureHint(404, sent, npmrc));
|
|
540
|
+
}
|
|
541
|
+
// Modern publishes carry an SRI `integrity`; very old ones only a hex
|
|
542
|
+
// sha1 `shasum`, which converts to a (weaker but supported) SRI hash.
|
|
543
|
+
const integrity = typeof dist.integrity === 'string' && dist.integrity !== ''
|
|
544
|
+
? dist.integrity
|
|
545
|
+
: typeof dist.shasum === 'string' && /^[0-9a-f]{40}$/.test(dist.shasum)
|
|
546
|
+
? `sha1-${Buffer.from(dist.shasum, 'hex').toString('base64')}`
|
|
547
|
+
: undefined;
|
|
548
|
+
if (integrity === undefined) {
|
|
549
|
+
throw new EmbeddedPackageError(`The registry metadata for embedded package '${tarball.name}@${tarball.version}'`
|
|
550
|
+
+ ` (from '${redactUrl(versionUrl)}') provides no usable integrity hash, so the`
|
|
551
|
+
+ ` downloaded tarball could not be verified.`);
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
integrity,
|
|
555
|
+
registryKey: registry.key,
|
|
556
|
+
// The same cheap prefilter the lockfile readers apply, and no more:
|
|
557
|
+
// anything that survives it is checked properly by
|
|
558
|
+
// `#assertFetchableTarballUrl`, which reports a URL the registry
|
|
559
|
+
// returned rather than silently composing a different one.
|
|
560
|
+
tarballUrl: typeof dist.tarball === 'string' && /^https?:/.test(dist.tarball)
|
|
561
|
+
? dist.tarball
|
|
562
|
+
: undefined,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Who supplied the credentials on a request, for the failure message.
|
|
567
|
+
*
|
|
568
|
+
* Userinfo embedded in the URL wins: axios sends that itself and drops
|
|
569
|
+
* the Authorization header when it does, so naming the config entry
|
|
570
|
+
* would name credentials that never reached the wire. A URL with no
|
|
571
|
+
* userinfo falls back to the config keys — including one derived from the
|
|
572
|
+
* default registry, whose URL carries none by construction.
|
|
573
|
+
*/
|
|
574
|
+
#sentCredentials(url, origin, auth) {
|
|
575
|
+
if (hasUrlCredentials(url)) {
|
|
576
|
+
return { from: 'url', origin };
|
|
577
|
+
}
|
|
578
|
+
return auth !== undefined ? { from: 'config', keys: auth.keys } : undefined;
|
|
284
579
|
}
|
|
285
|
-
|
|
286
|
-
|
|
580
|
+
/**
|
|
581
|
+
* Fetches one metadata URL and extracts its `dist` via `select`.
|
|
582
|
+
*
|
|
583
|
+
* Returns undefined on a 404 — distinct from `{ dist: undefined }`, which
|
|
584
|
+
* means the route answered but carried nothing usable. The caller needs
|
|
585
|
+
* both apart: only a route that never answered can be an authorization
|
|
586
|
+
* failure. Any other failure — auth, network, malformed response — throws,
|
|
587
|
+
* because retrying a different route would only mask it.
|
|
588
|
+
*/
|
|
589
|
+
async #fetchMetadataDist(tarball, npmrc, url, registryKey, select) {
|
|
590
|
+
const auth = resolveAuthHeader(npmrc.config, url, tarball.name, this.#env);
|
|
591
|
+
// This URL is always one the CLI built from the registry.
|
|
592
|
+
const sent = this.#sentCredentials(url, { registryKey }, auth);
|
|
593
|
+
debug('%s@%s: resolving integrity from %s', tarball.name, tarball.version, redactUrl(url));
|
|
594
|
+
try {
|
|
595
|
+
const response = await axios.get(url, assignProxy(url, {
|
|
596
|
+
headers: {
|
|
597
|
+
...(auth !== undefined ? { authorization: auth.header } : {}),
|
|
598
|
+
},
|
|
599
|
+
timeout: DOWNLOAD_TIMEOUT_MS,
|
|
600
|
+
}));
|
|
601
|
+
return { dist: select(response.data) ?? undefined };
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
if (err?.response?.status === 404) {
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
throw registryHttpError(err, `Failed to fetch registry metadata for embedded package`
|
|
608
|
+
+ ` '${tarball.name}@${tarball.version}' from '${redactUrl(url)}'`, status => downloadFailureHint(status, sent, npmrc));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async #download(tarball, url, npmrc, urlOrigin) {
|
|
612
|
+
const auth = resolveAuthHeader(npmrc.config, url, tarball.name, this.#env);
|
|
613
|
+
const sent = this.#sentCredentials(url, urlOrigin, auth);
|
|
614
|
+
// A redirect can make the credentials moot: follow-redirects drops
|
|
615
|
+
// confidential headers rather than hand them to another host, so
|
|
616
|
+
// whatever answered never saw them and "they were rejected" would be
|
|
617
|
+
// wrong. Tarball downloads redirect to CDNs routinely.
|
|
618
|
+
//
|
|
619
|
+
// Observed, not predicted: the drop happens before `beforeRedirect`
|
|
620
|
+
// runs and mutates the very options handed to it, so the hook can see
|
|
621
|
+
// what actually survived. Re-deriving the library's rule would get
|
|
622
|
+
// subdomain redirects (which keep the header) and protocol downgrades
|
|
623
|
+
// (which drop it regardless of host) wrong, and would rot silently if
|
|
624
|
+
// the policy ever changed.
|
|
625
|
+
//
|
|
626
|
+
// The hop itself is recorded even when nothing was sent: whatever
|
|
627
|
+
// answered is then not the host the reader configured, and telling them
|
|
628
|
+
// to add credentials for a host that never asked is its own dead end.
|
|
629
|
+
const redirect = {};
|
|
287
630
|
try {
|
|
288
631
|
const response = await axios.get(url, assignProxy(url, {
|
|
289
632
|
responseType: 'arraybuffer',
|
|
@@ -293,7 +636,23 @@ export class EmbeddedPackagesMaterializer {
|
|
|
293
636
|
// otherwise make axios gunzip it, breaking integrity verification
|
|
294
637
|
// with a misleading "different artifact" error.
|
|
295
638
|
'accept-encoding': 'identity',
|
|
296
|
-
...(
|
|
639
|
+
...(auth !== undefined ? { authorization: auth.header } : {}),
|
|
640
|
+
},
|
|
641
|
+
beforeRedirect: (options) => {
|
|
642
|
+
redirect.host = options.host;
|
|
643
|
+
if (sent === undefined) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const keptHeader = Object.keys(options.headers ?? {})
|
|
647
|
+
.some(header => header.toLowerCase() === 'authorization');
|
|
648
|
+
// `!= null` rather than `!== undefined`: the legacy URL path
|
|
649
|
+
// yields `null` here, and treating that as "credentials survived"
|
|
650
|
+
// would fail open on the very check meant to catch a drop.
|
|
651
|
+
const keptUrlAuth = options.auth != null && options.auth !== '';
|
|
652
|
+
// Assigned rather than latched: a later hop back to the original
|
|
653
|
+
// origin restores URL credentials, and reporting them as dropped
|
|
654
|
+
// would send the reader to inspect the wrong host.
|
|
655
|
+
redirect.credentialsDropped = !keptHeader && !keptUrlAuth;
|
|
297
656
|
},
|
|
298
657
|
timeout: DOWNLOAD_TIMEOUT_MS,
|
|
299
658
|
maxContentLength: MAX_TARBALL_BYTES,
|
|
@@ -301,13 +660,8 @@ export class EmbeddedPackagesMaterializer {
|
|
|
301
660
|
return Buffer.from(response.data);
|
|
302
661
|
}
|
|
303
662
|
catch (err) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const authHint = status === 401 || status === 403
|
|
307
|
-
? ` Check that your .npmrc contains valid credentials for this registry.`
|
|
308
|
-
: '';
|
|
309
|
-
throw new EmbeddedPackageError(`Failed to download embedded package '${tarball.name}@${tarball.version}'`
|
|
310
|
-
+ ` from '${redactUrl(url)}'${statusHint}.${authHint}`, { cause: err });
|
|
663
|
+
throw registryHttpError(err, `Failed to download embedded package '${tarball.name}@${tarball.version}'`
|
|
664
|
+
+ ` from '${redactUrl(url)}'`, status => downloadFailureHint(status, sent, npmrc, redirect));
|
|
311
665
|
}
|
|
312
666
|
}
|
|
313
667
|
}
|