@vzn/vx-lockfile 0.0.631 → 0.0.633

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/README.md CHANGED
@@ -52,7 +52,7 @@ The claim, the per-project key, the memo and the `--affected` diff are core's `l
52
52
  Declaring any of the four plugins adds `vx prune` (the `commands` seam): the workspace cut to some projects and their transitive workspace dependencies, for a Docker build that installs and builds only what it ships — `turbo prune`.
53
53
 
54
54
  ```sh
55
- vx prune <project...> [--out-dir <dir>] [--docker]
55
+ vx prune <project...> [--out-dir <dir>] [--docker] [--production]
56
56
  ```
57
57
 
58
58
  The out dir (default `out/`, relative to the cwd) gets the root `package.json` with `workspaces` rewritten to the subset's dirs (a glob character in a dir name bracketed, `a[1]` → `a[[]1]`, so the pattern matches that dir alone), `pnpm-workspace.yaml` with `packages` rewritten (every other key as written), each lockfile at the root **pruned** to what the subset installs, the install config (`.npmrc`, `.yarnrc.yml`, `.pnpmfile.cjs`, `bunfig.toml`, `.nvmrc`, `.node-version`, the patch files `patchedDependencies` names, Yarn's `yarnPath` and plugins), `vx.workspace.*`, the root `vx.config.*`, and each project directory less `node_modules`, `.git`, `.vx` and `.turbo`. A workspace package a copied vx config imports (a local plugin) comes along with its closure: the config loads before any task. A relative config import that leaves the subset is warned about. `--docker` writes `json/` — the root install files and each project's `package.json`, the cacheable install layer — and `full/`, everything:
@@ -66,6 +66,8 @@ RUN bunx vx run app#build
66
66
 
67
67
  The pruned lockfile is the source with whole entries cut, in the package manager's own layout, so it installs with a frozen lockfile (`bun install --frozen-lockfile`, `pnpm install --frozen-lockfile`, `npm ci`, `yarn install --immutable`) and a line only another project reaches no longer busts the install layer. Kept: the root's and each kept workspace's entries and every package they reach — through Bun's and npm's hoisted walk (a package stays at the path it held; nothing is re-hoisted), pnpm's snapshots, Yarn's descriptors. Workspace-wide fields (catalogs, overrides, patches, settings, `trustedDependencies`) stay whole: the root manifest still names them. pnpm's env document stays whole. Yarn classic records no workspaces, so its walk starts at each kept manifest's dependencies.
68
68
 
69
+ `--production` follows no `devDependencies` edge between workspace packages: a package only a dev dependency reaches is left out (`turbo prune --production`). Each kept manifest, the root's too, loses the `devDependencies` entries naming one, its field gone when it empties, and every lockfile drops the same entries: bun's and npm's workspace records, pnpm's importer (`{}` when it empties), Yarn berry's `workspace:` link in the workspace entry; Yarn classic walks the stripped manifests. A name another field also holds is installed in production and stays. Third-party dev dependencies stay in the lockfile: an install with `--production` / `--omit=dev` / `--prod` skips them. For the stage that runs a built app, not the one that builds it.
70
+
69
71
  Refused, with the reason: an out dir that is or contains the root, one inside a copied project, one that already has content; a project name the workspace lacks (the root is always kept); `bun.lockb` without `bun.lock` (binary — `bun install --save-text-lockfile`). A `file:` or `link:` dependency outside every copied project is not copied. One plugin or four, there is one `prune`: it prunes every lockfile at the root.
70
72
 
71
73
  ## Testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-lockfile",
3
- "version": "0.0.631",
3
+ "version": "0.0.633",
4
4
  "license": "MIT",
5
5
  "description": "Lockfile plugins for vx — pnpm(), bun(), npm(), yarn(): each keys every task on its project's own resolved dependency closure from the package manager's lockfile, so a lockfile change re-keys only the projects it reaches; --affected follows the same answer; `vx prune` cuts the workspace and the lockfile to a Docker subset.",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "peerDependencies": {
40
- "@vzn/vx": "^0.0.631"
40
+ "@vzn/vx": "^0.0.633"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
package/src/bun.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // Bun.JSONC is the parser: no dependency, and the file is Bun's own.
12
12
 
13
13
  import { reachDigests } from '@vzn/vx'
14
- import { depsOf, record, type Json } from './json.js'
14
+ import { depsOf, devOnly, dropDev, record, type Json } from './json.js'
15
15
  import type { PruneScope } from './scope.js'
16
16
 
17
17
  export interface Lockfile {
@@ -243,7 +243,7 @@ export function importerDigests(
243
243
  * field as written — catalogs, overrides, patches and trusted
244
244
  * dependencies are the workspace's, and the root manifest still names them.
245
245
  */
246
- export function pruneLockfile(text: string, { dirs }: PruneScope): string {
246
+ export function pruneLockfile(text: string, { dirs, dropped }: PruneScope): string {
247
247
  const lock = parseLockfile(text)
248
248
  const doc = Bun.JSONC.parse(text) as Json
249
249
  const reached = new Set<string>()
@@ -260,16 +260,26 @@ export function pruneLockfile(text: string, { dirs }: PruneScope): string {
260
260
  }
261
261
  for (const name of entry.deps.keys()) visit(resolve(lock, key, name))
262
262
  }
263
+ const workspaces = record(doc['workspaces']) ?? {}
264
+ const skip = new Map<string, Set<string>>()
265
+ for (const [dir, manifest] of Object.entries(workspaces)) {
266
+ const key = dir === '' ? '.' : dir
267
+ if (!dirs.has(key)) {
268
+ delete workspaces[dir]
269
+ continue
270
+ }
271
+ const names = devOnly(record(manifest), dropped)
272
+ skip.set(key, names)
273
+ dropDev(record(manifest), names)
274
+ }
263
275
  for (const [dir, deps] of lock.workspaces) {
264
276
  if (!dirs.has(dir)) continue
265
277
  let from = ''
266
278
  for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir && dir !== '.') from = name
267
279
  if (from !== '') visit(from)
268
- for (const name of deps.keys()) visit(resolve(lock, from, name))
280
+ const gone = skip.get(dir)
281
+ for (const name of deps.keys()) if (gone?.has(name) !== true) visit(resolve(lock, from, name))
269
282
  }
270
- const workspaces = record(doc['workspaces']) ?? {}
271
- for (const dir of Object.keys(workspaces))
272
- if (!dirs.has(dir === '' ? '.' : dir)) delete workspaces[dir]
273
283
  const packages = record(doc['packages']) ?? {}
274
284
  for (const key of Object.keys(packages)) if (!reached.has(key)) delete packages[key]
275
285
  return writeLockfile(doc)
package/src/json.ts CHANGED
@@ -23,3 +23,25 @@ export function depsOf(
23
23
  export function record(v: unknown): Json | undefined {
24
24
  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
25
25
  }
26
+
27
+ /**
28
+ * The `dropped` names a manifest (or a lockfile's record of one) names
29
+ * ONLY as devDependencies: an entry another field also holds is installed
30
+ * in production and stays.
31
+ */
32
+ export function devOnly(manifest: Json | undefined, dropped: ReadonlySet<string>): Set<string> {
33
+ const out = new Set<string>()
34
+ const dev = record(manifest?.['devDependencies'])
35
+ if (dev === undefined || dropped.size === 0) return out
36
+ const elsewhere = depsOf(manifest!, ['dependencies', 'optionalDependencies', 'peerDependencies'])
37
+ for (const name of Object.keys(dev)) if (dropped.has(name) && !elsewhere.has(name)) out.add(name)
38
+ return out
39
+ }
40
+
41
+ /** `manifest`'s devDependencies without `names`; the field goes when nothing is left. */
42
+ export function dropDev(manifest: Json | undefined, names: ReadonlySet<string>): void {
43
+ const dev = record(manifest?.['devDependencies'])
44
+ if (manifest === undefined || dev === undefined || names.size === 0) return
45
+ for (const name of names) delete dev[name]
46
+ if (Object.keys(dev).length === 0) delete manifest['devDependencies']
47
+ }
package/src/npm.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // ancestor directory's, else `node_modules/d` — Node's own walk.
9
9
 
10
10
  import { reachDigests } from '@vzn/vx'
11
- import { depsOf, record, type Json } from './json.js'
11
+ import { depsOf, devOnly, dropDev, record, type Json } from './json.js'
12
12
  import type { PruneScope } from './scope.js'
13
13
 
14
14
  export interface Lockfile {
@@ -147,9 +147,18 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
147
147
  * same paths.
148
148
  */
149
149
  export function pruneLockfile(text: string, scope: PruneScope): string {
150
- const { dirs, members, workspaces } = scope
150
+ const { dirs, members, workspaces, dropped } = scope
151
151
  const lock = parseLockfile(text)
152
152
  const doc = JSON.parse(text) as Json
153
+ const packages = record(doc['packages'])!
154
+ // What production struck from each kept workspace's manifest, struck from its entry too.
155
+ const skip = new Map<string, Set<string>>()
156
+ for (const p of Object.keys(packages)) {
157
+ if (!dirs.has(p === '' ? '.' : p)) continue
158
+ const names = devOnly(record(packages[p]), dropped)
159
+ skip.set(p, names)
160
+ dropDev(record(packages[p]), names)
161
+ }
153
162
  const reached = new Set<string>()
154
163
  const visit = (p: string | undefined): void => {
155
164
  if (p === undefined || reached.has(p)) return
@@ -159,13 +168,13 @@ export function pruneLockfile(text: string, scope: PruneScope): string {
159
168
  }
160
169
  reached.add(p)
161
170
  if (e.link !== undefined) return visit(lock.packages.has(e.link) ? e.link : undefined)
162
- for (const name of e.deps.keys()) visit(resolve(lock, p, name))
171
+ const gone = skip.get(p)
172
+ for (const name of e.deps.keys()) if (gone?.has(name) !== true) visit(resolve(lock, p, name))
163
173
  }
164
174
  for (const [p, e] of lock.packages) {
165
175
  const linked = e.link !== undefined && dirs.has(e.link)
166
176
  if (linked || dirs.has(p === '' ? '.' : p)) visit(p)
167
177
  }
168
- const packages = record(doc['packages'])!
169
178
  for (const p of Object.keys(packages)) if (!reached.has(p)) delete packages[p]
170
179
  const rootEntry = record(packages[''])
171
180
  if (rootEntry?.['workspaces'] !== undefined) rootEntry['workspaces'] = [...workspaces]
package/src/pnpm.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  // Bun.YAML is the parser: no dependency, and the file is plain YAML.
13
13
 
14
14
  import { reachDigests } from '@vzn/vx'
15
- import { pruneSections } from './blocks.js'
16
- import { record } from './json.js'
15
+ import { pruneEntries, pruneSections, yamlKey } from './blocks.js'
16
+ import { devOnly, record } from './json.js'
17
17
  import type { PruneScope } from './scope.js'
18
18
 
19
19
  export interface Lockfile {
@@ -283,13 +283,23 @@ function buildGraph(lock: Lockfile): Graph {
283
283
  * overrides and settings are the workspace's, and the subset's manifests
284
284
  * still name them. A multi-document lockfile keeps the env document whole.
285
285
  */
286
- export function pruneLockfile(text: string, { dirs }: PruneScope): string {
286
+ export function pruneLockfile(text: string, { dirs, dropped }: PruneScope): string {
287
287
  const marker = [...text.matchAll(/^---[ \t]*$/gm)].at(-1)
288
288
  const cut = marker === undefined ? 0 : marker.index + marker[0].length
289
289
  const lock = parseLockfile(text)
290
+ // What production struck from each kept importer's manifest.
291
+ const skip = new Map<string, Set<string>>()
292
+ if (dropped.size > 0) {
293
+ const docs: unknown = Bun.YAML.parse(text)
294
+ const doc = record(Array.isArray(docs) ? docs.at(-1) : docs)
295
+ for (const [dir, entry] of Object.entries(record(doc?.['importers']) ?? {})) {
296
+ if (dirs.has(dir)) skip.set(dir, devOnly(record(entry), dropped))
297
+ }
298
+ }
290
299
  const reached = new Set<string>()
291
- const visit = (dir: string, deps: ReadonlyMap<string, string>): void => {
300
+ const visit = (dir: string, deps: ReadonlyMap<string, string>, importer = false): void => {
292
301
  for (const [name, version] of deps) {
302
+ if (importer && skip.get(dir)?.has(name) === true) continue
293
303
  if (version.startsWith('link:')) {
294
304
  const target = joinPosix(dir, version.slice('link:'.length))
295
305
  if (lock.importers.has(target) && !dirs.has(target)) {
@@ -303,19 +313,71 @@ export function pruneLockfile(text: string, { dirs }: PruneScope): string {
303
313
  visit('.', lock.snapshots.get(key) ?? new Map())
304
314
  }
305
315
  }
306
- for (const [dir, deps] of lock.importers) if (dirs.has(dir)) visit(dir, deps)
316
+ for (const [dir, deps] of lock.importers) if (dirs.has(dir)) visit(dir, deps, true)
307
317
  const v9 = Number.parseInt(lock.version, 10) >= 9
308
318
  const packages = new Set([...reached].map((key) => (v9 ? packageKey(key) : key)))
309
319
  return (
310
320
  text.slice(0, cut) +
311
- pruneSections(text.slice(cut), {
312
- importers: (dir) => dirs.has(dir),
313
- packages: (key) => packages.has(key),
314
- snapshots: (key) => reached.has(key),
315
- })
321
+ dropImporterDev(
322
+ pruneSections(text.slice(cut), {
323
+ importers: (dir) => dirs.has(dir),
324
+ packages: (key) => packages.has(key),
325
+ snapshots: (key) => reached.has(key),
326
+ }),
327
+ skip,
328
+ )
316
329
  )
317
330
  }
318
331
 
332
+ /**
333
+ * Each importer's `devDependencies` without the `dropped` names, cut as
334
+ * lines like every other prune: the subsection goes when it empties, and
335
+ * an importer left with nothing is `{}`, as pnpm writes one.
336
+ */
337
+ function dropImporterDev(text: string, skip: ReadonlyMap<string, ReadonlySet<string>>): string {
338
+ if (![...skip.values()].some((s) => s.size > 0)) return text
339
+ const lines = text.split('\n')
340
+ const start = lines.findIndex((l) => /^importers:\s*$/.test(l))
341
+ if (start === -1) return text
342
+ let end = start + 1
343
+ while (end < lines.length && !/^\S/.test(lines[end]!)) end++
344
+ const indented = (l: string, n: number): boolean =>
345
+ l.startsWith(' '.repeat(n)) && /^[^\s#]/.test(l.slice(n))
346
+ const out = lines.slice(0, start + 1)
347
+ let i = start + 1
348
+ while (i < end) {
349
+ if (!indented(lines[i]!, 2)) {
350
+ out.push(lines[i++]!)
351
+ continue
352
+ }
353
+ const key = i
354
+ const dropped = skip.get(yamlKey(lines[key]!)) ?? new Set<string>()
355
+ let stop = i + 1
356
+ while (stop < end && !indented(lines[stop]!, 2)) stop++
357
+ const body: string[] = []
358
+ for (let j = key + 1; j < stop;) {
359
+ if (lines[j] !== ' devDependencies:') {
360
+ body.push(lines[j++]!)
361
+ continue
362
+ }
363
+ let sub = j + 1
364
+ while (sub < stop && (lines[sub]!.trim() === '' || /^ {5,}/.test(lines[sub]!))) sub++
365
+ const kept = pruneEntries(lines, j + 1, sub, 6, (l) => !dropped.has(yamlKey(l)))
366
+ if (kept.some((l) => indented(l, 6))) body.push(lines[j]!, ...kept)
367
+ else body.push(...kept.filter((l) => l.trim() === ''))
368
+ j = sub
369
+ }
370
+ const emptied = !body.some((l) => indented(l, 4)) && stop - key - 1 > body.length
371
+ const entry = !emptied
372
+ ? [lines[key]!, ...body]
373
+ : [`${lines[key]!.replace(/:\s*$/, '')}: {}`, ...body.filter((l) => l.trim() === '')]
374
+ out.push(...entry)
375
+ i = stop
376
+ }
377
+ out.push(...lines.slice(end))
378
+ return out.join('\n')
379
+ }
380
+
319
381
  /** `packages/a` + `../b` → `packages/b`; `.` + `packages/a` → `packages/a`. POSIX, as the lockfile writes paths. */
320
382
  function joinPosix(base: string, rel: string): string {
321
383
  const parts: string[] = []
package/src/prune.ts CHANGED
@@ -1,9 +1,12 @@
1
- // `vx prune <project...> [--out-dir <dir>] [--docker]`: the workspace cut to
1
+ // `vx prune <project...> [--out-dir <dir>] [--docker] [--production]`: the workspace cut to
2
2
  // the named projects and their transitive workspace dependencies, for a
3
3
  // Docker build that installs and builds only what it ships (`turbo prune`).
4
4
  // Every lockfile here is cut to what the subset installs, so the copy
5
5
  // installs with a frozen lockfile and a lockfile line the subset cannot
6
- // reach does not bust the install layer.
6
+ // reach does not bust the install layer. `--production` follows no
7
+ // devDependencies edge between workspace packages: a package only a dev
8
+ // dependency pulls in is left out, and the kept manifests and lockfile no
9
+ // longer name it (`turbo prune --production`).
7
10
  //
8
11
  // <out>/ the subset (with --docker: <out>/full/)
9
12
  // package.json `workspaces` rewritten to the subset's dirs
@@ -31,7 +34,7 @@ import * as pnpmLock from './pnpm.js'
31
34
  import * as yarnLock from './yarn.js'
32
35
  import type { PruneScope } from './scope.js'
33
36
 
34
- const USAGE = 'usage: vx prune <project...> [--out-dir <dir>] [--docker]'
37
+ const USAGE = 'usage: vx prune <project...> [--out-dir <dir>] [--docker] [--production]'
35
38
 
36
39
  const LOCKFILES: Readonly<Record<string, (text: string, scope: PruneScope) => string>> = {
37
40
  'pnpm-lock.yaml': pnpmLock.pruneLockfile,
@@ -70,16 +73,19 @@ interface Args {
70
73
  readonly projects: readonly string[]
71
74
  readonly outDir: string
72
75
  readonly docker: boolean
76
+ readonly production: boolean
73
77
  }
74
78
 
75
79
  function parseArgs(argv: readonly string[]): Args | null {
76
80
  const projects: string[] = []
77
81
  let outDir = 'out'
78
82
  let docker = false
83
+ let production = false
79
84
  for (let i = 0; i < argv.length; i++) {
80
85
  const a = argv[i]!
81
86
  if (a === '--help' || a === '-h') return null
82
87
  if (a === '--docker') docker = true
88
+ else if (a === '--production') production = true
83
89
  else if (a === '--out-dir' || a.startsWith('--out-dir=')) {
84
90
  const v = a === '--out-dir' ? argv[++i] : a.slice('--out-dir='.length)
85
91
  if (v === undefined || v === '')
@@ -93,7 +99,7 @@ function parseArgs(argv: readonly string[]): Args | null {
93
99
  else projects.push(a)
94
100
  }
95
101
  if (projects.length === 0) throw new UserError(`vx prune: name at least one project\n${USAGE}`)
96
- return { projects, outDir, docker }
102
+ return { projects, outDir, docker, production }
97
103
  }
98
104
 
99
105
  async function exists(p: string): Promise<boolean> {
@@ -123,7 +129,14 @@ export async function prune(argv: readonly string[], ctx: CommandContext): Promi
123
129
  `vx prune: no project named ${unknown.map((n) => JSON.stringify(n)).join(', ')} (the root is always kept; name the projects under it)`,
124
130
  )
125
131
  }
126
- const graph = buildPackageGraph(projects)
132
+ // Production: the graph without dev edges, so a package reached only
133
+ // through a devDependencies entry is never added. The linking rules are
134
+ // the graph's own either way.
135
+ const graph = buildPackageGraph(
136
+ args.production
137
+ ? projects.map((p) => ({ ...p, packageJson: { ...p.packageJson, devDependencies: {} } }))
138
+ : projects,
139
+ )
127
140
  const names = new Set<string>()
128
141
  const add = (name: string): void => {
129
142
  if (names.has(name) || !byName.has(name)) return
@@ -207,17 +220,43 @@ export async function prune(argv: readonly string[], ctx: CommandContext): Promi
207
220
  : { ...(field as object), packages: workspaces }
208
221
  }
209
222
 
223
+ // Production: each kept manifest (the root's too) without the
224
+ // devDependencies entries naming a workspace package the subset leaves
225
+ // out, written over the copy; the lockfiles drop the same entries.
226
+ const dropped = new Set<string>()
227
+ const rewritten = new Map<string, Record<string, unknown>>()
228
+ if (args.production) {
229
+ const strip = (rel: string, pkg: Record<string, unknown>): void => {
230
+ const dev = record(pkg['devDependencies'])
231
+ const gone = Object.keys(dev ?? {}).filter((n) => byName.has(n) && !names.has(n))
232
+ if (dev === undefined || gone.length === 0) return
233
+ for (const n of gone) dropped.add(n)
234
+ const { devDependencies: _, ...rest } = pkg
235
+ const kept = Object.entries(dev).filter(([n]) => !gone.includes(n))
236
+ rewritten.set(
237
+ rel,
238
+ kept.length === 0 ? rest : { ...pkg, devDependencies: Object.fromEntries(kept) },
239
+ )
240
+ }
241
+ strip('.', rootManifest)
242
+ for (const [i, p] of subset.entries()) {
243
+ strip(rels[i]!, JSON.parse(await Bun.file(path.join(p.dir, 'package.json')).text()))
244
+ }
245
+ }
210
246
  const manifests = new Map<string, ReadonlyMap<string, string>>()
211
247
  type Deps = Partial<Record<(typeof DEP_FIELDS)[number], Record<string, string>>>
212
248
  const depsOf = (pkg: Deps): ReadonlyMap<string, string> =>
213
249
  new Map(DEP_FIELDS.flatMap((f) => Object.entries(pkg[f] ?? {})))
214
- manifests.set('.', depsOf(rootManifest as Deps))
215
- for (const [i, p] of subset.entries()) manifests.set(rels[i]!, depsOf(p.packageJson))
250
+ manifests.set('.', depsOf((rewritten.get('.') ?? rootManifest) as Deps))
251
+ for (const [i, p] of subset.entries()) {
252
+ manifests.set(rels[i]!, depsOf((rewritten.get(rels[i]!) ?? p.packageJson) as Deps))
253
+ }
216
254
  const scope: PruneScope = {
217
255
  dirs: new Set(['.', ...rels]),
218
256
  members: new Set(projects.map((p) => posix(path.relative(root, p.dir)))),
219
257
  workspaces,
220
258
  manifests,
259
+ dropped,
221
260
  }
222
261
 
223
262
  if (
@@ -245,11 +284,12 @@ export async function prune(argv: readonly string[], ctx: CommandContext): Promi
245
284
  rewriteWorkspaceYaml(await Bun.file(workspaceYaml).text(), patterns),
246
285
  )
247
286
  }
248
- // A manifest with no `workspaces` (pnpm's) is copied byte for byte.
249
- if (listed !== undefined)
250
- written.set('package.json', `${JSON.stringify(rootManifest, null, 2)}\n`)
287
+ // A manifest with no `workspaces` (pnpm's) is copied byte for byte
288
+ // unless production rewrote it.
289
+ const rootOut = rewritten.get('.') ?? (listed !== undefined ? rootManifest : undefined)
290
+ if (rootOut !== undefined) written.set('package.json', `${JSON.stringify(rootOut, null, 2)}\n`)
251
291
  const installFiles = [
252
- ...(listed === undefined ? ['package.json'] : []),
292
+ ...(rootOut === undefined ? ['package.json'] : []),
253
293
  ...INSTALL_FILES,
254
294
  ...(await namedFiles(root, rootManifest)),
255
295
  ]
@@ -272,12 +312,22 @@ export async function prune(argv: readonly string[], ctx: CommandContext): Promi
272
312
  filter: (src) => !COPY_EXCLUDES.has(path.basename(src)),
273
313
  })
274
314
  }
315
+ const manifestText = (rel: string): string | undefined => {
316
+ const pkg = rel === '.' ? undefined : rewritten.get(rel)
317
+ return pkg === undefined ? undefined : `${JSON.stringify(pkg, null, 2)}\n`
318
+ }
319
+ for (const rel of rels) {
320
+ const text = manifestText(rel)
321
+ if (text !== undefined) await writeFile(path.join(full, rel, 'package.json'), text)
322
+ }
275
323
  if (args.docker) {
276
324
  const json = path.join(out, 'json')
277
325
  await emit(json, [])
278
326
  for (const rel of rels) {
279
327
  await mkdir(path.join(json, rel), { recursive: true })
280
- await cp(path.join(root, rel, 'package.json'), path.join(json, rel, 'package.json'))
328
+ const text = manifestText(rel)
329
+ if (text !== undefined) await writeFile(path.join(json, rel, 'package.json'), text)
330
+ else await cp(path.join(root, rel, 'package.json'), path.join(json, rel, 'package.json'))
281
331
  }
282
332
  }
283
333
 
@@ -285,6 +335,7 @@ export async function prune(argv: readonly string[], ctx: CommandContext): Promi
285
335
  process.stdout.write(
286
336
  `vx prune: ${subset.length} project${subset.length === 1 ? '' : 's'} → ${args.outDir}` +
287
337
  `${args.docker ? ' (json/ + full/)' : ''}` +
338
+ `${args.production ? ' (production)' : ''}` +
288
339
  `${lockfiles.length > 0 ? `, ${lockfiles.join(', ')} pruned` : ''}\n` +
289
340
  subset.map((p, i) => ` ${p.name} (${rels[i]})\n`).join(''),
290
341
  )
package/src/scope.ts CHANGED
@@ -8,4 +8,10 @@ export interface PruneScope {
8
8
  readonly workspaces: readonly string[]
9
9
  /** Each kept dir's manifest dependencies (every bucket): yarn classic records no workspaces, so its walk starts here. */
10
10
  readonly manifests: ReadonlyMap<string, ReadonlyMap<string, string>>
11
+ /**
12
+ * Workspace package names `--production` struck from the kept manifests'
13
+ * devDependencies: the lockfile's kept workspaces stop naming them too.
14
+ * Empty otherwise.
15
+ */
16
+ readonly dropped: ReadonlySet<string>
11
17
  }
package/src/yarn.ts CHANGED
@@ -293,7 +293,12 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
293
293
  * skipped).
294
294
  */
295
295
  export function pruneLockfile(text: string, scope: PruneScope): string {
296
- const { dirs, members, manifests } = scope
296
+ const { dirs, members, manifests, dropped } = scope
297
+ // Berry merges a workspace's dev dependencies into its entry's
298
+ // `dependencies`, so a struck name is told by its link: a dropped
299
+ // workspace package is reached through a `workspace:` range.
300
+ const isDropped = (name: string, range: string): boolean =>
301
+ dropped.has(name) && range.startsWith('workspace:')
297
302
  const lock = parseLockfile(text)
298
303
  const reached = new Set<string>()
299
304
  const visit = (id: string | undefined): void => {
@@ -304,13 +309,23 @@ export function pruneLockfile(text: string, scope: PruneScope): string {
304
309
  throw new Error(`yarn.lock: ${id} is a workspace the subset leaves out`)
305
310
  }
306
311
  reached.add(id)
312
+ const kept = dir !== undefined && dirs.has(dir)
307
313
  for (const [name, range] of lock.entries.get(id)!.installs) {
314
+ if (kept && isDropped(name, range)) continue
308
315
  for (const target of resolveDescriptor(lock, id, name, range)) visit(target)
309
316
  }
310
317
  }
311
318
  const berry = lock.generation === 'berry'
319
+ // Berry records a workspace's dev dependencies in its entry's
320
+ // `dependencies`; classic records no workspace, and its manifests are
321
+ // already stripped.
322
+ const keptWorkspaces = new Set<string>()
312
323
  if (berry) {
313
- for (const [dir, id] of lock.workspaces) if (dirs.has(dir)) visit(id)
324
+ for (const [dir, id] of lock.workspaces) {
325
+ if (!dirs.has(dir)) continue
326
+ keptWorkspaces.add(id)
327
+ visit(id)
328
+ }
314
329
  } else {
315
330
  for (const [dir, deps] of manifests) {
316
331
  if (!dirs.has(dir)) continue
@@ -329,9 +344,28 @@ export function pruneLockfile(text: string, scope: PruneScope): string {
329
344
  berry
330
345
  ? yamlKey(line).split(',')[0]!.trimStart()
331
346
  : unquote(line.replace(/:\s*$/, '').split(',')[0]!.trim())
332
- return pruneEntries(lines, 0, lines.length, 0, (line) => {
347
+ const pruned = pruneEntries(lines, 0, lines.length, 0, (line) => {
333
348
  if (line.startsWith('__metadata:')) return true
334
349
  const id = lock.descriptors.get(first(line))
335
350
  return id !== undefined && reached.has(id)
336
- }).join('\n')
351
+ })
352
+ if (!berry || dropped.size === 0) return pruned.join('\n')
353
+ const out: string[] = []
354
+ let inKept = false
355
+ for (let i = 0; i < pruned.length; i++) {
356
+ const line = pruned[i]!
357
+ if (/^\S/.test(line)) inKept = keptWorkspaces.has(lock.descriptors.get(first(line)) ?? '')
358
+ if (!inKept || line !== ' dependencies:') {
359
+ out.push(line)
360
+ continue
361
+ }
362
+ const deps: string[] = []
363
+ while (i + 1 < pruned.length && pruned[i + 1]!.startsWith(' ')) deps.push(pruned[++i]!)
364
+ const kept = deps.filter((l) => {
365
+ const [name, range] = Object.entries(record(Bun.YAML.parse(l.trim())) ?? {})[0] ?? []
366
+ return name === undefined || !isDropped(name, String(range))
367
+ })
368
+ if (kept.length > 0) out.push(line, ...kept)
369
+ }
370
+ return out.join('\n')
337
371
  }