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.
Files changed (85) hide show
  1. package/dist/ai-context/skills-command/references/configure-playwright-checks.md +24 -19
  2. package/dist/commands/debug/parse-project.js +3 -0
  3. package/dist/commands/debug/parse-project.js.map +1 -1
  4. package/dist/commands/deploy.js +3 -0
  5. package/dist/commands/deploy.js.map +1 -1
  6. package/dist/commands/pw-test.js +3 -0
  7. package/dist/commands/pw-test.js.map +1 -1
  8. package/dist/commands/test.js +3 -0
  9. package/dist/commands/test.js.map +1 -1
  10. package/dist/config.d.ts +1 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/constructs/playwright-check-bundle.d.ts +5 -5
  13. package/dist/constructs/playwright-check-bundle.js.map +1 -1
  14. package/dist/constructs/project.js +5 -2
  15. package/dist/constructs/project.js.map +1 -1
  16. package/dist/constructs/session.d.ts +2 -1
  17. package/dist/constructs/session.js +2 -1
  18. package/dist/constructs/session.js.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/services/check-parser/bundler.d.ts +79 -8
  21. package/dist/services/check-parser/bundler.js +567 -9
  22. package/dist/services/check-parser/bundler.js.map +1 -1
  23. package/dist/services/check-parser/cache-hash.d.ts +141 -17
  24. package/dist/services/check-parser/cache-hash.js +78 -12
  25. package/dist/services/check-parser/cache-hash.js.map +1 -1
  26. package/dist/services/check-parser/faux-package.d.ts +5 -0
  27. package/dist/services/check-parser/faux-package.js +9 -1
  28. package/dist/services/check-parser/faux-package.js.map +1 -1
  29. package/dist/services/check-parser/lockfile-pruner.d.ts +104 -0
  30. package/dist/services/check-parser/lockfile-pruner.js +1107 -0
  31. package/dist/services/check-parser/lockfile-pruner.js.map +1 -0
  32. package/dist/services/check-parser/package-files/package-manager.d.ts +34 -0
  33. package/dist/services/check-parser/package-files/package-manager.js +131 -3
  34. package/dist/services/check-parser/package-files/package-manager.js.map +1 -1
  35. package/dist/services/check-parser/package-files/pnpmfile.d.ts +40 -0
  36. package/dist/services/check-parser/package-files/pnpmfile.js +322 -0
  37. package/dist/services/check-parser/package-files/pnpmfile.js.map +1 -0
  38. package/dist/services/check-parser/package-files/resolver.d.ts +6 -1
  39. package/dist/services/check-parser/package-files/resolver.js +34 -0
  40. package/dist/services/check-parser/package-files/resolver.js.map +1 -1
  41. package/dist/services/check-parser/package-files/workspace.d.ts +16 -1
  42. package/dist/services/check-parser/package-files/workspace.js +17 -2
  43. package/dist/services/check-parser/package-files/workspace.js.map +1 -1
  44. package/dist/services/check-parser/package-prune.d.ts +68 -0
  45. package/dist/services/check-parser/package-prune.js +274 -0
  46. package/dist/services/check-parser/package-prune.js.map +1 -0
  47. package/dist/services/check-parser/parser.js +9 -0
  48. package/dist/services/check-parser/parser.js.map +1 -1
  49. package/dist/services/check-parser/patched-dependencies.d.ts +133 -0
  50. package/dist/services/check-parser/patched-dependencies.js +428 -0
  51. package/dist/services/check-parser/patched-dependencies.js.map +1 -0
  52. package/dist/services/checkly-config-loader.d.ts +164 -10
  53. package/dist/services/checkly-config-loader.js +39 -0
  54. package/dist/services/checkly-config-loader.js.map +1 -1
  55. package/dist/services/embedded-packages/cache.d.ts +8 -4
  56. package/dist/services/embedded-packages/cache.js +27 -6
  57. package/dist/services/embedded-packages/cache.js.map +1 -1
  58. package/dist/services/embedded-packages/diagnostics.d.ts +129 -0
  59. package/dist/services/embedded-packages/diagnostics.js +212 -0
  60. package/dist/services/embedded-packages/diagnostics.js.map +1 -0
  61. package/dist/services/embedded-packages/lockfile-filter.d.ts +20 -0
  62. package/dist/services/embedded-packages/lockfile-filter.js +40 -0
  63. package/dist/services/embedded-packages/lockfile-filter.js.map +1 -0
  64. package/dist/services/embedded-packages/lockfile-packages.d.ts +31 -2
  65. package/dist/services/embedded-packages/lockfile-packages.js +234 -30
  66. package/dist/services/embedded-packages/lockfile-packages.js.map +1 -1
  67. package/dist/services/embedded-packages/materializer.d.ts +19 -5
  68. package/dist/services/embedded-packages/materializer.js +445 -91
  69. package/dist/services/embedded-packages/materializer.js.map +1 -1
  70. package/dist/services/embedded-packages/npmrc.d.ts +163 -21
  71. package/dist/services/embedded-packages/npmrc.js +440 -75
  72. package/dist/services/embedded-packages/npmrc.js.map +1 -1
  73. package/dist/services/embedded-packages/spec.d.ts +89 -17
  74. package/dist/services/embedded-packages/spec.js +199 -30
  75. package/dist/services/embedded-packages/spec.js.map +1 -1
  76. package/dist/services/embedded-packages/url.d.ts +39 -0
  77. package/dist/services/embedded-packages/url.js +60 -0
  78. package/dist/services/embedded-packages/url.js.map +1 -0
  79. package/dist/services/playwright-project-bundler.js +27 -23
  80. package/dist/services/playwright-project-bundler.js.map +1 -1
  81. package/dist/services/runner/registries.d.ts +144 -0
  82. package/dist/services/runner/registries.js +232 -0
  83. package/dist/services/runner/registries.js.map +1 -0
  84. package/oclif.manifest.json +1 -1
  85. 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 { verifyIntegrity } from './integrity.js';
10
- import { UnsupportedLockfileError, loadLockfilePackages, } from './lockfile-packages.js';
11
- import { defaultNpmrcPaths, loadNpmrcConfig, resolveAuthHeader, resolveRegistryUrl } from './npmrc.js';
12
- import { InvalidEmbeddedPackageSpecError, parseEmbeddedPackageSpec, specMatchesPackageName, } from './spec.js';
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
- * Joins up to 8 items, appending `<overflow>N more` for the rest — the
31
- * uniform truncation for user-facing lists of packages, versions and
32
- * reasons.
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 capList(items, separator, overflow) {
35
- const shown = items.slice(0, 8).join(separator);
36
- return items.length > 8 ? `${shown}${overflow}${items.length - 8} more` : shown;
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
- * Removes userinfo credentials from a URL so it can be safely included in
40
- * error messages and logs (a registry URL may embed a token).
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 redactUrl(url) {
43
- try {
44
- const parsed = new URL(url);
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 (materialize), through a chain of CLI cache → npm cacache →
56
+ * cache (materializeTarballs), through a chain of CLI cache → npm cacache →
59
57
  * registry download, always verified against the lockfile integrity.
60
58
  *
61
- * Both stages memoize their in-flight promise: multiple Playwright checks
62
- * bundle concurrently, and validation and bundling share one instance per
63
- * parsed project, so the work runs exactly once.
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
- #materialized;
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
- materialize() {
87
- this.#materialized ??= this.#materializeAll();
88
- return this.#materialized;
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 candidates = nameMatches
143
- .filter(entry => spec.version === undefined || entry.version === spec.version);
144
- const nameExcluded = relevantExcluded.filter(entry => specMatchesPackageName(spec, entry.name));
145
- const looseExcluded = nameExcluded.filter(entry => spec.version === undefined || entry.version === undefined || entry.version === spec.version);
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
- const strictExcluded = nameExcluded.filter(entry => spec.version === undefined || entry.version === spec.version);
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 ? looseExcluded : [];
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.namePattern !== undefined
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.namePattern !== undefined) {
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
- async #materializeAll() {
229
- const { tarballs, issues } = await this.plan();
230
- // Commands validate before bundling and exit on fatal diagnostics, so
231
- // this is a defensive backstop for direct/programmatic use.
232
- if (issues.length > 0) {
233
- throw new EmbeddedPackageError(`Cannot embed packages due to configuration issues:\n\n`
234
- + issues.map(issue => ` ${issue.message}`).join('\n'));
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
- if (tarballs.length === 0) {
237
- return [];
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
- // Safe to assert: a missing lockfile is a plan issue, and issues abort
240
- // above.
241
- const npmrcConfig = await loadNpmrcConfig(defaultNpmrcPaths(this.#projectRoot, this.#homedir, this.#options.contextDir), this.#env);
242
- const queue = new PQueue({ concurrency: DOWNLOAD_CONCURRENCY });
243
- const results = await queue.addAll(tarballs.map(tarball => async () => {
244
- const filePath = await this.#obtainTarball(tarball, npmrcConfig);
245
- return {
246
- ...tarball,
247
- filePath,
248
- archivePath: `${EMBEDDED_PACKAGES_ARCHIVE_DIR}/${tarball.archiveFilename}`,
249
- };
250
- }));
251
- return results;
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
- async #obtainTarball(tarball, npmrcConfig) {
254
- const cached = await this.#cache.get(tarball.integrity);
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(tarball.integrity, this.#env, process.platform, this.#homedir);
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(tarball.integrity, fromNpmCacache);
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
- const url = tarball.tarballUrl ?? this.#deriveTarballUrl(tarball, npmrcConfig);
265
- if (!URL.canParse(url)) {
266
- throw new EmbeddedPackageError(`The tarball URL for embedded package '${tarball.name}@${tarball.version}'`
267
- + ` is not a valid URL: '${redactUrl(url)}'. Check the 'registry' configuration`
268
- + ` in your .npmrc (it must be an absolute URL including the protocol).`);
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, npmrcConfig);
272
- if (!verifyIntegrity(content, tarball.integrity)) {
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 the integrity hash recorded in the lockfile`
275
- + ` ('${tarball.integrity}'). The registry may be serving a different artifact`
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(tarball.integrity, content);
492
+ return await this.#cache.put(integrity, content);
279
493
  }
280
- #deriveTarballUrl(tarball, npmrcConfig) {
281
- const registryUrl = resolveRegistryUrl(npmrcConfig, tarball.name, this.#env);
282
- const basename = tarball.name.split('/').pop();
283
- return `${registryUrl}${tarball.name}/-/${basename}-${tarball.version}.tgz`;
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
- async #download(tarball, url, npmrcConfig) {
286
- const authHeader = resolveAuthHeader(npmrcConfig, url, this.#env);
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
- ...(authHeader !== undefined ? { authorization: authHeader } : {}),
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
- const status = err?.response?.status;
305
- const statusHint = status !== undefined ? ` (HTTP ${status})` : '';
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
  }