lockfile-subset 1.2.1 → 1.3.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 (2) hide show
  1. package/dist/index.mjs +140 -61
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,33 +1,72 @@
1
1
  #!/usr/bin/env node
2
- import { join, resolve } from "path";
2
+ import { basename, dirname, join, relative, resolve, sep } from "path";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import { execSync } from "child_process";
5
5
  import { createRequire } from "module";
6
6
  import Arborist from "@npmcli/arborist";
7
7
  import yaml from "js-yaml";
8
+ //#region src/workspace-path.ts
9
+ /**
10
+ * Normalize a workspace/importer path to a forward-slash relative key.
11
+ * Returns "." for the root (empty input, ".", "./").
12
+ */
13
+ function normalizeWorkspacePath(p) {
14
+ if (!p || p === "." || p === "./") return ".";
15
+ return p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
16
+ }
17
+ //#endregion
8
18
  //#region src/extract.ts
9
- async function extractSubset({ projectPath, packageNames, includeOptional = true }) {
19
+ /**
20
+ * Rewrite a package-lock.json location key so the output is a standalone project.
21
+ * Locations inside the chosen workspace get their workspace prefix stripped;
22
+ * hoisted entries at the root stay where they are.
23
+ */
24
+ function rewritePackageLocation(location, workspacePath) {
25
+ if (workspacePath === ".") return location;
26
+ if (location === workspacePath) return "";
27
+ const prefix = workspacePath + "/";
28
+ if (location.startsWith(prefix)) return location.slice(prefix.length);
29
+ return location;
30
+ }
31
+ async function extractSubset({ projectPath, packageNames, includeOptional = true, workspacePath = "." }) {
10
32
  const tree = await new Arborist({ path: projectPath }).loadVirtual();
11
33
  const originalLockfileVersion = tree.meta.originalLockfileVersion;
12
34
  if (originalLockfileVersion < 2) throw new Error(`Lockfile version ${originalLockfileVersion} is not supported. Please upgrade to npm 7+ (lockfile v2/v3) by running: npm install --package-lock-only`);
35
+ const normalizedWorkspace = normalizeWorkspacePath(workspacePath);
36
+ let startNode = tree;
37
+ if (normalizedWorkspace !== ".") {
38
+ let found;
39
+ for (const child of tree.fsChildren) if (child.location === normalizedWorkspace) {
40
+ found = child;
41
+ break;
42
+ }
43
+ if (!found) {
44
+ const available = [...tree.fsChildren].map((c) => c.location).join(", ");
45
+ throw new Error(`Workspace "${normalizedWorkspace}" not found in package-lock.json. Available workspaces: ${available || "(none)"}`);
46
+ }
47
+ startNode = found;
48
+ }
13
49
  const keep = /* @__PURE__ */ new Set();
14
50
  for (const name of packageNames) {
15
- const edge = tree.edgesOut.get(name);
51
+ const edge = startNode.edgesOut.get(name);
16
52
  if (!edge?.to) throw new Error(`Package "${name}" not found in lockfile`);
53
+ if (edge.type === "workspace" || edge.to.isWorkspace) throw new Error(`Package "${name}" resolves to a workspace, not a published package`);
17
54
  const queue = [edge.to];
18
55
  while (queue.length > 0) {
19
56
  const node = queue.shift();
20
57
  if (keep.has(node)) continue;
58
+ if (node.isWorkspace) continue;
21
59
  keep.add(node);
22
60
  for (const e of node.edgesOut.values()) {
23
61
  if (e.type === "dev") continue;
62
+ if (e.type === "workspace") continue;
24
63
  if (e.type === "optional" && !includeOptional) continue;
25
- if (e.to && !keep.has(e.to)) queue.push(e.to);
64
+ if (e.to && !e.to.isWorkspace && !keep.has(e.to)) queue.push(e.to);
26
65
  }
27
66
  }
28
67
  }
29
68
  const dependencies = {};
30
- for (const name of packageNames) dependencies[name] = tree.edgesOut.get(name).to.version;
69
+ for (const name of packageNames) dependencies[name] = startNode.edgesOut.get(name).to.version;
31
70
  const subsetPackages = {};
32
71
  subsetPackages[""] = {
33
72
  name: "lockfile-subset-output",
@@ -36,8 +75,10 @@ async function extractSubset({ projectPath, packageNames, includeOptional = true
36
75
  };
37
76
  const originalPackages = tree.meta.data.packages;
38
77
  for (const node of keep) {
39
- const location = node.location;
40
- if (originalPackages[location]) subsetPackages[location] = originalPackages[location];
78
+ const original = originalPackages[node.location];
79
+ if (!original) continue;
80
+ const rewritten = rewritePackageLocation(node.location, normalizedWorkspace);
81
+ subsetPackages[rewritten] = original;
41
82
  }
42
83
  const collected = [...keep].map((node) => ({
43
84
  name: node.name,
@@ -77,21 +118,31 @@ function parseSnapshotKey(key) {
77
118
  function snapshotKey(name, version) {
78
119
  return `${name}@${version}`;
79
120
  }
80
- async function extractPnpmSubset({ projectPath, packageNames, includeOptional = true }) {
121
+ async function extractPnpmSubset({ projectPath, packageNames, includeOptional = true, workspacePath = "." }) {
81
122
  const content = readFileSync(join(projectPath, "pnpm-lock.yaml"), "utf8");
82
123
  const lockfile = yaml.load(content);
83
124
  if (!lockfile.lockfileVersion || !String(lockfile.lockfileVersion).startsWith("9")) throw new Error(`pnpm lockfile version ${lockfile.lockfileVersion} is not supported. Please upgrade to pnpm 9+ (lockfile v9).`);
84
- const rootImporter = lockfile.importers["."];
85
- if (!rootImporter) throw new Error("No root importer found in pnpm-lock.yaml");
125
+ const importerKey = normalizeWorkspacePath(workspacePath);
126
+ const importer = lockfile.importers[importerKey];
127
+ if (!importer) {
128
+ const available = Object.keys(lockfile.importers).join(", ");
129
+ throw new Error(`Importer "${importerKey}" not found in pnpm-lock.yaml. Available importers: ${available}`);
130
+ }
86
131
  const rootDeps = {};
87
- if (rootImporter.dependencies) for (const [name, info] of Object.entries(rootImporter.dependencies)) rootDeps[name] = info.version;
88
- if (rootImporter.optionalDependencies) for (const [name, info] of Object.entries(rootImporter.optionalDependencies)) rootDeps[name] = info.version;
132
+ for (const info of [importer.dependencies, importer.optionalDependencies]) {
133
+ if (!info) continue;
134
+ for (const [name, dep] of Object.entries(info)) rootDeps[name] = {
135
+ specifier: dep.specifier,
136
+ version: dep.version
137
+ };
138
+ }
89
139
  const keepSnapshots = /* @__PURE__ */ new Set();
90
140
  const keepPackages = /* @__PURE__ */ new Set();
91
141
  for (const name of packageNames) {
92
- const version = rootDeps[name];
93
- if (!version) throw new Error(`Package "${name}" not found in pnpm-lock.yaml`);
94
- const queue = [snapshotKey(name, version)];
142
+ const dep = rootDeps[name];
143
+ if (!dep) throw new Error(`Package "${name}" not found in pnpm-lock.yaml`);
144
+ if (dep.version.startsWith("link:")) throw new Error(`Package "${name}" resolves to a workspace (${dep.version}), not a published package`);
145
+ const queue = [snapshotKey(name, dep.version)];
95
146
  while (queue.length > 0) {
96
147
  const current = queue.shift();
97
148
  if (keepSnapshots.has(current)) continue;
@@ -101,25 +152,27 @@ async function extractPnpmSubset({ projectPath, packageNames, includeOptional =
101
152
  const snapshot = lockfile.snapshots[current];
102
153
  if (!snapshot) continue;
103
154
  if (snapshot.dependencies) for (const [depName, depVersion] of Object.entries(snapshot.dependencies)) {
155
+ if (depVersion.startsWith("link:")) continue;
104
156
  const depKey = snapshotKey(depName, depVersion);
105
157
  if (!keepSnapshots.has(depKey)) queue.push(depKey);
106
158
  }
107
159
  if (includeOptional && snapshot.optionalDependencies) for (const [depName, depVersion] of Object.entries(snapshot.optionalDependencies)) {
160
+ if (depVersion.startsWith("link:")) continue;
108
161
  const depKey = snapshotKey(depName, depVersion);
109
162
  if (!keepSnapshots.has(depKey)) queue.push(depKey);
110
163
  }
111
164
  }
112
165
  }
113
166
  const dependencies = {};
114
- for (const name of packageNames) dependencies[name] = parseSnapshotKey(snapshotKey(name, rootDeps[name])).version;
167
+ for (const name of packageNames) dependencies[name] = rootDeps[name].specifier;
115
168
  const subsetPackages = {};
116
169
  for (const key of keepPackages) if (lockfile.packages[key]) subsetPackages[key] = lockfile.packages[key];
117
170
  const subsetSnapshots = {};
118
171
  for (const key of keepSnapshots) if (lockfile.snapshots[key]) subsetSnapshots[key] = lockfile.snapshots[key];
119
172
  const subsetImporter = { dependencies: {} };
120
173
  for (const name of packageNames) subsetImporter.dependencies[name] = {
121
- specifier: dependencies[name],
122
- version: rootDeps[name]
174
+ specifier: rootDeps[name].specifier,
175
+ version: rootDeps[name].version
123
176
  };
124
177
  const collected = [...keepPackages].map((key) => {
125
178
  const parsed = parseSnapshotKey(key);
@@ -151,11 +204,11 @@ const { parse: parseYarnLockV1, stringify: stringifyYarnLockV1 } = createRequire
151
204
  function detectYarnVersion(content) {
152
205
  return content.includes("# yarn lockfile v1") ? 1 : 2;
153
206
  }
154
- function extractV1({ projectPath, packageNames, includeOptional, lockfileContent }) {
207
+ function extractV1({ projectPath, packageNames, includeOptional, lockfileContent, workspacePath }) {
155
208
  const parsed = parseYarnLockV1(lockfileContent);
156
209
  if (parsed.type !== "success") throw new Error(`Failed to parse yarn.lock: ${parsed.type}`);
157
210
  const lockfile = parsed.object;
158
- const pkgJsonPath = join(projectPath, "package.json");
211
+ const pkgJsonPath = join(projectPath, workspacePath, "package.json");
159
212
  const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
160
213
  const allDeps = {
161
214
  ...pkgJson.dependencies,
@@ -166,6 +219,7 @@ function extractV1({ projectPath, packageNames, includeOptional, lockfileContent
166
219
  for (const name of packageNames) {
167
220
  const range = allDeps[name];
168
221
  if (!range) throw new Error(`Package "${name}" not found in yarn.lock`);
222
+ if (!lockfile[`${name}@${range}`]) throw new Error(`Package "${name}@${range}" has no lockfile entry. It is likely a workspace dependency, not a published package.`);
169
223
  const queue = [`${name}@${range}`];
170
224
  while (queue.length > 0) {
171
225
  const key = queue.shift();
@@ -190,7 +244,7 @@ function extractV1({ projectPath, packageNames, includeOptional, lockfileContent
190
244
  const subset = {};
191
245
  for (const key of keepKeys) subset[key] = lockfile[key];
192
246
  const dependencies = {};
193
- for (const name of packageNames) dependencies[name] = lockfile[`${name}@${allDeps[name]}`].version;
247
+ for (const name of packageNames) dependencies[name] = allDeps[name];
194
248
  const seen = /* @__PURE__ */ new Set();
195
249
  const deduped = collected.filter((c) => {
196
250
  const key = `${c.name}@${c.version}`;
@@ -210,7 +264,7 @@ function extractV1({ projectPath, packageNames, includeOptional, lockfileContent
210
264
  collected: deduped
211
265
  };
212
266
  }
213
- function extractBerry({ projectPath, packageNames, includeOptional, lockfileContent }) {
267
+ function extractBerry({ projectPath, packageNames, includeOptional, lockfileContent, workspacePath }) {
214
268
  const lockfile = yaml.load(lockfileContent);
215
269
  const descriptorMap = /* @__PURE__ */ new Map();
216
270
  for (const [compoundKey, entry] of Object.entries(lockfile)) {
@@ -221,7 +275,7 @@ function extractBerry({ projectPath, packageNames, includeOptional, lockfileCont
221
275
  originalKey: compoundKey
222
276
  });
223
277
  }
224
- const pkgJsonPath = join(projectPath, "package.json");
278
+ const pkgJsonPath = join(projectPath, workspacePath, "package.json");
225
279
  const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
226
280
  const allDeps = {
227
281
  ...pkgJson.dependencies,
@@ -233,6 +287,7 @@ function extractBerry({ projectPath, packageNames, includeOptional, lockfileCont
233
287
  for (const name of packageNames) {
234
288
  const range = allDeps[name];
235
289
  if (!range) throw new Error(`Package "${name}" not found in yarn.lock`);
290
+ if (range.startsWith("workspace:")) throw new Error(`Package "${name}" resolves to a workspace, not a published package`);
236
291
  const queue = [`${name}@npm:${range}`];
237
292
  while (queue.length > 0) {
238
293
  const desc = queue.shift();
@@ -246,20 +301,19 @@ function extractBerry({ projectPath, packageNames, includeOptional, lockfileCont
246
301
  version: match.entry.version
247
302
  });
248
303
  if (match.entry.dependencies) for (const [depName, depRange] of Object.entries(match.entry.dependencies)) {
304
+ if (depRange.startsWith("workspace:")) continue;
249
305
  const depDesc = `${depName}@${depRange}`;
250
306
  if (!visited.has(depDesc)) queue.push(depDesc);
251
307
  }
252
308
  if (includeOptional && match.entry.optionalDependencies) for (const [depName, depRange] of Object.entries(match.entry.optionalDependencies)) {
309
+ if (depRange.startsWith("workspace:")) continue;
253
310
  const depDesc = `${depName}@${depRange}`;
254
311
  if (!visited.has(depDesc)) queue.push(depDesc);
255
312
  }
256
313
  }
257
314
  }
258
315
  const dependencies = {};
259
- for (const name of packageNames) {
260
- const descriptor = `${name}@npm:${allDeps[name]}`;
261
- dependencies[name] = descriptorMap.get(descriptor).entry.version;
262
- }
316
+ for (const name of packageNames) dependencies[name] = allDeps[name];
263
317
  const lines = [];
264
318
  lines.push("# This file is generated by running \"yarn install\" inside your project.");
265
319
  lines.push("# Manual changes might be lost - proceed with caution!");
@@ -328,19 +382,23 @@ function parseDescriptorName(descriptor) {
328
382
  if (lastAt > 0) return descriptor.slice(0, lastAt);
329
383
  return descriptor;
330
384
  }
331
- async function extractYarnSubset({ projectPath, packageNames, includeOptional = true }) {
385
+ async function extractYarnSubset({ projectPath, packageNames, includeOptional = true, workspacePath = "." }) {
332
386
  const lockfileContent = readFileSync(join(projectPath, "yarn.lock"), "utf8");
333
- if (detectYarnVersion(lockfileContent) === 1) return extractV1({
387
+ const version = detectYarnVersion(lockfileContent);
388
+ const normalizedWorkspace = normalizeWorkspacePath(workspacePath);
389
+ if (version === 1) return extractV1({
334
390
  projectPath,
335
391
  packageNames,
336
392
  includeOptional,
337
- lockfileContent
393
+ lockfileContent,
394
+ workspacePath: normalizedWorkspace
338
395
  });
339
396
  else return extractBerry({
340
397
  projectPath,
341
398
  packageNames,
342
399
  includeOptional,
343
- lockfileContent
400
+ lockfileContent,
401
+ workspacePath: normalizedWorkspace
344
402
  });
345
403
  }
346
404
  //#endregion
@@ -411,37 +469,48 @@ function parseArgs(argv) {
411
469
  }
412
470
  return args;
413
471
  }
472
+ const LOCKFILE_BASENAMES = {
473
+ "pnpm-lock.yaml": "pnpm",
474
+ "yarn.lock": "yarn",
475
+ "package-lock.json": "npm"
476
+ };
477
+ /** Walk up from `start` looking for any known lockfile. Returns null if none found. */
478
+ function findLockfileUpwards(start) {
479
+ let dir = start;
480
+ while (true) {
481
+ for (const [name, type] of Object.entries(LOCKFILE_BASENAMES)) if (existsSync(resolve(dir, name))) return {
482
+ projectPath: dir,
483
+ type
484
+ };
485
+ const parent = dirname(dir);
486
+ if (parent === dir) return null;
487
+ dir = parent;
488
+ }
489
+ }
414
490
  function resolveLockfile(lockfilePath) {
415
491
  if (!lockfilePath) {
416
- if (existsSync(resolve("pnpm-lock.yaml"))) return {
417
- projectPath: resolve("."),
418
- type: "pnpm"
419
- };
420
- if (existsSync(resolve("yarn.lock"))) return {
421
- projectPath: resolve("."),
422
- type: "yarn"
423
- };
424
- if (existsSync(resolve("package-lock.json"))) return {
425
- projectPath: resolve("."),
426
- type: "npm"
427
- };
428
- throw new Error("No lockfile found in current directory. Expected package-lock.json, pnpm-lock.yaml, or yarn.lock.");
492
+ const found = findLockfileUpwards(resolve("."));
493
+ if (found) return found;
494
+ throw new Error("No lockfile found in current directory or any parent. Expected package-lock.json, pnpm-lock.yaml, or yarn.lock.");
429
495
  }
430
496
  const resolved = resolve(lockfilePath);
431
- const basename = resolved.split("/").pop();
432
- if (basename === "pnpm-lock.yaml") return {
433
- projectPath: resolve(resolved, ".."),
434
- type: "pnpm"
435
- };
436
- if (basename === "yarn.lock") return {
437
- projectPath: resolve(resolved, ".."),
438
- type: "yarn"
439
- };
440
- if (basename === "package-lock.json") return {
441
- projectPath: resolve(resolved, ".."),
442
- type: "npm"
497
+ const type = LOCKFILE_BASENAMES[basename(resolved)];
498
+ if (!type) throw new Error(`Invalid lockfile path: ${lockfilePath}. Expected a path to package-lock.json, pnpm-lock.yaml, or yarn.lock.`);
499
+ return {
500
+ projectPath: dirname(resolved),
501
+ type
443
502
  };
444
- throw new Error(`Invalid lockfile path: ${lockfilePath}. Expected a path to package-lock.json, pnpm-lock.yaml, or yarn.lock.`);
503
+ }
504
+ /**
505
+ * Resolve the workspace path (relative to projectPath, forward slashes).
506
+ * Inferred from process.cwd() vs the lockfile's project directory: if cwd
507
+ * sits inside a sub-workspace, that path is used; otherwise "." (root).
508
+ */
509
+ function resolveWorkspacePath(projectPath) {
510
+ const rel = relative(projectPath, resolve("."));
511
+ if (rel === "" || rel === ".") return ".";
512
+ if (rel.startsWith("..")) return ".";
513
+ return rel.split(sep).join("/");
445
514
  }
446
515
  const HELP = `
447
516
  lockfile-subset <packages...> [options]
@@ -453,7 +522,7 @@ Arguments:
453
522
  packages Package names to extract (one or more, space-separated)
454
523
 
455
524
  Options:
456
- --lockfile, -l <path> Path to lockfile (auto-detected from cwd by default)
525
+ --lockfile, -l <path> Path to lockfile (auto-detected by walking up from cwd)
457
526
  --output, -o <dir> Output directory (default: ./lockfile-subset-output)
458
527
  --no-optional Exclude optional dependencies
459
528
  --install Run npm ci / pnpm install / yarn install after generating
@@ -467,6 +536,11 @@ Examples:
467
536
  lockfile-subset @prisma/client sharp -l /build/package-lock.json
468
537
  lockfile-subset @prisma/client sharp -l pnpm-lock.yaml --install
469
538
  lockfile-subset chalk --dry-run
539
+
540
+ Monorepos: cd into the target workspace and run as usual.
541
+ The lockfile is found by walking up from the current directory, and the
542
+ sub-workspace is inferred from cwd relative to the lockfile.
543
+ cd apps/web && lockfile-subset next
470
544
  `.trim();
471
545
  async function main() {
472
546
  const args = parseArgs(process.argv.slice(2));
@@ -484,22 +558,27 @@ async function main() {
484
558
  process.exit(1);
485
559
  }
486
560
  const { projectPath, type } = resolveLockfile(args.lockfile);
561
+ const workspacePath = resolveWorkspacePath(projectPath);
487
562
  const outputDir = resolve(args.output);
563
+ if (workspacePath !== ".") console.log(`Using workspace: ${workspacePath} (lockfile root: ${projectPath})`);
488
564
  let result;
489
565
  if (type === "pnpm") result = await extractPnpmSubset({
490
566
  projectPath,
491
567
  packageNames: args.packages,
492
- includeOptional: args.includeOptional
568
+ includeOptional: args.includeOptional,
569
+ workspacePath
493
570
  });
494
571
  else if (type === "yarn") result = await extractYarnSubset({
495
572
  projectPath,
496
573
  packageNames: args.packages,
497
- includeOptional: args.includeOptional
574
+ includeOptional: args.includeOptional,
575
+ workspacePath
498
576
  });
499
577
  else result = await extractSubset({
500
578
  projectPath,
501
579
  packageNames: args.packages,
502
- includeOptional: args.includeOptional
580
+ includeOptional: args.includeOptional,
581
+ workspacePath
503
582
  });
504
583
  console.log(`Collected ${result.collected.length} packages (${args.packages.length} direct, ${result.collected.length - args.packages.length} transitive)`);
505
584
  if (args.dryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lockfile-subset",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Extract a subset of package-lock.json, pnpm-lock.yaml, or yarn.lock for specified packages and their transitive dependencies",
5
5
  "type": "module",
6
6
  "bin": {