@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 +3 -1
- package/package.json +2 -2
- package/src/bun.ts +16 -6
- package/src/json.ts +22 -0
- package/src/npm.ts +13 -4
- package/src/pnpm.ts +72 -10
- package/src/prune.ts +63 -12
- package/src/scope.ts +6 -0
- package/src/yarn.ts +38 -4
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
}
|