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
@@ -0,0 +1,1107 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import Debug from 'debug';
6
+ import { execa } from 'execa';
7
+ import JSON5 from 'json5';
8
+ import { parse as parseYaml } from 'yaml';
9
+ import { createFauxPackageFiles } from './faux-package.js';
10
+ import { isPnpmfilePath } from './package-files/pnpmfile.js';
11
+ import { lineage } from './package-files/walk.js';
12
+ import { PathLookup } from './package-files/package-manager.js';
13
+ import { pathToPosix } from '../util.js';
14
+ const debug = Debug('checkly:cli:services:check-parser:lockfile-pruner');
15
+ // A legitimate prune reuses resolutions from the lockfile and takes seconds
16
+ // (measured 1-4s even against multi-thousand-package lockfiles); only a
17
+ // stale lockfile behind an unreachable registry runs long, and that path
18
+ // ends in a fallback anyway because the subset check rejects fresh
19
+ // resolutions — so waiting minutes buys nothing.
20
+ const DEFAULT_TIMEOUT_MS = 30_000;
21
+ // The yarn version probe shares the install's budget; if it leaves the
22
+ // install less than this, the prune is abandoned with a provisioning
23
+ // message rather than spawning an install doomed to time out.
24
+ const YARN_PROBE_MIN_INSTALL_BUDGET_MS = 1_000;
25
+ const MAX_FAILURE_DETAIL_LENGTH = 400;
26
+ /**
27
+ * Environment keys that alter the very behavior the prune command pins with
28
+ * explicit flags. Everything else (registry and auth configuration in
29
+ * particular) is passed through.
30
+ */
31
+ const STRIPPED_ENV_KEYS = new Set([
32
+ 'npm_config_frozen_lockfile',
33
+ 'npm_config_lockfile',
34
+ 'npm_config_package_lock',
35
+ 'npm_config_dry_run',
36
+ 'npm_config_ignore_workspace',
37
+ // Redirects the lockfile write outside the temp dir. The explicit
38
+ // --lockfile-dir flag on the pnpm command outranks this anyway; stripped
39
+ // as defense in depth.
40
+ 'npm_config_lockfile_dir',
41
+ ]);
42
+ /**
43
+ * A workspace package's manifest path in the archive, posix-relative to
44
+ * the workspace root — the keying the bundler's file map uses. Exported so
45
+ * the bundler's manifest pruning derives identical keys; the
46
+ * `rewrittenManifests` set is matched against these.
47
+ */
48
+ export function manifestArchivePath(workspace, pkg) {
49
+ return pathToPosix(path.relative(workspace.root.path, pkg.packageJsonPath));
50
+ }
51
+ function unknownVersionReason(pkg) {
52
+ return `the version of workspace package '${pkg.name}' could not be determined`;
53
+ }
54
+ /** The workspace lockfile's archive path, when the workspace has one. */
55
+ export function lockfileArchivePath(workspace) {
56
+ if (!workspace.lockfile.isOk()) {
57
+ return undefined;
58
+ }
59
+ return pathToPosix(path.relative(workspace.root.path, workspace.lockfile.unwrap()));
60
+ }
61
+ /**
62
+ * Decides whether the bundle's lockfile needs pruning at all. Pruning only
63
+ * matters when the bundle differs from the full workspace — when every
64
+ * workspace member's real manifest is in the bundle, the original lockfile
65
+ * already describes the bundle exactly.
66
+ */
67
+ export function shouldPruneLockfile(workspace, files, env = process.env, rewrittenManifests = new Set()) {
68
+ // '0' is the documented spelling; 'false' is a tolerated alias for the
69
+ // common boolean-env habit and must keep working.
70
+ if (env.CHECKLY_LOCKFILE_PRUNE === '0' || env.CHECKLY_LOCKFILE_PRUNE === 'false') {
71
+ return { prune: false, reason: `disabled via CHECKLY_LOCKFILE_PRUNE=${env.CHECKLY_LOCKFILE_PRUNE}` };
72
+ }
73
+ const archivePath = lockfileArchivePath(workspace);
74
+ if (archivePath === undefined) {
75
+ return { prune: false, reason: 'the workspace has no lockfile' };
76
+ }
77
+ const lockfileEntry = files.get(archivePath);
78
+ if (lockfileEntry === undefined) {
79
+ return { prune: false, reason: 'the bundle does not contain the lockfile' };
80
+ }
81
+ let bundleMatchesWorkspace = true;
82
+ for (const pkg of [workspace.root, ...workspace.packages]) {
83
+ const archivePath = manifestArchivePath(workspace, pkg);
84
+ const manifest = files.get(archivePath);
85
+ if (manifest === undefined || !manifest.physical) {
86
+ bundleMatchesWorkspace = false;
87
+ }
88
+ // A faux manifest for a member with an unknown version carries the
89
+ // 0.0.0 fallback, which could make specifiers resolve differently than
90
+ // they did for the user; do not feed it into resolution. A manifest
91
+ // the bundler rewrote from its on-disk original is exempt: it carries
92
+ // the member's real content, version field included (or legitimately
93
+ // absent, as on most workspace roots).
94
+ if (manifest !== undefined
95
+ && !manifest.physical
96
+ && pkg.version === undefined
97
+ && !rewrittenManifests.has(archivePath)) {
98
+ return { prune: false, reason: unknownVersionReason(pkg), notable: true };
99
+ }
100
+ }
101
+ if (bundleMatchesWorkspace) {
102
+ return { prune: false, reason: 'the bundle contains the full workspace' };
103
+ }
104
+ return { prune: true, lockfileArchivePath: archivePath };
105
+ }
106
+ // Deliberately NOT materialized: .yarnrc.yml (and bunfig.toml). Both can
107
+ // hold registry auth secrets and neither is part of the bundle today. For
108
+ // yarn the omission is verified safe for --mode=update-lockfile: Berry
109
+ // lockfiles are registry-agnostic (npm: protocol, content checksums — no
110
+ // URLs to rewrite), locked git entries reuse without approvedGitRepositories,
111
+ // packageExtensions does not change entry serialization, and any resolution
112
+ // that WOULD need registry config is blocked by the child env's network
113
+ // guard and fails closed.
114
+ const MATERIALIZED_BASENAMES = new Set(['package.json', '.npmrc', 'pnpm-workspace.yaml']);
115
+ /**
116
+ * Selects the bundle entries that affect dependency resolution: manifests,
117
+ * package manager configuration, patches and the lockfile itself. This
118
+ * deliberately mirrors what the remote install will see, so the pruned
119
+ * lockfile matches the remote install's inputs.
120
+ */
121
+ export function selectMaterializationEntries(files, lockfileArchivePath) {
122
+ const selected = [];
123
+ for (const [archivePath, file] of files) {
124
+ // The lockfile is always selected — without it the "regeneration" would
125
+ // be a from-scratch registry resolution. (Even for a symlink entry, the
126
+ // materialization copies the file the entry's own path points at.)
127
+ if (archivePath === lockfileArchivePath) {
128
+ selected.push([archivePath, file]);
129
+ continue;
130
+ }
131
+ if (file.physical && file.symlinkTarget !== undefined) {
132
+ continue;
133
+ }
134
+ const segments = archivePath.split('/');
135
+ // Entries can resolve outside the workspace root (a '..' archive path) —
136
+ // never write those into the temp dir. node_modules content and embedded
137
+ // package tarballs play no part in lockfile resolution.
138
+ if (segments.includes('..') || segments.includes('node_modules')) {
139
+ continue;
140
+ }
141
+ if (segments[0] === '.checkly') {
142
+ continue;
143
+ }
144
+ const basename = segments[segments.length - 1];
145
+ if (MATERIALIZED_BASENAMES.has(basename)
146
+ || isPnpmfilePath(basename)
147
+ || basename.endsWith('.patch')) {
148
+ selected.push([archivePath, file]);
149
+ }
150
+ }
151
+ return selected;
152
+ }
153
+ class UnsupportedLockfileFormatError extends Error {
154
+ }
155
+ // Yarn Berry lockfile metadata versions this parser handles, mapped to the
156
+ // yarn major that writes them — the parser allowlist and the
157
+ // generation-mismatch check both derive from this one table so adding a
158
+ // version cannot leave them out of sync. Version 6 is yarn 3; 8 (early
159
+ // yarn 4) and 10 (current) are yarn 4. 6 records dependency specs without
160
+ // the npm: protocol prefix; 8+ record it.
161
+ const YARN_METADATA_VERSION_TO_MAJOR = { 6: 3, 8: 4, 10: 4 };
162
+ const PNPM_DEPENDENCY_GROUPS = ['dependencies', 'devDependencies', 'optionalDependencies'];
163
+ // Manifests can also reference a workspace member through peerDependencies
164
+ // (plugin-style monorepos), even though lockfile importer sections do not
165
+ // have a peer group of their own.
166
+ const MANIFEST_DEPENDENCY_GROUPS = [...PNPM_DEPENDENCY_GROUPS, 'peerDependencies'];
167
+ function parseLockfileSnapshot(content, lockfileName) {
168
+ const snapshot = {
169
+ excludeLinksFromLockfile: false,
170
+ edges: new Map(),
171
+ resolutions: new Map(),
172
+ importers: new Set(),
173
+ };
174
+ if (lockfileName === 'pnpm-lock.yaml') {
175
+ const doc = parseYaml(content);
176
+ if (doc === null || typeof doc !== 'object') {
177
+ throw new UnsupportedLockfileFormatError(`could not parse ${lockfileName}`);
178
+ }
179
+ snapshot.lockfileVersion = doc.lockfileVersion !== undefined ? String(doc.lockfileVersion) : undefined;
180
+ // Fail closed on unknown formats: a future pnpm schema could rename the
181
+ // sections this parser reads, silently emptying every check.
182
+ const pnpmMajor = snapshot.lockfileVersion?.split('.')[0];
183
+ if (pnpmMajor === undefined || !['6', '9'].includes(pnpmMajor)) {
184
+ throw new UnsupportedLockfileFormatError(`unsupported ${lockfileName} version ${snapshot.lockfileVersion}`);
185
+ }
186
+ snapshot.pnpmfileChecksum = typeof doc.pnpmfileChecksum === 'string' ? doc.pnpmfileChecksum : undefined;
187
+ snapshot.excludeLinksFromLockfile = doc.settings?.excludeLinksFromLockfile === true;
188
+ for (const [importer, groups] of Object.entries(doc.importers ?? {})) {
189
+ snapshot.importers.add(importer);
190
+ if (groups === null || typeof groups !== 'object') {
191
+ continue;
192
+ }
193
+ for (const group of PNPM_DEPENDENCY_GROUPS) {
194
+ const entries = groups[group];
195
+ if (entries === null || typeof entries !== 'object') {
196
+ continue;
197
+ }
198
+ for (const [name, entry] of Object.entries(entries)) {
199
+ // v9 entries are `{ specifier, version }` objects; older formats
200
+ // use a plain version string.
201
+ const version = typeof entry === 'string' ? entry : entry?.version;
202
+ if (typeof version !== 'string') {
203
+ continue;
204
+ }
205
+ snapshot.edges.set(`${importer}\0${group}\0${name}`, {
206
+ name,
207
+ isLink: version.startsWith('link:'),
208
+ });
209
+ }
210
+ }
211
+ }
212
+ // Package keys pin exact versions (and peer suffixes), so presence alone
213
+ // is what the subset check needs.
214
+ for (const section of ['packages', 'snapshots']) {
215
+ for (const key of Object.keys(doc[section] ?? {})) {
216
+ snapshot.resolutions.set(`${section}\0${key}`, '');
217
+ }
218
+ }
219
+ return snapshot;
220
+ }
221
+ if (lockfileName === 'package-lock.json') {
222
+ let doc;
223
+ try {
224
+ doc = JSON.parse(content);
225
+ }
226
+ catch {
227
+ throw new UnsupportedLockfileFormatError(`could not parse ${lockfileName}`);
228
+ }
229
+ snapshot.lockfileVersion = doc?.lockfileVersion !== undefined ? String(doc.lockfileVersion) : undefined;
230
+ const packages = doc?.packages;
231
+ // lockfileVersion 1 has no `packages` section; without it neither the
232
+ // link check nor the subset check can see anything. Unknown future
233
+ // versions fail closed for the same reason.
234
+ if (!['2', '3'].includes(snapshot.lockfileVersion ?? '')
235
+ || packages === null || typeof packages !== 'object') {
236
+ throw new UnsupportedLockfileFormatError(`unsupported ${lockfileName} version ${snapshot.lockfileVersion}`);
237
+ }
238
+ for (const [key, entry] of Object.entries(packages)) {
239
+ if (entry === null || typeof entry !== 'object') {
240
+ continue;
241
+ }
242
+ const nodeModulesIndex = key.lastIndexOf('node_modules/');
243
+ if (nodeModulesIndex === -1) {
244
+ // The root project's entry is keyed '' in package-lock.json;
245
+ // normalize to '.' so the importer-preservation check treats it
246
+ // like pnpm's root importer.
247
+ snapshot.importers.add(key === '' ? '.' : key);
248
+ continue;
249
+ }
250
+ // Covers nested installs too (`packages/a/node_modules/foo`), which
251
+ // npm emits when a member's version conflicts with a hoisted one.
252
+ const name = key.slice(nodeModulesIndex + 'node_modules/'.length);
253
+ snapshot.edges.set(key, {
254
+ name,
255
+ isLink: entry.link === true,
256
+ });
257
+ if (entry.link !== true) {
258
+ snapshot.resolutions.set(key, String(entry.version ?? entry.resolved ?? ''));
259
+ }
260
+ }
261
+ return snapshot;
262
+ }
263
+ if (lockfileName === 'bun.lock') {
264
+ // bun.lock is JSONC (bun writes trailing commas), hence JSON5.
265
+ let doc;
266
+ try {
267
+ doc = JSON5.parse(content);
268
+ }
269
+ catch {
270
+ throw new UnsupportedLockfileFormatError(`could not parse ${lockfileName}`);
271
+ }
272
+ const version = doc?.lockfileVersion !== undefined ? String(doc.lockfileVersion) : undefined;
273
+ const workspaces = doc?.workspaces;
274
+ const packages = doc?.packages;
275
+ // Fail closed on unknown formats, like the parsers above.
276
+ if (version !== '1'
277
+ || workspaces === null || typeof workspaces !== 'object'
278
+ || packages === null || typeof packages !== 'object') {
279
+ throw new UnsupportedLockfileFormatError(`unsupported ${lockfileName} version ${version}`);
280
+ }
281
+ // configVersion is folded into the version so the verification step also
282
+ // catches a regeneration that changed it. Bun preserves an existing value
283
+ // and treats an absent one as 0 rather than upgrading it, so the fold is
284
+ // stable for lockfiles written by older bun versions too. The format is
285
+ // self-describing because the value surfaces verbatim in the "lockfile
286
+ // version changed" failure reason.
287
+ snapshot.lockfileVersion = `${version} (configVersion ${doc.configVersion ?? 0})`;
288
+ // A dependency edge resolves to a workspace link if either its spec says
289
+ // so or the package entry it resolves to is a workspace tuple; the latter
290
+ // covers bare semver specs that bun resolved to a workspace member. The
291
+ // entry must be resolved per edge — member-scoped key first, hoisted key
292
+ // second, as in parseBunLockfileVersion — because a workspace member's
293
+ // name may also be consumed from the registry by a different importer,
294
+ // and a name-global answer would misclassify one of the two edges.
295
+ const resolvesToWorkspace = (memberName, depName) => {
296
+ const keys = typeof memberName === 'string' && memberName !== ''
297
+ ? [`${memberName}/${depName}`, depName]
298
+ : [depName];
299
+ for (const key of keys) {
300
+ const tuple = packages[key];
301
+ if (Array.isArray(tuple) && typeof tuple[0] === 'string') {
302
+ return tuple[0].includes('@workspace:');
303
+ }
304
+ }
305
+ return false;
306
+ };
307
+ for (const [dir, entry] of Object.entries(workspaces)) {
308
+ // The root importer is keyed '' in bun.lock; normalize to '.' so the
309
+ // importer-preservation check treats it like pnpm's root importer.
310
+ const importer = dir === '' ? '.' : dir;
311
+ snapshot.importers.add(importer);
312
+ if (entry === null || typeof entry !== 'object') {
313
+ continue;
314
+ }
315
+ // Unlike pnpm importers, bun workspace entries mirror all four manifest
316
+ // dependency groups, peerDependencies included.
317
+ for (const group of MANIFEST_DEPENDENCY_GROUPS) {
318
+ const entries = entry[group];
319
+ if (entries === null || typeof entries !== 'object') {
320
+ continue;
321
+ }
322
+ for (const [name, spec] of Object.entries(entries)) {
323
+ if (typeof spec !== 'string') {
324
+ continue;
325
+ }
326
+ snapshot.edges.set(`${importer}\0${group}\0${name}`, {
327
+ name,
328
+ isLink: spec.startsWith('workspace:')
329
+ || spec.startsWith('link:')
330
+ || resolvesToWorkspace(entry.name, name),
331
+ });
332
+ }
333
+ }
334
+ }
335
+ // Package values are resolution tuples (name@version, then registry URL,
336
+ // dependencies and integrity in a kind-dependent arity). Key the subset
337
+ // check by the whole serialized tuple rather than by the lockfile key:
338
+ // pruning the member that owns a hoisted key re-keys the surviving
339
+ // member-scoped entry (e.g. `b/ms` becomes `ms`) with an unchanged tuple,
340
+ // which a key-based check would falsely reject — while any change WITHIN
341
+ // a tuple (a registry rewrite of the tarball URL, a version bump) must
342
+ // still fail the check. Serialization is stable because both sides are
343
+ // parsed from bun's own deterministic output by this same function.
344
+ for (const tuple of Object.values(packages)) {
345
+ snapshot.resolutions.set(JSON.stringify(tuple), '');
346
+ }
347
+ return snapshot;
348
+ }
349
+ if (lockfileName === 'yarn.lock') {
350
+ // Yarn Classic (v1) files must be recognized BEFORE the YAML parse:
351
+ // realistic Classic lockfiles do not parse as YAML at all (an entry
352
+ // with a nested `dependencies:` block mixes plain scalars and a
353
+ // mapping, which the parser rejects), so without the header check a
354
+ // Classic user would get a "could not parse" message implying a broken
355
+ // lockfile. Every yarn-1-generated lockfile carries this header.
356
+ if (/^# yarn lockfile v1$/m.test(content)) {
357
+ throw new UnsupportedLockfileFormatError(`${lockfileName} is a Yarn Classic (v1) lockfile, which is not supported`);
358
+ }
359
+ let doc;
360
+ try {
361
+ // The failsafe schema keeps every scalar a string: yarn 3 writes
362
+ // bare numeric ranges unquoted (`two: 2`), which the default schema
363
+ // would coerce to numbers — dropping those edges (and losing `1.0`
364
+ // as written, so String() could not undo it).
365
+ doc = parseYaml(content, { schema: 'failsafe' });
366
+ }
367
+ catch {
368
+ throw new UnsupportedLockfileFormatError(`could not parse ${lockfileName}`);
369
+ }
370
+ if (doc === null || typeof doc !== 'object') {
371
+ throw new UnsupportedLockfileFormatError(`could not parse ${lockfileName}`);
372
+ }
373
+ const metadata = doc.__metadata;
374
+ if (metadata === null || typeof metadata !== 'object' || metadata.version === undefined) {
375
+ throw new UnsupportedLockfileFormatError(`${lockfileName} is not a Yarn Berry lockfile (Yarn Classic lockfiles are not supported)`);
376
+ }
377
+ // Fail closed on unknown metadata versions, like the parsers above
378
+ // (see YARN_METADATA_VERSION_TO_MAJOR).
379
+ const version = String(metadata.version);
380
+ // hasOwnProperty, not `in`: a corrupted lockfile whose version equals an
381
+ // Object.prototype key ('toString', '__proto__') must still fail closed.
382
+ if (!Object.prototype.hasOwnProperty.call(YARN_METADATA_VERSION_TO_MAJOR, version)) {
383
+ throw new UnsupportedLockfileFormatError(`unsupported ${lockfileName} metadata version ${version}`);
384
+ }
385
+ snapshot.lockfileVersion = version;
386
+ // The cacheKey names the checksum scheme; a regeneration under a
387
+ // different scheme must fail verification. Compared as its own field —
388
+ // not folded into the version — because yarn 3 omits cacheKey entirely
389
+ // when a lockfile resolves no registry packages, so a prune that
390
+ // removes the last registry entry legitimately goes from "cacheKey: 8"
391
+ // to no cacheKey at all.
392
+ snapshot.cacheKey = metadata.cacheKey !== undefined ? String(metadata.cacheKey) : undefined;
393
+ // First pass: validate the entry shape and collect the descriptors (the
394
+ // comma-joined parts of each entry key) that name workspace entries, so
395
+ // edges can be classified per descriptor below. Splitting on ', ' is
396
+ // safe: npm semver ranges cannot contain a comma, and yarn itself joins
397
+ // descriptor lists with this exact separator.
398
+ const workspaceDescriptors = new Set();
399
+ const entries = [];
400
+ for (const [key, entry] of Object.entries(doc)) {
401
+ if (key === '__metadata') {
402
+ continue;
403
+ }
404
+ if (entry === null || typeof entry !== 'object' || typeof entry.resolution !== 'string') {
405
+ throw new UnsupportedLockfileFormatError(`unsupported ${lockfileName} entry shape for '${key}'`);
406
+ }
407
+ entries.push([key, entry]);
408
+ if (entry.resolution.includes('@workspace:')) {
409
+ for (const descriptor of key.split(', ')) {
410
+ workspaceDescriptors.add(descriptor);
411
+ }
412
+ }
413
+ }
414
+ // A regular dependency edge resolves to a workspace link if either its
415
+ // spec says so or its descriptor is one the lockfile keys a workspace
416
+ // entry under; the latter covers bare semver specs that yarn resolved
417
+ // to a workspace member (the member's entry is then keyed under both
418
+ // the range descriptor and the workspace descriptor). Classified per
419
+ // descriptor, because a member's name may also be consumed from the
420
+ // registry by a different importer. The spec is probed as written —
421
+ // metadata version 6 records `^1.0.0` where 8+ record `npm:^1.0.0`,
422
+ // and the keys follow the same convention, so no prefix juggling is
423
+ // needed. Peer edges are deliberately NEVER probed: a peer only shares
424
+ // a descriptor with some other importer's real dependency, and pruning
425
+ // that importer away legitimately removes the descriptor — probing
426
+ // would then classify the surviving peer edge as a link that
427
+ // "degraded", failing a correct prune. Peers are never resolved on
428
+ // their own (the consumer's ancestors provide them), so there is no
429
+ // silent-substitution channel to catch either; a `workspace:` peer
430
+ // spec still counts as a link via its prefix.
431
+ const resolvesToWorkspace = (name, spec) => {
432
+ return workspaceDescriptors.has(`${name}@${spec}`);
433
+ };
434
+ for (const [, entry] of entries) {
435
+ const resolution = entry.resolution;
436
+ // lastIndexOf, not a simple split: scoped names contain '@'.
437
+ const workspaceMarker = resolution.lastIndexOf('@workspace:');
438
+ if (workspaceMarker === -1) {
439
+ // Non-workspace entries (registry, git, patch, portal, ...) feed the
440
+ // subset check. Key it by the whole serialized entry rather than the
441
+ // lockfile key: pruning a consumer shrinks a multi-descriptor key
442
+ // (e.g. "b@npm:^1.0.0, b@workspace:packages/b" loses its npm range)
443
+ // with an unchanged value, which a key-based check would falsely
444
+ // reject — while any change WITHIN an entry (version, checksum,
445
+ // dependencies) must still fail the check. Serialization is stable
446
+ // because both sides are parsed from yarn's own deterministic
447
+ // output by this same function.
448
+ snapshot.resolutions.set(JSON.stringify(entry), '');
449
+ continue;
450
+ }
451
+ // Workspace entries are the importers: their resolution carries the
452
+ // member directory ('.' for the root), and their dependencies maps
453
+ // carry the importer's edges — devDependencies and
454
+ // optionalDependencies are merged into `dependencies` by yarn, and
455
+ // peerDependencies stays its own group. Their content changes when a
456
+ // member is shimmed, which is exactly what pruning does, so they must
457
+ // NOT feed the subset check above.
458
+ const importer = resolution.slice(workspaceMarker + '@workspace:'.length);
459
+ snapshot.importers.add(importer);
460
+ for (const group of ['dependencies', 'peerDependencies']) {
461
+ const dependencies = entry[group];
462
+ if (dependencies === null || typeof dependencies !== 'object') {
463
+ continue;
464
+ }
465
+ for (const [name, spec] of Object.entries(dependencies)) {
466
+ if (typeof spec !== 'string') {
467
+ continue;
468
+ }
469
+ snapshot.edges.set(`${importer}\0${group}\0${name}`, {
470
+ name,
471
+ isLink: spec.startsWith('workspace:')
472
+ || spec.startsWith('link:')
473
+ || spec.startsWith('portal:')
474
+ // Descriptor probing is for regular dependencies only — see
475
+ // resolvesToWorkspace above for why peers must not probe.
476
+ || (group === 'dependencies' && resolvesToWorkspace(name, spec)),
477
+ });
478
+ }
479
+ }
480
+ }
481
+ return snapshot;
482
+ }
483
+ if (lockfileName === 'bun.lockb') {
484
+ throw new UnsupportedLockfileFormatError('the binary bun.lockb format is not supported;'
485
+ + ' regenerate a text lockfile with `bun install --save-text-lockfile`');
486
+ }
487
+ throw new UnsupportedLockfileFormatError(`unsupported lockfile ${lockfileName}`);
488
+ }
489
+ /**
490
+ * Verifies that the regenerated lockfile is a pruned copy of the original
491
+ * rather than a (partial) re-resolution. Returns a failure reason, or
492
+ * undefined when everything checks out.
493
+ */
494
+ function verifyPrunedLockfile(original, regenerated, files) {
495
+ // A changed lockfile format version means the package manager rewrote the
496
+ // file wholesale (e.g. a newer pnpm "upgrading" an old lockfile), which is
497
+ // a full re-resolution.
498
+ if (original.lockfileVersion !== regenerated.lockfileVersion) {
499
+ return `the lockfile version changed from ${original.lockfileVersion} to ${regenerated.lockfileVersion}`;
500
+ }
501
+ // A changed yarn checksum scheme means every checksum was rewritten —
502
+ // a wholesale regeneration, not a prune. Only compared when both sides
503
+ // record one: yarn 3 omits the cacheKey when a lockfile resolves no
504
+ // registry packages, which a prune can legitimately arrive at.
505
+ if (original.cacheKey !== undefined && regenerated.cacheKey !== undefined
506
+ && original.cacheKey !== regenerated.cacheKey) {
507
+ return `the lockfile cacheKey changed from ${original.cacheKey} to ${regenerated.cacheKey}`;
508
+ }
509
+ // Any change to the recorded pnpmfile checksum means the resolve ran with
510
+ // different pnpm hooks than the user's own install.
511
+ if (original.pnpmfileChecksum !== regenerated.pnpmfileChecksum) {
512
+ return 'the regenerated lockfile records a different pnpmfile checksum than the original';
513
+ }
514
+ // Pruning only removes: every resolution in the regenerated lockfile must
515
+ // already exist in the original. A new or changed resolution means the
516
+ // lockfile was out of date with the bundled manifests and the package
517
+ // manager resolved something fresh from the registry — versions the user
518
+ // never installed or tested with.
519
+ for (const [key, version] of regenerated.resolutions) {
520
+ if (original.resolutions.get(key) !== version) {
521
+ return `the regenerated lockfile resolves entries not present in the original `
522
+ + `(is the lockfile out of date with package.json?)`;
523
+ }
524
+ }
525
+ // Every dependency edge that was a workspace link and that still exists
526
+ // must still be a link. Catches npm's silent registry substitution (a
527
+ // member whose version does not satisfy a range is fetched from the
528
+ // registry with exit code 0).
529
+ for (const [key, edge] of original.edges) {
530
+ if (!edge.isLink) {
531
+ continue;
532
+ }
533
+ const after = regenerated.edges.get(key);
534
+ if (after !== undefined && !after.isLink) {
535
+ return `'${edge.name}' is no longer a workspace link`;
536
+ }
537
+ }
538
+ // Importers may only disappear for members absent from the bundle. Losing
539
+ // an importer whose manifest IS bundled would make the remote (frozen)
540
+ // install see an importer the lockfile lacks.
541
+ for (const importer of original.importers) {
542
+ if (regenerated.importers.has(importer)) {
543
+ continue;
544
+ }
545
+ const manifestPath = importer === '.' ? 'package.json' : `${importer}/package.json`;
546
+ if (files.has(manifestPath)) {
547
+ return `the regenerated lockfile lost the importer '${importer}' whose manifest is bundled`;
548
+ }
549
+ }
550
+ }
551
+ /**
552
+ * Synthesizes faux manifests for workspace members that selected manifests
553
+ * reference as links (via the `workspace:` protocol, or resolved as links in
554
+ * the original lockfile) but that have no manifest among the selected
555
+ * entries. Without these the temp-dir resolve would fail (pnpm) or silently
556
+ * resolve the member from the registry (npm) — and, crucially, the same
557
+ * would happen during the remote install, so the caller must also register
558
+ * the returned manifests into the bundle.
559
+ */
560
+ async function collectBackfilledManifests(workspace, selected, original) {
561
+ const linkedNames = new Set();
562
+ for (const edge of original.edges.values()) {
563
+ if (edge.isLink) {
564
+ linkedNames.add(edge.name);
565
+ }
566
+ }
567
+ const manifestEntries = selected.filter(([archivePath]) => path.posix.basename(archivePath) === 'package.json');
568
+ const presentManifestPaths = new Set(manifestEntries.map(([archivePath]) => archivePath));
569
+ const backfilled = new Map();
570
+ for (const [, file] of manifestEntries) {
571
+ let manifest;
572
+ try {
573
+ const content = file.physical
574
+ ? await fs.readFile(file.filePath, 'utf8')
575
+ : file.content;
576
+ manifest = JSON.parse(content);
577
+ }
578
+ catch {
579
+ continue;
580
+ }
581
+ for (const group of MANIFEST_DEPENDENCY_GROUPS) {
582
+ const entries = manifest?.[group];
583
+ if (entries === null || typeof entries !== 'object') {
584
+ continue;
585
+ }
586
+ for (const [name, spec] of Object.entries(entries)) {
587
+ const member = workspace.memberByName(name);
588
+ if (member === undefined || member === workspace.root) {
589
+ continue;
590
+ }
591
+ // Optional peers (peerDependenciesMeta.optional) are deliberately NOT
592
+ // exempted: pnpm resolves a `workspace:` peer spec regardless of the
593
+ // optional flag when auto-install-peers is on (the default), so a
594
+ // missing manifest fails the install outright.
595
+ const isLink = (typeof spec === 'string' && spec.startsWith('workspace:'))
596
+ || linkedNames.has(name);
597
+ if (!isLink) {
598
+ continue;
599
+ }
600
+ const memberManifestPath = manifestArchivePath(workspace, member);
601
+ if (presentManifestPaths.has(memberManifestPath) || backfilled.has(memberManifestPath)) {
602
+ continue;
603
+ }
604
+ // Same rule as in shouldPruneLockfile: never feed the 0.0.0 fallback
605
+ // version into resolution.
606
+ if (member.version === undefined) {
607
+ return { skipReason: unknownVersionReason(member) };
608
+ }
609
+ for (const fauxFile of createFauxPackageFiles(member)) {
610
+ backfilled.set(pathToPosix(path.relative(workspace.root.path, fauxFile.filePath)), fauxFile);
611
+ }
612
+ }
613
+ }
614
+ }
615
+ return { manifests: backfilled };
616
+ }
617
+ function buildChildEnv(baseEnv) {
618
+ const env = {};
619
+ for (const [key, value] of Object.entries(baseEnv)) {
620
+ if (STRIPPED_ENV_KEYS.has(key.toLowerCase())) {
621
+ continue;
622
+ }
623
+ env[key] = value;
624
+ }
625
+ // The temp project's root package.json may pin a different package manager
626
+ // than the one being invoked; relax corepack's mismatch error (this does
627
+ // not affect corepack's pinned-version resolution).
628
+ env.COREPACK_ENABLE_STRICT = '0';
629
+ // A legitimate yarn prune needs no network at all (verified even with a
630
+ // cold cache: the fetch step only touches entries that are NEW, which a
631
+ // prune never introduces) — but a lockfile that is out of date with a
632
+ // manifest would make yarn resolve the missing descriptor against its
633
+ // configured registry, and since the project's .yarnrc.yml is not
634
+ // materialized (see MATERIALIZED_BASENAMES) that is the PUBLIC registry:
635
+ // the request would disclose the (possibly private) package name before
636
+ // the subset verification could reject the result. Disabling the network
637
+ // makes that case fail fast with yarn's own blocked-request error
638
+ // instead. Only Yarn Berry reads this variable, and every Berry
639
+ // generation accepts it (unlike YARN_ENABLE_HARDENED_MODE, which is set
640
+ // per-run once the yarn generation is known).
641
+ env.YARN_ENABLE_NETWORK = '0';
642
+ // Yarn honors a .yarnrc.yml (Berry) or .yarnrc (Classic) found in ANY
643
+ // ancestor of its working directory — for the prune temp dir that means
644
+ // the system temp root and everything above it, none of which this
645
+ // process controls (on shared hosts /tmp is world-writable). An ancestor
646
+ // rc can redirect the lockfile write (lockfileFilename), re-enable what
647
+ // the variables above disable, or worst of all execute an arbitrary
648
+ // script via yarnPath/yarn-path — during the version probe already. Two
649
+ // independent guards, because one alone is insufficient:
650
+ // - YARN_IGNORE_PATH neutralizes yarnPath/yarn-path specifically, and
651
+ // is the ONLY mechanism that covers Yarn Classic (which ignores
652
+ // YARN_RC_FILENAME and has no env-settable rc path). Verified to
653
+ // disable the exploit on 1.22.22, 3.8.7 and 4.18.0.
654
+ // - YARN_RC_FILENAME points Berry's rc lookup at a per-invocation
655
+ // random name so no ancestor rc is read at all (blocking
656
+ // lockfileFilename etc., not just yarnPath). It must be random: a
657
+ // fixed name is a compile-time constant an attacker can pre-create
658
+ // to re-open the channel.
659
+ env.YARN_IGNORE_PATH = '1';
660
+ env.YARN_RC_FILENAME = `.checkly-lockfile-prune-no-rc-${randomUUID()}.yml`;
661
+ // The link step (where lifecycle scripts run) is already skipped by
662
+ // --mode=update-lockfile; disabling scripts outright is defense in
663
+ // depth, and enableScripts exists in every Berry generation.
664
+ env.YARN_ENABLE_SCRIPTS = '0';
665
+ return env;
666
+ }
667
+ function sanitizeDetail(detail) {
668
+ // Package manager output can embed registry URLs with userinfo credentials
669
+ // (pnpm does not redact them); scrub before surfacing anywhere.
670
+ const redacted = detail.replace(/\/\/[^/@\s]+@/g, '//').trim();
671
+ if (redacted.length <= MAX_FAILURE_DETAIL_LENGTH) {
672
+ return redacted;
673
+ }
674
+ return `${redacted.slice(0, MAX_FAILURE_DETAIL_LENGTH)}…`;
675
+ }
676
+ // Larger files are not plausible manifests; the cap also keeps a scan of a
677
+ // shared temp root from slurping an arbitrarily large unrelated file.
678
+ const MAX_ANCESTOR_MANIFEST_BYTES = 4 * 1024 * 1024;
679
+ async function directoryExists(dir) {
680
+ try {
681
+ await fs.access(dir);
682
+ return true;
683
+ }
684
+ catch {
685
+ return false;
686
+ }
687
+ }
688
+ // Shared between the spawn-ENOENT and lockfile-read-ENOENT branches: both
689
+ // must rule out a reaped temp dir before attributing the ENOENT to anything
690
+ // more specific.
691
+ const TEMP_DIR_VANISHED = {
692
+ status: 'failed',
693
+ reason: 'the temp directory disappeared while the command ran',
694
+ };
695
+ // A missing package manager binary is a real situation for bun, whose
696
+ // detection needs only a committed bun.lock. Pruning was never attempted,
697
+ // so it is a notable skip, not a failure whose message would suggest the
698
+ // lockfile is broken — and installing the package manager, not disabling
699
+ // pruning, is the fix.
700
+ function executableMissing(executable) {
701
+ return {
702
+ status: 'skipped',
703
+ reason: `${executable} is not installed or not on PATH; install it so the lockfile can be pruned`,
704
+ notable: true,
705
+ };
706
+ }
707
+ /**
708
+ * Walks from `startDir` to the filesystem root looking for a package.json
709
+ * that declares npm workspaces. The caller treats any hit as "this location
710
+ * is not a safe sandbox", so the scan errs toward matching: manifests are
711
+ * parsed with the same leniency bun's own package.json parser has (JSONC —
712
+ * comments and trailing commas, which strict JSON.parse rejects), and a
713
+ * manifest that exists but cannot be parsed even then counts as a hit
714
+ * (`parseable: false`), since bun's parser might still accept it.
715
+ */
716
+ async function findWorkspaceAncestor(startDir) {
717
+ for (const dir of lineage(startDir)) {
718
+ const manifestPath = path.join(dir, 'package.json');
719
+ let raw;
720
+ try {
721
+ // Shared temp roots can hold arbitrary files under this name; stat
722
+ // first so a FIFO can't hang the read and an oversized file isn't
723
+ // slurped.
724
+ const stats = await fs.stat(manifestPath);
725
+ if (!stats.isFile() || stats.size > MAX_ANCESTOR_MANIFEST_BYTES) {
726
+ continue;
727
+ }
728
+ raw = await fs.readFile(manifestPath, 'utf8');
729
+ }
730
+ catch {
731
+ // No manifest here — keep walking.
732
+ continue;
733
+ }
734
+ try {
735
+ const manifest = JSON5.parse(raw);
736
+ if (manifest !== null && typeof manifest === 'object' && 'workspaces' in manifest) {
737
+ return { dir, parseable: true };
738
+ }
739
+ }
740
+ catch {
741
+ return { dir, parseable: false };
742
+ }
743
+ }
744
+ return undefined;
745
+ }
746
+ /**
747
+ * Regenerates the bundle's lockfile so it matches the bundle's actual set of
748
+ * manifests, by materializing the resolution-relevant bundle entries into a
749
+ * temp directory and running the package manager's lockfile-only install.
750
+ *
751
+ * Returns `skipped` when pruning is unnecessary or unsupported, and `failed`
752
+ * when the caller should fall back to the original lockfile.
753
+ */
754
+ export async function pruneBundledLockfile(options) {
755
+ const { workspace, packageManager, files, rewrittenManifests = new Set(), timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env, } = options;
756
+ const decision = shouldPruneLockfile(workspace, files, env, rewrittenManifests);
757
+ if (!decision.prune) {
758
+ return { status: 'skipped', reason: decision.reason, notable: decision.notable };
759
+ }
760
+ // Every pre-run skip below this point is notable: shouldPruneLockfile has
761
+ // already established that the bundle is a partial workspace, so the
762
+ // lockfile over-describes the bundle and this setup cannot be helped.
763
+ // (The one post-run skip — a byte-identical regeneration — is the
764
+ // opposite: pruning ran and proved there was nothing to change.)
765
+ // This capability check must stay ahead of the lockfile read below: an
766
+ // unsupported package manager should always skip notably, never surface
767
+ // a lockfile read error as a 'failed' warning that implies pruning was
768
+ // attempted.
769
+ const runnable = packageManager.lockfileOnlyInstallCommand();
770
+ if (runnable === undefined) {
771
+ return {
772
+ status: 'skipped',
773
+ reason: `${packageManager.name} has no supported lockfile-only install`,
774
+ notable: true,
775
+ };
776
+ }
777
+ const lockfileName = path.posix.basename(decision.lockfileArchivePath);
778
+ let originalContent;
779
+ try {
780
+ originalContent = await fs.readFile(workspace.lockfile.unwrap(), 'utf8');
781
+ }
782
+ catch (err) {
783
+ return { status: 'failed', reason: `could not read the lockfile: ${err.message}` };
784
+ }
785
+ let original;
786
+ try {
787
+ original = parseLockfileSnapshot(originalContent, lockfileName);
788
+ }
789
+ catch (err) {
790
+ return { status: 'skipped', reason: err.message, notable: true };
791
+ }
792
+ if (original.excludeLinksFromLockfile) {
793
+ // Without link entries in the lockfile, neither the backfill nor the
794
+ // link-preservation check can see workspace links.
795
+ return {
796
+ status: 'skipped',
797
+ reason: 'the lockfile is written with excludeLinksFromLockfile',
798
+ notable: true,
799
+ };
800
+ }
801
+ const selected = selectMaterializationEntries(files, decision.lockfileArchivePath);
802
+ if (original.pnpmfileChecksum !== undefined) {
803
+ const hasPnpmfile = selected.some(([archivePath]) => isPnpmfilePath(archivePath));
804
+ if (!hasPnpmfile) {
805
+ return {
806
+ status: 'skipped',
807
+ reason: 'the lockfile records a pnpmfile checksum but no pnpmfile is bundled',
808
+ notable: true,
809
+ };
810
+ }
811
+ }
812
+ const backfill = await collectBackfilledManifests(workspace, selected, original);
813
+ if ('skipReason' in backfill) {
814
+ return { status: 'skipped', reason: backfill.skipReason, notable: true };
815
+ }
816
+ let tempDir;
817
+ try {
818
+ // Assign before the realpath call so a realpath failure cannot leak the
819
+ // freshly created directory.
820
+ tempDir = await fs.mkdtemp(path.join(tmpdir(), 'checkly-lockfile-prune-'));
821
+ tempDir = await fs.realpath(tempDir);
822
+ // Bun re-roots at an ancestor directory whose package.json declares
823
+ // workspaces with a glob matching the working directory — and then
824
+ // resolves against THAT root and writes the regenerated lockfile there,
825
+ // outside this sandbox, over a real file. This can only happen when the
826
+ // system temp dir itself sits inside a workspace (e.g. TMPDIR pointing
827
+ // into a repo), so refuse to run rather than risk it. pnpm pins the
828
+ // write with --lockfile-dir and anchors at the materialized
829
+ // pnpm-workspace.yaml, and npm does not re-root, so only bun needs the
830
+ // guard.
831
+ if (packageManager.name === 'bun') {
832
+ const ancestor = await findWorkspaceAncestor(path.dirname(tempDir));
833
+ if (ancestor !== undefined) {
834
+ return {
835
+ status: 'skipped',
836
+ reason: (ancestor.parseable
837
+ ? `the temp directory is inside the npm workspace at '${ancestor.dir}'`
838
+ : `an unparseable package.json at '${ancestor.dir}' could not be ruled out as a workspace root`)
839
+ + '; point TMPDIR (TEMP/TMP on Windows) outside any workspace to enable pruning',
840
+ notable: true,
841
+ };
842
+ }
843
+ }
844
+ const entries = [
845
+ ...selected,
846
+ ...backfill.manifests,
847
+ ];
848
+ for (const [archiveRelativePath, file] of entries) {
849
+ const target = path.join(tempDir, ...archiveRelativePath.split('/'));
850
+ // Defense in depth alongside the '..' filter in
851
+ // selectMaterializationEntries: never write outside the temp dir.
852
+ // (A plain startsWith('..') would also reject a directory that merely
853
+ // begins with two dots, e.g. '..artifacts'.)
854
+ const relative = path.relative(tempDir, target);
855
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
856
+ continue;
857
+ }
858
+ await fs.mkdir(path.dirname(target), { recursive: true });
859
+ if (file.physical) {
860
+ await fs.copyFile(file.filePath, target);
861
+ }
862
+ else {
863
+ await fs.writeFile(target, file.content);
864
+ }
865
+ }
866
+ // Guard against the selection or the write guards having dropped the
867
+ // lockfile — running without one would be a from-scratch registry
868
+ // resolution, not a prune.
869
+ try {
870
+ await fs.access(path.join(tempDir, lockfileName));
871
+ }
872
+ catch {
873
+ return { status: 'failed', reason: 'the lockfile could not be materialized' };
874
+ }
875
+ // Yarn Classic must be stopped BEFORE the install is spawned: verified
876
+ // on yarn 1.22.22 that it silently ignores --mode=update-lockfile and
877
+ // performs a FULL install — fresh registry resolution, node_modules in
878
+ // the temp dir, dependency lifecycle scripts — with exit 0, then writes
879
+ // a v1 lockfile that fails verification with misleading advice. Classic
880
+ // is what a plain `yarn` resolves to when a Berry project pins its
881
+ // version via yarnPath (which lives in the unbundled .yarnrc.yml)
882
+ // rather than the packageManager field. Only a positive 1.x match
883
+ // skips: a failing or unparseable probe falls through to the install,
884
+ // whose own error carries the real detail — so a probe hiccup can
885
+ // never block a working prune, mirroring the post-hoc PATH probe
886
+ // below. Probe and install share the executable, cwd and env, so their
887
+ // version resolution cannot diverge.
888
+ const childEnv = buildChildEnv(env);
889
+ // The probe shares the install's time budget so the yarn path cannot
890
+ // block for longer than the documented timeout in total (a stalled
891
+ // first-use corepack download would otherwise be paid twice).
892
+ // timeoutMs === 0 means "no timeout" to execa, so the whole budget
893
+ // dance is skipped in that case.
894
+ let installTimeoutMs = timeoutMs;
895
+ if (packageManager.name === 'yarn') {
896
+ // A whole budget below the install floor can never succeed (the yarn
897
+ // path spends a probe plus an install), so reject it up front —
898
+ // before the probe, whose own outcome under such a budget would be a
899
+ // misleading "timed out" rather than this caller-misconfiguration.
900
+ if (timeoutMs > 0 && timeoutMs < YARN_PROBE_MIN_INSTALL_BUDGET_MS) {
901
+ return {
902
+ status: 'failed',
903
+ reason: `the prune timeout (${timeoutMs}ms) is below the minimum needed to run yarn`,
904
+ };
905
+ }
906
+ const probeStartedAt = Date.now();
907
+ const probe = await execa(runnable.executable, ['--version'], {
908
+ cwd: tempDir,
909
+ env: childEnv,
910
+ extendEnv: false,
911
+ timeout: timeoutMs,
912
+ reject: false,
913
+ });
914
+ if (probe.timedOut) {
915
+ // The probe consumed the whole budget; spawning the install with
916
+ // the ~zero remainder would only produce a confusing second kill.
917
+ return { status: 'failed', reason: `${runnable.executable} timed out after ${timeoutMs}ms` };
918
+ }
919
+ if (timeoutMs > 0) {
920
+ const remaining = timeoutMs - (Date.now() - probeStartedAt);
921
+ if (remaining < YARN_PROBE_MIN_INSTALL_BUDGET_MS) {
922
+ // The probe (typically a slow first-use corepack toolchain
923
+ // download) left too little for the install; a 1 ms install would
924
+ // be a misleading second timeout, so say what actually happened.
925
+ return {
926
+ status: 'failed',
927
+ reason: 'provisioning the yarn toolchain used up the prune time budget before the'
928
+ + ' lockfile could be regenerated; pre-install yarn or raise the timeout',
929
+ };
930
+ }
931
+ installTimeoutMs = remaining;
932
+ }
933
+ const probeVersion = probe.failed ? '' : probe.stdout?.trim() ?? '';
934
+ const major = Number.parseInt(probeVersion, 10);
935
+ if (major === 1) {
936
+ return {
937
+ status: 'skipped',
938
+ reason: 'yarn resolves to Yarn Classic (1.x) here, which cannot regenerate'
939
+ + ' a Yarn Berry lockfile; set the packageManager field in package.json'
940
+ + ' and enable Corepack so a Yarn 2+ binary runs instead',
941
+ notable: true,
942
+ };
943
+ }
944
+ // Yarn only REUSES a lockfile written by its own generation — handed
945
+ // an older one it re-resolves everything, which the network guard
946
+ // blocks (verified: yarn 4.18 re-resolves both v6 and v8 lockfiles).
947
+ // A cross-generation mismatch would therefore fail with a message
948
+ // about blocked registry requests; skip with the actual remedies
949
+ // instead. The parser only accepts versions in the table, so the
950
+ // lookup is always defined here.
951
+ const requiredMajor = YARN_METADATA_VERSION_TO_MAJOR[original.lockfileVersion ?? ''];
952
+ if (major >= 2 && major !== requiredMajor) {
953
+ return {
954
+ status: 'skipped',
955
+ reason: `the lockfile was written by yarn ${requiredMajor} (metadata version`
956
+ + ` ${original.lockfileVersion}) but yarn resolves to ${sanitizeDetail(probeVersion)} here;`
957
+ + ' run your own install to migrate the lockfile, or pin the matching yarn'
958
+ + ' version via the packageManager field in package.json so Corepack provisions it',
959
+ notable: true,
960
+ };
961
+ }
962
+ if (major >= 4) {
963
+ // Hardened mode revalidates locked entries against the registry —
964
+ // yarn 4 enables it automatically on pull-request CI, and with the
965
+ // network guard above that would fail every prune there. Only set
966
+ // for a CONFIRMED yarn 4+: the setting does not exist before yarn
967
+ // 4, which rejects unknown environment settings with a usage error
968
+ // (verified on 3.8.7). An unidentified yarn proceeds without it —
969
+ // worst case a hardened-mode prune fails closed with a warning.
970
+ childEnv.YARN_ENABLE_HARDENED_MODE = '0';
971
+ }
972
+ }
973
+ debug(`Running ${runnable.unsafeDisplayCommand} in ${tempDir}`);
974
+ const result = await execa(runnable.executable, runnable.args, {
975
+ cwd: tempDir,
976
+ env: childEnv,
977
+ extendEnv: false,
978
+ timeout: installTimeoutMs,
979
+ reject: false,
980
+ });
981
+ if (result.timedOut) {
982
+ return { status: 'failed', reason: `${runnable.executable} timed out after ${installTimeoutMs}ms` };
983
+ }
984
+ if (result.code === 'ENOENT') {
985
+ // A spawn ENOENT can also mean the working directory vanished (a temp
986
+ // reaper); only report a missing executable when the temp dir is
987
+ // still there.
988
+ if (!await directoryExists(tempDir)) {
989
+ return TEMP_DIR_VANISHED;
990
+ }
991
+ return executableMissing(runnable.executable);
992
+ }
993
+ if (result.failed || result.exitCode !== 0) {
994
+ // On Windows a missing executable never surfaces as a spawn ENOENT:
995
+ // execa spawns through cross-spawn, which wraps an unresolvable
996
+ // command in cmd.exe, so the child "runs" and exits non-zero with
997
+ // cmd.exe's not-recognized message. Classify after the fact with a
998
+ // PATH probe — safe against probe/spawn resolution differences,
999
+ // because the command has already failed either way and only the
1000
+ // reporting is at stake. Executables given as a path are left to the
1001
+ // spawn's own error detail.
1002
+ if (path.basename(runnable.executable) === runnable.executable) {
1003
+ const executablePath = await new PathLookup().lookupPath(runnable.executable);
1004
+ if (executablePath === undefined) {
1005
+ return executableMissing(runnable.executable);
1006
+ }
1007
+ }
1008
+ const detail = [result.stderr, result.stdout, result.shortMessage]
1009
+ .find(value => typeof value === 'string' && value.trim() !== '') ?? 'unknown error';
1010
+ // Yarn's blocked-request error is the network guard doing its job;
1011
+ // surfaced verbatim it reads like the user's own configuration is
1012
+ // broken. Name the two real causes instead — a stale lockfile, or a
1013
+ // same-generation yarn that still declines to reuse it (e.g. a v8
1014
+ // lockfile under a yarn that writes v10). Scan BOTH streams: real
1015
+ // yarn prints YN0080 on stdout, but the single-stream `detail` above
1016
+ // prefers a non-empty stderr, so the marker can hide in either one.
1017
+ const yarnOutput = [result.stdout, result.stderr]
1018
+ .filter((value) => typeof value === 'string' && value.trim() !== '')
1019
+ .join('\n');
1020
+ if (packageManager.name === 'yarn' && /has been blocked/.test(yarnOutput)) {
1021
+ return {
1022
+ status: 'failed',
1023
+ reason: 'yarn needed the network to reuse the lockfile, which pruning forbids'
1024
+ + ' — the lockfile may be out of date with a package.json, or written by a'
1025
+ + ' different yarn version than the one that ran (pin it via the'
1026
+ + ' packageManager field); the request was blocked before any package name'
1027
+ // Yarn's own output stays attached so the affected descriptor
1028
+ // is identifiable; echoing it is no new disclosure, the
1029
+ // request never left the machine.
1030
+ + ` left the machine: ${sanitizeDetail(yarnOutput)}`,
1031
+ };
1032
+ }
1033
+ return {
1034
+ status: 'failed',
1035
+ reason: `${runnable.unsafeDisplayCommand} failed: ${sanitizeDetail(String(detail))}`,
1036
+ };
1037
+ }
1038
+ let regeneratedContent;
1039
+ try {
1040
+ regeneratedContent = await fs.readFile(path.join(tempDir, lockfileName), 'utf8');
1041
+ }
1042
+ catch (err) {
1043
+ if (err.code === 'ENOENT') {
1044
+ // As in the spawn ENOENT branch: distinguish a reaped temp dir from
1045
+ // a deliberately removed lockfile.
1046
+ if (!await directoryExists(tempDir)) {
1047
+ return TEMP_DIR_VANISHED;
1048
+ }
1049
+ // Some package managers remove rather than write a lockfile in edge
1050
+ // cases (bun deletes one that would describe no packages at all:
1051
+ // "No packages! Deleted empty lockfile") — deliberate behavior, not
1052
+ // a broken lockfile, so don't surface it as a failure whose advice
1053
+ // says to refresh the lockfile.
1054
+ return {
1055
+ status: 'skipped',
1056
+ reason: 'the regenerated lockfile was not found after the command completed'
1057
+ + ' (some package managers delete a lockfile that would describe no packages)',
1058
+ notable: true,
1059
+ };
1060
+ }
1061
+ return {
1062
+ status: 'failed',
1063
+ reason: `could not read the regenerated lockfile: ${err.message}`,
1064
+ };
1065
+ }
1066
+ // A byte-identical regeneration with backfilled manifests still counts
1067
+ // as a prune: the manifests must reach the bundle (a lockfile importer
1068
+ // without a manifest breaks the remote install), and the verification
1069
+ // below passes trivially for identical content.
1070
+ if (regeneratedContent === originalContent && backfill.manifests.size === 0) {
1071
+ return {
1072
+ status: 'skipped',
1073
+ reason: 'the regenerated lockfile is identical to the original',
1074
+ consistent: true,
1075
+ };
1076
+ }
1077
+ let regenerated;
1078
+ try {
1079
+ regenerated = parseLockfileSnapshot(regeneratedContent, lockfileName);
1080
+ }
1081
+ catch (err) {
1082
+ return { status: 'failed', reason: err.message };
1083
+ }
1084
+ const problem = verifyPrunedLockfile(original, regenerated, files);
1085
+ if (problem !== undefined) {
1086
+ return { status: 'failed', reason: problem };
1087
+ }
1088
+ return {
1089
+ status: 'pruned',
1090
+ archivePath: decision.lockfileArchivePath,
1091
+ content: regeneratedContent,
1092
+ originalContent,
1093
+ backfilledManifests: Array.from(backfill.manifests.values()),
1094
+ };
1095
+ }
1096
+ catch (err) {
1097
+ return { status: 'failed', reason: err.message };
1098
+ }
1099
+ finally {
1100
+ if (tempDir !== undefined) {
1101
+ // Cleanup failures must never override the computed result.
1102
+ await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 3 })
1103
+ .catch(err => debug(`Could not remove temp dir ${tempDir}: ${err}`));
1104
+ }
1105
+ }
1106
+ }
1107
+ //# sourceMappingURL=lockfile-pruner.js.map