checkly 8.22.0 → 8.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-context/skills-command/references/configure-playwright-checks.md +24 -19
- package/dist/commands/debug/parse-project.js +3 -0
- package/dist/commands/debug/parse-project.js.map +1 -1
- package/dist/commands/deploy.js +3 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/pw-test.js +3 -0
- package/dist/commands/pw-test.js.map +1 -1
- package/dist/commands/test.js +3 -0
- package/dist/commands/test.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js.map +1 -1
- package/dist/constructs/playwright-check-bundle.d.ts +5 -5
- package/dist/constructs/playwright-check-bundle.js.map +1 -1
- package/dist/constructs/project.js +5 -2
- package/dist/constructs/project.js.map +1 -1
- package/dist/constructs/session.d.ts +2 -1
- package/dist/constructs/session.js +2 -1
- package/dist/constructs/session.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/services/check-parser/bundler.d.ts +79 -8
- package/dist/services/check-parser/bundler.js +567 -9
- package/dist/services/check-parser/bundler.js.map +1 -1
- package/dist/services/check-parser/cache-hash.d.ts +141 -17
- package/dist/services/check-parser/cache-hash.js +78 -12
- package/dist/services/check-parser/cache-hash.js.map +1 -1
- package/dist/services/check-parser/faux-package.d.ts +5 -0
- package/dist/services/check-parser/faux-package.js +9 -1
- package/dist/services/check-parser/faux-package.js.map +1 -1
- package/dist/services/check-parser/lockfile-pruner.d.ts +104 -0
- package/dist/services/check-parser/lockfile-pruner.js +1107 -0
- package/dist/services/check-parser/lockfile-pruner.js.map +1 -0
- package/dist/services/check-parser/package-files/package-manager.d.ts +34 -0
- package/dist/services/check-parser/package-files/package-manager.js +131 -3
- package/dist/services/check-parser/package-files/package-manager.js.map +1 -1
- package/dist/services/check-parser/package-files/pnpmfile.d.ts +40 -0
- package/dist/services/check-parser/package-files/pnpmfile.js +322 -0
- package/dist/services/check-parser/package-files/pnpmfile.js.map +1 -0
- package/dist/services/check-parser/package-files/resolver.d.ts +6 -1
- package/dist/services/check-parser/package-files/resolver.js +34 -0
- package/dist/services/check-parser/package-files/resolver.js.map +1 -1
- package/dist/services/check-parser/package-files/workspace.d.ts +16 -1
- package/dist/services/check-parser/package-files/workspace.js +17 -2
- package/dist/services/check-parser/package-files/workspace.js.map +1 -1
- package/dist/services/check-parser/package-prune.d.ts +68 -0
- package/dist/services/check-parser/package-prune.js +274 -0
- package/dist/services/check-parser/package-prune.js.map +1 -0
- package/dist/services/check-parser/parser.js +9 -0
- package/dist/services/check-parser/parser.js.map +1 -1
- package/dist/services/check-parser/patched-dependencies.d.ts +133 -0
- package/dist/services/check-parser/patched-dependencies.js +428 -0
- package/dist/services/check-parser/patched-dependencies.js.map +1 -0
- package/dist/services/checkly-config-loader.d.ts +164 -10
- package/dist/services/checkly-config-loader.js +39 -0
- package/dist/services/checkly-config-loader.js.map +1 -1
- package/dist/services/embedded-packages/cache.d.ts +8 -4
- package/dist/services/embedded-packages/cache.js +27 -6
- package/dist/services/embedded-packages/cache.js.map +1 -1
- package/dist/services/embedded-packages/diagnostics.d.ts +129 -0
- package/dist/services/embedded-packages/diagnostics.js +212 -0
- package/dist/services/embedded-packages/diagnostics.js.map +1 -0
- package/dist/services/embedded-packages/lockfile-filter.d.ts +20 -0
- package/dist/services/embedded-packages/lockfile-filter.js +40 -0
- package/dist/services/embedded-packages/lockfile-filter.js.map +1 -0
- package/dist/services/embedded-packages/lockfile-packages.d.ts +31 -2
- package/dist/services/embedded-packages/lockfile-packages.js +234 -30
- package/dist/services/embedded-packages/lockfile-packages.js.map +1 -1
- package/dist/services/embedded-packages/materializer.d.ts +19 -5
- package/dist/services/embedded-packages/materializer.js +445 -91
- package/dist/services/embedded-packages/materializer.js.map +1 -1
- package/dist/services/embedded-packages/npmrc.d.ts +163 -21
- package/dist/services/embedded-packages/npmrc.js +440 -75
- package/dist/services/embedded-packages/npmrc.js.map +1 -1
- package/dist/services/embedded-packages/spec.d.ts +89 -17
- package/dist/services/embedded-packages/spec.js +199 -30
- package/dist/services/embedded-packages/spec.js.map +1 -1
- package/dist/services/embedded-packages/url.d.ts +39 -0
- package/dist/services/embedded-packages/url.js +60 -0
- package/dist/services/embedded-packages/url.js.map +1 -0
- package/dist/services/playwright-project-bundler.js +27 -23
- package/dist/services/playwright-project-bundler.js.map +1 -1
- package/dist/services/runner/registries.d.ts +144 -0
- package/dist/services/runner/registries.js +232 -0
- package/dist/services/runner/registries.js.map +1 -0
- package/oclif.manifest.json +1 -1
- package/package.json +5 -5
|
@@ -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
|