@vzn/vx-lockfile 0.0.492 → 0.0.494
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 +22 -1
- package/package.json +6 -4
- package/src/blocks.ts +84 -0
- package/src/bun.ts +78 -0
- package/src/index.ts +5 -4
- package/src/npm.ts +44 -0
- package/src/pnpm.ts +41 -0
- package/src/prune.ts +341 -0
- package/src/scope.ts +11 -0
- package/src/yarn.ts +58 -0
package/README.md
CHANGED
|
@@ -47,6 +47,27 @@ The claim, the per-project key, the memo and the `--affected` diff are core's `l
|
|
|
47
47
|
|
|
48
48
|
`vx run test --affected=origin/main` after a lockfile change used to select every project. With a plugin declared at `scope: 'project'`, core hands it the lockfile at the base ref and in the working tree; it digests both and names the projects whose digest moved. At `scope: 'workspace'` a change still selects every project. A lockfile that appeared or was deleted still selects everything — every project's `node_modules` is in question.
|
|
49
49
|
|
|
50
|
+
## `vx prune`
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
```sh
|
|
55
|
+
vx prune <project...> [--out-dir <dir>] [--docker]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The out dir (default `out/`, relative to the cwd) gets the root `package.json` with `workspaces` rewritten to the subset's dirs, `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:
|
|
59
|
+
|
|
60
|
+
```dockerfile
|
|
61
|
+
COPY out/json/ .
|
|
62
|
+
RUN bun install --frozen-lockfile
|
|
63
|
+
COPY out/full/ .
|
|
64
|
+
RUN bunx vx run app#build
|
|
65
|
+
```
|
|
66
|
+
|
|
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
|
+
|
|
69
|
+
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
|
+
|
|
50
71
|
## Testing
|
|
51
72
|
|
|
52
|
-
`bun test` covers each parser's digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals) and `vx run` / `vx why` / `--affected` end to end. vx's own repository declares `bun()`.
|
|
73
|
+
`bun test` covers each parser's digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals) and `vx run` / `vx why` / `--affected` end to end. `vx prune` is pinned on lockfiles each package manager wrote (bun 1.4, pnpm 10, npm 10, yarn 1 and 4) and end to end: the pruned lockfile is installed frozen and offline by bun always, and by pnpm, npm and yarn 1 where they are on PATH (yarn 1's `--frozen-lockfile` passes a lockfile missing entries, so its check is an install that leaves the file byte-identical). vx's own repository declares `bun()`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-lockfile",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.494",
|
|
4
4
|
"license": "MIT",
|
|
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.",
|
|
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": [
|
|
7
7
|
"vx",
|
|
8
8
|
"vx-plugin",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"pnpm",
|
|
12
12
|
"bun",
|
|
13
13
|
"npm",
|
|
14
|
-
"yarn"
|
|
14
|
+
"yarn",
|
|
15
|
+
"prune",
|
|
16
|
+
"docker"
|
|
15
17
|
],
|
|
16
18
|
"type": "module",
|
|
17
19
|
"main": "./src/index.ts",
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
"access": "public"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
|
38
|
-
"@vzn/vx": "^0.0.
|
|
40
|
+
"@vzn/vx": "^0.0.494"
|
|
39
41
|
},
|
|
40
42
|
"repository": {
|
|
41
43
|
"type": "git",
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Line-level pruning for the YAML-shaped lockfiles (pnpm-lock.yaml, yarn.lock
|
|
2
|
+
// berry and classic). Rewriting the parsed document would drop the package
|
|
3
|
+
// manager's own layout — its quoting, its blank lines, its key order — so a
|
|
4
|
+
// pruned file is the original with whole entries cut out, and a pruned
|
|
5
|
+
// lockfile diffs against the source as removals only.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `lines` with every entry at `indent` spaces between `from` and `to` that
|
|
9
|
+
* `keep` refuses cut out. An entry is its key line and every line after it
|
|
10
|
+
* up to the next key line at that indent; the blank lines separating
|
|
11
|
+
* entries are kept as they were between the survivors, and those after
|
|
12
|
+
* the last entry stay after the last survivor.
|
|
13
|
+
*/
|
|
14
|
+
export function pruneEntries(
|
|
15
|
+
lines: readonly string[],
|
|
16
|
+
from: number,
|
|
17
|
+
to: number,
|
|
18
|
+
indent: number,
|
|
19
|
+
keep: (keyLine: string) => boolean,
|
|
20
|
+
): string[] {
|
|
21
|
+
const pad = ' '.repeat(indent)
|
|
22
|
+
const isKey = (l: string): boolean =>
|
|
23
|
+
l.startsWith(pad) && l.length > indent && l[indent] !== ' ' && l[indent] !== '#'
|
|
24
|
+
const head: string[] = []
|
|
25
|
+
let i = from
|
|
26
|
+
while (i < to && !isKey(lines[i]!)) head.push(lines[i++]!)
|
|
27
|
+
const entries: string[][] = []
|
|
28
|
+
let separator: string[] | undefined
|
|
29
|
+
while (i < to) {
|
|
30
|
+
const entry = [lines[i++]!]
|
|
31
|
+
while (i < to && !isKey(lines[i]!)) entry.push(lines[i++]!)
|
|
32
|
+
entries.push(entry)
|
|
33
|
+
}
|
|
34
|
+
const blanks = (e: string[]): string[] => {
|
|
35
|
+
let n = e.length
|
|
36
|
+
while (n > 1 && e[n - 1]!.trim() === '') n--
|
|
37
|
+
return e.splice(n)
|
|
38
|
+
}
|
|
39
|
+
const tail = entries.length === 0 ? [] : blanks(entries.at(-1)!)
|
|
40
|
+
for (const e of entries.slice(0, -1)) {
|
|
41
|
+
const gap = blanks(e)
|
|
42
|
+
separator ??= gap
|
|
43
|
+
}
|
|
44
|
+
const kept = entries.filter((e) => keep(e[0]!))
|
|
45
|
+
const out = [...head]
|
|
46
|
+
for (const [n, e] of kept.entries()) {
|
|
47
|
+
if (n > 0) out.push(...(separator ?? []))
|
|
48
|
+
out.push(...e)
|
|
49
|
+
}
|
|
50
|
+
out.push(...tail)
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The key a YAML mapping line opens: ` 'a@1': {}` → `a@1`, ` .:` → `.`. */
|
|
55
|
+
export function yamlKey(line: string): string {
|
|
56
|
+
const parsed: unknown = Bun.YAML.parse(line.trim())
|
|
57
|
+
return parsed !== null && typeof parsed === 'object' ? (Object.keys(parsed)[0] ?? '') : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `text` with each top-level section named in `sections` pruned to the
|
|
62
|
+
* entries its predicate keeps; every other line as it was.
|
|
63
|
+
*/
|
|
64
|
+
export function pruneSections(
|
|
65
|
+
text: string,
|
|
66
|
+
sections: Readonly<Record<string, (key: string) => boolean>>,
|
|
67
|
+
): string {
|
|
68
|
+
const lines = text.split('\n')
|
|
69
|
+
const out: string[] = []
|
|
70
|
+
let i = 0
|
|
71
|
+
while (i < lines.length) {
|
|
72
|
+
const line = lines[i]!
|
|
73
|
+
out.push(line)
|
|
74
|
+
i++
|
|
75
|
+
const name = /^([A-Za-z]+):\s*$/.exec(line)?.[1]
|
|
76
|
+
const keep = name === undefined ? undefined : sections[name]
|
|
77
|
+
if (keep === undefined) continue
|
|
78
|
+
let end = i
|
|
79
|
+
while (end < lines.length && !/^\S/.test(lines[end]!)) end++
|
|
80
|
+
out.push(...pruneEntries(lines, i, end, 2, (l) => keep(yamlKey(l))))
|
|
81
|
+
i = end
|
|
82
|
+
}
|
|
83
|
+
return out.join('\n')
|
|
84
|
+
}
|
package/src/bun.ts
CHANGED
|
@@ -11,6 +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 type { PruneScope } from './scope.js'
|
|
14
15
|
|
|
15
16
|
export interface Lockfile {
|
|
16
17
|
readonly version: string
|
|
@@ -255,6 +256,83 @@ export function importerDigests(
|
|
|
255
256
|
return out
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The lockfile cut to the workspaces at `dirs` (`.` the root) and the
|
|
261
|
+
* packages they reach through Bun's hoisted walk, each where it sat: Bun
|
|
262
|
+
* installs a nested key whose root counterpart is gone as written
|
|
263
|
+
* (`--frozen-lockfile` passes), so nothing is re-hoisted. Every other
|
|
264
|
+
* field as written — catalogs, overrides, patches and trusted
|
|
265
|
+
* dependencies are the workspace's, and the root manifest still names them.
|
|
266
|
+
*/
|
|
267
|
+
export function pruneLockfile(text: string, { dirs }: PruneScope): string {
|
|
268
|
+
const lock = parseLockfile(text)
|
|
269
|
+
const doc = Bun.JSONC.parse(text) as Json
|
|
270
|
+
const reached = new Set<string>()
|
|
271
|
+
const visit = (key: string | undefined): void => {
|
|
272
|
+
if (key === undefined || reached.has(key)) return
|
|
273
|
+
reached.add(key)
|
|
274
|
+
const entry = lock.packages.get(key)!
|
|
275
|
+
const at = entry.id.indexOf('@workspace:')
|
|
276
|
+
if (at !== -1) {
|
|
277
|
+
const dir = entry.id.slice(at + '@workspace:'.length) || '.'
|
|
278
|
+
if (!dirs.has(dir))
|
|
279
|
+
throw new Error(`bun.lock: ${key} is workspace ${dir}, which the subset leaves out`)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
for (const name of entry.deps.keys()) visit(resolve(lock, key, name))
|
|
283
|
+
}
|
|
284
|
+
for (const [dir, deps] of lock.workspaces) {
|
|
285
|
+
if (!dirs.has(dir)) continue
|
|
286
|
+
let from = ''
|
|
287
|
+
for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir && dir !== '.') from = name
|
|
288
|
+
if (from !== '') visit(from)
|
|
289
|
+
for (const name of deps.keys()) visit(resolve(lock, from, name))
|
|
290
|
+
}
|
|
291
|
+
const workspaces = record(doc['workspaces']) ?? {}
|
|
292
|
+
for (const dir of Object.keys(workspaces))
|
|
293
|
+
if (!dirs.has(dir === '' ? '.' : dir)) delete workspaces[dir]
|
|
294
|
+
const packages = record(doc['packages']) ?? {}
|
|
295
|
+
for (const key of Object.keys(packages)) if (!reached.has(key)) delete packages[key]
|
|
296
|
+
return writeLockfile(doc)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Bun's own text layout, so a pruned file diffs against the source as
|
|
301
|
+
* removals: two-space indent, a trailing comma after every member but the
|
|
302
|
+
* document's last, each `packages` entry on one line with a blank line
|
|
303
|
+
* between entries.
|
|
304
|
+
*/
|
|
305
|
+
function writeLockfile(doc: Json): string {
|
|
306
|
+
const inline = (v: unknown): string => {
|
|
307
|
+
if (Array.isArray(v)) return `[${v.map(inline).join(', ')}]`
|
|
308
|
+
const r = record(v)
|
|
309
|
+
if (r === undefined) return JSON.stringify(v)
|
|
310
|
+
const members = Object.entries(r).map(([k, x]) => `${JSON.stringify(k)}: ${inline(x)}`)
|
|
311
|
+
return members.length === 0 ? '{}' : `{ ${members.join(', ')} }`
|
|
312
|
+
}
|
|
313
|
+
const block = (v: unknown, depth: number): string => {
|
|
314
|
+
const pad = ' '.repeat(depth + 1)
|
|
315
|
+
const end = ' '.repeat(depth)
|
|
316
|
+
if (Array.isArray(v)) {
|
|
317
|
+
return v.length === 0
|
|
318
|
+
? '[]'
|
|
319
|
+
: `[\n${v.map((x) => `${pad}${block(x, depth + 1)},\n`).join('')}${end}]`
|
|
320
|
+
}
|
|
321
|
+
const r = record(v)
|
|
322
|
+
if (r === undefined) return JSON.stringify(v)
|
|
323
|
+
const members = Object.entries(r)
|
|
324
|
+
if (members.length === 0) return '{}'
|
|
325
|
+
return `{\n${members.map(([k, x]) => `${pad}${JSON.stringify(k)}: ${block(x, depth + 1)},\n`).join('')}${end}}`
|
|
326
|
+
}
|
|
327
|
+
const members = Object.entries(doc).map(([k, v]) => {
|
|
328
|
+
if (k !== 'packages') return ` ${JSON.stringify(k)}: ${block(v, 1)}`
|
|
329
|
+
const entries = Object.entries(record(v) ?? {})
|
|
330
|
+
if (entries.length === 0) return ` "packages": {}`
|
|
331
|
+
return ` "packages": {\n${entries.map(([p, t]) => ` ${JSON.stringify(p)}: ${inline(t)},\n`).join('\n')} }`
|
|
332
|
+
})
|
|
333
|
+
return `{\n${members.join(',\n')}\n}\n`
|
|
334
|
+
}
|
|
335
|
+
|
|
258
336
|
/** The name a package key installs under: its last segment, scope included. */
|
|
259
337
|
function installName(key: string): string {
|
|
260
338
|
const parts = key.split('/')
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import * as pnpmLock from './pnpm.js'
|
|
|
26
26
|
import * as bunLock from './bun.js'
|
|
27
27
|
import * as npmLock from './npm.js'
|
|
28
28
|
import * as yarnLock from './yarn.js'
|
|
29
|
+
import { pruneCommand } from './prune.js'
|
|
29
30
|
|
|
30
31
|
export interface LockfileOptions {
|
|
31
32
|
/**
|
|
@@ -93,9 +94,9 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
93
94
|
return new Error(`${named} — regenerate it with \`${manager.name} install\``)
|
|
94
95
|
}
|
|
95
96
|
const extraFiles = manager.extraFiles
|
|
96
|
-
return definePlugin(
|
|
97
|
-
|
|
98
|
-
lockfileClaim({
|
|
97
|
+
return definePlugin(import.meta, {
|
|
98
|
+
commands: { prune: pruneCommand },
|
|
99
|
+
...lockfileClaim({
|
|
99
100
|
file: manager.file,
|
|
100
101
|
part: manager.name,
|
|
101
102
|
version: DIGEST_VERSION,
|
|
@@ -126,7 +127,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
126
127
|
}
|
|
127
128
|
},
|
|
128
129
|
}),
|
|
129
|
-
)
|
|
130
|
+
})
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/** Each option `LockfileOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
package/src/npm.ts
CHANGED
|
@@ -8,6 +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 type { PruneScope } from './scope.js'
|
|
11
12
|
|
|
12
13
|
export interface Lockfile {
|
|
13
14
|
readonly version: number
|
|
@@ -159,6 +160,49 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
|
|
|
159
160
|
return out
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The lockfile cut to the workspaces at `dirs` (`.` the root) and what
|
|
165
|
+
* they reach through Node's walk, each at the path it held; the root
|
|
166
|
+
* entry's `workspaces` becomes `workspaces`, the list the pruned root
|
|
167
|
+
* manifest carries. Version 2's legacy `dependencies` tree is cut to the
|
|
168
|
+
* same paths.
|
|
169
|
+
*/
|
|
170
|
+
export function pruneLockfile(text: string, scope: PruneScope): string {
|
|
171
|
+
const { dirs, members, workspaces } = scope
|
|
172
|
+
const lock = parseLockfile(text)
|
|
173
|
+
const doc = JSON.parse(text) as Json
|
|
174
|
+
const reached = new Set<string>()
|
|
175
|
+
const visit = (p: string | undefined): void => {
|
|
176
|
+
if (p === undefined || reached.has(p)) return
|
|
177
|
+
const e = lock.packages.get(p)!
|
|
178
|
+
if (members.has(p) && !dirs.has(p)) {
|
|
179
|
+
throw new Error(`package-lock.json: ${p} is a workspace the subset leaves out`)
|
|
180
|
+
}
|
|
181
|
+
reached.add(p)
|
|
182
|
+
if (e.link !== undefined) return visit(lock.packages.has(e.link) ? e.link : undefined)
|
|
183
|
+
for (const name of e.deps.keys()) visit(resolve(lock, p, name))
|
|
184
|
+
}
|
|
185
|
+
for (const [p, e] of lock.packages) {
|
|
186
|
+
const linked = e.link !== undefined && dirs.has(e.link)
|
|
187
|
+
if (linked || dirs.has(p === '' ? '.' : p)) visit(p)
|
|
188
|
+
}
|
|
189
|
+
const packages = record(doc['packages'])!
|
|
190
|
+
for (const p of Object.keys(packages)) if (!reached.has(p)) delete packages[p]
|
|
191
|
+
const rootEntry = record(packages[''])
|
|
192
|
+
if (rootEntry?.['workspaces'] !== undefined) rootEntry['workspaces'] = [...workspaces]
|
|
193
|
+
const prune = (tree: unknown, base: string): void => {
|
|
194
|
+
const deps = record(tree)
|
|
195
|
+
if (deps === undefined) return
|
|
196
|
+
for (const [name, sub] of Object.entries(deps)) {
|
|
197
|
+
const p = `${base}node_modules/${name}`
|
|
198
|
+
if (!reached.has(p)) delete deps[name]
|
|
199
|
+
else prune(record(sub)?.['dependencies'], `${p}/`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
prune(doc['dependencies'], '')
|
|
203
|
+
return `${JSON.stringify(doc, null, 2)}\n`
|
|
204
|
+
}
|
|
205
|
+
|
|
162
206
|
/** `node_modules/a/node_modules/@s/b` → `@s/b`. */
|
|
163
207
|
function installName(p: string): string {
|
|
164
208
|
const i = p.lastIndexOf('node_modules/')
|
package/src/pnpm.ts
CHANGED
|
@@ -12,6 +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 type { PruneScope } from './scope.js'
|
|
15
17
|
|
|
16
18
|
export interface Lockfile {
|
|
17
19
|
readonly version: string
|
|
@@ -280,6 +282,45 @@ function buildGraph(lock: Lockfile): Graph {
|
|
|
280
282
|
return { index, material, edges }
|
|
281
283
|
}
|
|
282
284
|
|
|
285
|
+
/**
|
|
286
|
+
* The lockfile cut to the importers at `dirs` (`.` the root) and the
|
|
287
|
+
* packages they reach; every other section as written — catalogs, patches,
|
|
288
|
+
* overrides and settings are the workspace's, and the subset's manifests
|
|
289
|
+
* still name them. A multi-document lockfile keeps the env document whole.
|
|
290
|
+
*/
|
|
291
|
+
export function pruneLockfile(text: string, { dirs }: PruneScope): string {
|
|
292
|
+
const marker = [...text.matchAll(/^---[ \t]*$/gm)].at(-1)
|
|
293
|
+
const cut = marker === undefined ? 0 : marker.index + marker[0].length
|
|
294
|
+
const lock = parseLockfile(text)
|
|
295
|
+
const reached = new Set<string>()
|
|
296
|
+
const visit = (dir: string, deps: ReadonlyMap<string, string>): void => {
|
|
297
|
+
for (const [name, version] of deps) {
|
|
298
|
+
if (version.startsWith('link:')) {
|
|
299
|
+
const target = joinPosix(dir, version.slice('link:'.length))
|
|
300
|
+
if (lock.importers.has(target) && !dirs.has(target)) {
|
|
301
|
+
throw new Error(`pnpm-lock.yaml: ${dir} links ${target}, which the subset leaves out`)
|
|
302
|
+
}
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
const key = snapshotKey(lock, name, version)
|
|
306
|
+
if (reached.has(key)) continue
|
|
307
|
+
reached.add(key)
|
|
308
|
+
visit('.', lock.snapshots.get(key) ?? new Map())
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const [dir, deps] of lock.importers) if (dirs.has(dir)) visit(dir, deps)
|
|
312
|
+
const v9 = Number.parseInt(lock.version, 10) >= 9
|
|
313
|
+
const packages = new Set([...reached].map((key) => (v9 ? packageKey(key) : key)))
|
|
314
|
+
return (
|
|
315
|
+
text.slice(0, cut) +
|
|
316
|
+
pruneSections(text.slice(cut), {
|
|
317
|
+
importers: (dir) => dirs.has(dir),
|
|
318
|
+
packages: (key) => packages.has(key),
|
|
319
|
+
snapshots: (key) => reached.has(key),
|
|
320
|
+
})
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
283
324
|
/** `packages/a` + `../b` → `packages/b`; `.` + `packages/a` → `packages/a`. POSIX, as the lockfile writes paths. */
|
|
284
325
|
function joinPosix(base: string, rel: string): string {
|
|
285
326
|
const parts: string[] = []
|
package/src/prune.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// `vx prune <project...> [--out-dir <dir>] [--docker]`: the workspace cut to
|
|
2
|
+
// the named projects and their transitive workspace dependencies, for a
|
|
3
|
+
// Docker build that installs and builds only what it ships (`turbo prune`).
|
|
4
|
+
// Every lockfile here is cut to what the subset installs, so the copy
|
|
5
|
+
// installs with a frozen lockfile and a lockfile line the subset cannot
|
|
6
|
+
// reach does not bust the install layer.
|
|
7
|
+
//
|
|
8
|
+
// <out>/ the subset (with --docker: <out>/full/)
|
|
9
|
+
// package.json `workspaces` rewritten to the subset's dirs
|
|
10
|
+
// pnpm-workspace.yaml `packages` rewritten the same way, the rest as written
|
|
11
|
+
// <lockfile> pruned
|
|
12
|
+
// vx.workspace.*, the root vx.config.*, install config, patch files
|
|
13
|
+
// <project dirs> whole, less node_modules / .git / .vx / .turbo
|
|
14
|
+
// <out>/json/ (--docker) the install layer: root install files and
|
|
15
|
+
// each project's package.json, nothing else
|
|
16
|
+
|
|
17
|
+
import { cp, mkdir, readdir, stat, writeFile } from 'node:fs/promises'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import {
|
|
20
|
+
buildPackageGraph,
|
|
21
|
+
listProjectMetas,
|
|
22
|
+
loadWorkspace,
|
|
23
|
+
UserError,
|
|
24
|
+
type CommandContext,
|
|
25
|
+
type PluginCommand,
|
|
26
|
+
type ProjectMeta,
|
|
27
|
+
} from '@vzn/vx'
|
|
28
|
+
import * as bunLock from './bun.js'
|
|
29
|
+
import * as npmLock from './npm.js'
|
|
30
|
+
import * as pnpmLock from './pnpm.js'
|
|
31
|
+
import * as yarnLock from './yarn.js'
|
|
32
|
+
import type { PruneScope } from './scope.js'
|
|
33
|
+
|
|
34
|
+
const USAGE = 'usage: vx prune <project...> [--out-dir <dir>] [--docker]'
|
|
35
|
+
|
|
36
|
+
const LOCKFILES: Readonly<Record<string, (text: string, scope: PruneScope) => string>> = {
|
|
37
|
+
'pnpm-lock.yaml': pnpmLock.pruneLockfile,
|
|
38
|
+
'bun.lock': bunLock.pruneLockfile,
|
|
39
|
+
'package-lock.json': npmLock.pruneLockfile,
|
|
40
|
+
'yarn.lock': yarnLock.pruneLockfile,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Root files an install reads, copied when present (the manifest and workspace file are rewritten). */
|
|
44
|
+
const INSTALL_FILES = [
|
|
45
|
+
'.npmrc',
|
|
46
|
+
'.yarnrc.yml',
|
|
47
|
+
'.pnpmfile.cjs',
|
|
48
|
+
'bunfig.toml',
|
|
49
|
+
'.nvmrc',
|
|
50
|
+
'.node-version',
|
|
51
|
+
]
|
|
52
|
+
const VX_FILES = [
|
|
53
|
+
'vx.workspace.ts',
|
|
54
|
+
'vx.workspace.mts',
|
|
55
|
+
'vx.workspace.js',
|
|
56
|
+
'vx.workspace.mjs',
|
|
57
|
+
'vx.config.ts',
|
|
58
|
+
'vx.config.mts',
|
|
59
|
+
'vx.config.js',
|
|
60
|
+
'vx.config.mjs',
|
|
61
|
+
'vx.config.cts',
|
|
62
|
+
'vx.config.cjs',
|
|
63
|
+
]
|
|
64
|
+
const COPY_EXCLUDES = new Set(['node_modules', '.git', '.vx', '.turbo'])
|
|
65
|
+
const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'] as const
|
|
66
|
+
/** `from '…'`, `import '…'`, `import('…')`: static specifiers only. */
|
|
67
|
+
const SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
|
|
68
|
+
|
|
69
|
+
/** One object for all four plugins, so a workspace declaring two still has one `prune`. */
|
|
70
|
+
export const pruneCommand: PluginCommand = {
|
|
71
|
+
description: 'copy projects and their workspace deps, lockfile pruned, for a Docker build',
|
|
72
|
+
run: (argv, ctx) => prune(argv, ctx),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Args {
|
|
76
|
+
readonly projects: readonly string[]
|
|
77
|
+
readonly outDir: string
|
|
78
|
+
readonly docker: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseArgs(argv: readonly string[]): Args | null {
|
|
82
|
+
const projects: string[] = []
|
|
83
|
+
let outDir = 'out'
|
|
84
|
+
let docker = false
|
|
85
|
+
for (let i = 0; i < argv.length; i++) {
|
|
86
|
+
const a = argv[i]!
|
|
87
|
+
if (a === '--help' || a === '-h') return null
|
|
88
|
+
if (a === '--docker') docker = true
|
|
89
|
+
else if (a === '--out-dir' || a.startsWith('--out-dir=')) {
|
|
90
|
+
const v = a === '--out-dir' ? argv[++i] : a.slice('--out-dir='.length)
|
|
91
|
+
if (v === undefined || v === '')
|
|
92
|
+
throw new UserError(`vx prune: --out-dir needs a path\n${USAGE}`)
|
|
93
|
+
outDir = v
|
|
94
|
+
} else if (a.startsWith('-')) throw new UserError(`vx prune: unknown flag ${a}\n${USAGE}`)
|
|
95
|
+
else projects.push(a)
|
|
96
|
+
}
|
|
97
|
+
if (projects.length === 0) throw new UserError(`vx prune: name at least one project\n${USAGE}`)
|
|
98
|
+
return { projects, outDir, docker }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function exists(p: string): Promise<boolean> {
|
|
102
|
+
return stat(p).then(
|
|
103
|
+
() => true,
|
|
104
|
+
() => false,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const posix = (p: string): string => p.split(path.sep).join('/')
|
|
109
|
+
|
|
110
|
+
/** `a` within `dir`, or `dir` itself. */
|
|
111
|
+
const within = (a: string, dir: string): boolean => a === dir || a.startsWith(dir + path.sep)
|
|
112
|
+
|
|
113
|
+
async function prune(argv: readonly string[], ctx: CommandContext): Promise<number> {
|
|
114
|
+
const args = parseArgs(argv)
|
|
115
|
+
if (args === null) {
|
|
116
|
+
process.stdout.write(`${USAGE}\n`)
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
const root = ctx.workspaceRoot
|
|
120
|
+
const projects = (await listProjectMetas(await loadWorkspace(root))).filter((p) => p.dir !== root)
|
|
121
|
+
const byName = new Map(projects.map((p) => [p.name, p]))
|
|
122
|
+
const unknown = args.projects.filter((n) => !byName.has(n))
|
|
123
|
+
if (unknown.length > 0) {
|
|
124
|
+
throw new UserError(
|
|
125
|
+
`vx prune: no project named ${unknown.map((n) => JSON.stringify(n)).join(', ')} (the root is always kept; name the projects under it)`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
const graph = buildPackageGraph(projects)
|
|
129
|
+
const names = new Set<string>()
|
|
130
|
+
const add = (name: string): void => {
|
|
131
|
+
if (names.has(name) || !byName.has(name)) return
|
|
132
|
+
names.add(name)
|
|
133
|
+
for (const d of graph.transitiveDeps(name)) add(d)
|
|
134
|
+
}
|
|
135
|
+
for (const n of args.projects) add(n)
|
|
136
|
+
// A vx config runs before any task, so a workspace package it imports (a
|
|
137
|
+
// local plugin) is as load-bearing as a dependency: without it `vx run`
|
|
138
|
+
// in the image cannot load the config. Pulled in with its closure, until
|
|
139
|
+
// no config names another.
|
|
140
|
+
const owner = (spec: string): ProjectMeta | undefined =>
|
|
141
|
+
projects.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))
|
|
142
|
+
const escapes: string[] = []
|
|
143
|
+
const scanned = new Set<string>()
|
|
144
|
+
const scan = async (dir: string, label: string): Promise<void> => {
|
|
145
|
+
for (const f of VX_FILES) {
|
|
146
|
+
const file = path.join(dir, f)
|
|
147
|
+
if (!(await exists(file))) continue
|
|
148
|
+
for (const [, spec] of (await Bun.file(file).text()).matchAll(SPECIFIER_RE)) {
|
|
149
|
+
if (spec!.startsWith('.')) {
|
|
150
|
+
const target = path.resolve(dir, spec!)
|
|
151
|
+
const kept =
|
|
152
|
+
[...names].some((n) => within(target, byName.get(n)!.dir)) ||
|
|
153
|
+
path.dirname(target) === root
|
|
154
|
+
if (!kept) escapes.push(`${label}: ${spec} resolves outside the subset`)
|
|
155
|
+
} else {
|
|
156
|
+
const p = owner(spec!)
|
|
157
|
+
if (p !== undefined) add(p.name)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
await scan(root, 'the workspace root')
|
|
163
|
+
for (let grew = true; grew;) {
|
|
164
|
+
grew = false
|
|
165
|
+
for (const n of [...names]) {
|
|
166
|
+
if (scanned.has(n)) continue
|
|
167
|
+
scanned.add(n)
|
|
168
|
+
grew = true
|
|
169
|
+
await scan(byName.get(n)!.dir, n)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const subset = [...names].map((n) => byName.get(n)!).sort((a, b) => a.name.localeCompare(b.name))
|
|
173
|
+
const rels = subset.map((p) => posix(path.relative(root, p.dir)))
|
|
174
|
+
|
|
175
|
+
const out = path.resolve(process.cwd(), args.outDir)
|
|
176
|
+
if (within(root, out)) {
|
|
177
|
+
throw new UserError(`vx prune: --out-dir ${args.outDir} is or contains the workspace root`)
|
|
178
|
+
}
|
|
179
|
+
for (const p of subset) {
|
|
180
|
+
if (within(out, p.dir)) {
|
|
181
|
+
throw new UserError(
|
|
182
|
+
`vx prune: --out-dir ${args.outDir} is inside ${p.name}, which the subset copies — pick a path outside it`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const held = await readdir(out).then(
|
|
187
|
+
(entries) => entries.length > 0,
|
|
188
|
+
(err: NodeJS.ErrnoException) => err.code !== 'ENOENT',
|
|
189
|
+
)
|
|
190
|
+
if (held) {
|
|
191
|
+
throw new UserError(
|
|
192
|
+
`vx prune: --out-dir ${args.outDir} already has content — remove it or name an empty directory`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rootManifest = JSON.parse(await Bun.file(path.join(root, 'package.json')).text()) as Record<
|
|
197
|
+
string,
|
|
198
|
+
unknown
|
|
199
|
+
>
|
|
200
|
+
const field = rootManifest['workspaces']
|
|
201
|
+
const listed = Array.isArray(field)
|
|
202
|
+
? (field as string[])
|
|
203
|
+
: ((field as { packages?: string[] } | undefined)?.packages ?? undefined)
|
|
204
|
+
const workspaces = listed?.includes('.') ? ['.', ...rels] : rels
|
|
205
|
+
if (listed !== undefined) {
|
|
206
|
+
rootManifest['workspaces'] = Array.isArray(field)
|
|
207
|
+
? workspaces
|
|
208
|
+
: { ...(field as object), packages: workspaces }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const manifests = new Map<string, ReadonlyMap<string, string>>()
|
|
212
|
+
type Deps = Partial<Record<(typeof DEP_FIELDS)[number], Record<string, string>>>
|
|
213
|
+
const depsOf = (pkg: Deps): ReadonlyMap<string, string> =>
|
|
214
|
+
new Map(DEP_FIELDS.flatMap((f) => Object.entries(pkg[f] ?? {})))
|
|
215
|
+
manifests.set('.', depsOf(rootManifest as Deps))
|
|
216
|
+
for (const [i, p] of subset.entries()) manifests.set(rels[i]!, depsOf(p.packageJson))
|
|
217
|
+
const scope: PruneScope = {
|
|
218
|
+
dirs: new Set(['.', ...rels]),
|
|
219
|
+
members: new Set(projects.map((p) => posix(path.relative(root, p.dir)))),
|
|
220
|
+
workspaces,
|
|
221
|
+
manifests,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
(await exists(path.join(root, 'bun.lockb'))) &&
|
|
226
|
+
!(await exists(path.join(root, 'bun.lock')))
|
|
227
|
+
) {
|
|
228
|
+
throw new UserError(
|
|
229
|
+
'vx prune: bun.lockb is binary and cannot be pruned — `bun install --save-text-lockfile` writes bun.lock',
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
const written = new Map<string, string>()
|
|
233
|
+
for (const [file, pruneLock] of Object.entries(LOCKFILES)) {
|
|
234
|
+
const src = path.join(root, file)
|
|
235
|
+
if (!(await exists(src))) continue
|
|
236
|
+
try {
|
|
237
|
+
written.set(file, pruneLock(await Bun.file(src).text(), scope))
|
|
238
|
+
} catch (err) {
|
|
239
|
+
throw new UserError(`vx prune: ${err instanceof Error ? err.message : String(err)}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const workspaceYaml = path.join(root, 'pnpm-workspace.yaml')
|
|
243
|
+
if (await exists(workspaceYaml)) {
|
|
244
|
+
written.set(
|
|
245
|
+
'pnpm-workspace.yaml',
|
|
246
|
+
rewriteWorkspaceYaml(await Bun.file(workspaceYaml).text(), rels),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
// A manifest with no `workspaces` (pnpm's) is copied byte for byte.
|
|
250
|
+
if (listed !== undefined)
|
|
251
|
+
written.set('package.json', `${JSON.stringify(rootManifest, null, 2)}\n`)
|
|
252
|
+
const installFiles = [
|
|
253
|
+
...(listed === undefined ? ['package.json'] : []),
|
|
254
|
+
...INSTALL_FILES,
|
|
255
|
+
...(await namedFiles(root, rootManifest)),
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
const emit = async (dest: string, extra: readonly string[]): Promise<void> => {
|
|
259
|
+
await mkdir(dest, { recursive: true })
|
|
260
|
+
for (const [file, text] of written) await writeFile(path.join(dest, file), text)
|
|
261
|
+
for (const f of [...installFiles, ...extra]) {
|
|
262
|
+
const src = path.join(root, f)
|
|
263
|
+
if (!(await exists(src))) continue
|
|
264
|
+
await mkdir(path.dirname(path.join(dest, f)), { recursive: true })
|
|
265
|
+
await cp(src, path.join(dest, f), { recursive: true })
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const full = args.docker ? path.join(out, 'full') : out
|
|
269
|
+
await emit(full, VX_FILES)
|
|
270
|
+
for (const [i, p] of subset.entries()) {
|
|
271
|
+
await cp(p.dir, path.join(full, rels[i]!), {
|
|
272
|
+
recursive: true,
|
|
273
|
+
filter: (src) => !COPY_EXCLUDES.has(path.basename(src)),
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
if (args.docker) {
|
|
277
|
+
const json = path.join(out, 'json')
|
|
278
|
+
await emit(json, [])
|
|
279
|
+
for (const rel of rels) {
|
|
280
|
+
await mkdir(path.join(json, rel), { recursive: true })
|
|
281
|
+
await cp(path.join(root, rel, 'package.json'), path.join(json, rel, 'package.json'))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const lockfiles = Object.keys(LOCKFILES).filter((f) => written.has(f))
|
|
286
|
+
process.stdout.write(
|
|
287
|
+
`vx prune: ${subset.length} project${subset.length === 1 ? '' : 's'} → ${args.outDir}` +
|
|
288
|
+
`${args.docker ? ' (json/ + full/)' : ''}` +
|
|
289
|
+
`${lockfiles.length > 0 ? `, ${lockfiles.join(', ')} pruned` : ''}\n` +
|
|
290
|
+
subset.map((p, i) => ` ${p.name} (${rels[i]})\n`).join(''),
|
|
291
|
+
)
|
|
292
|
+
for (const e of escapes) process.stderr.write(`vx prune: warning: ${e}\n`)
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** `packages:` cut to the subset's dirs; every other key as written. */
|
|
297
|
+
function rewriteWorkspaceYaml(text: string, rels: readonly string[]): string {
|
|
298
|
+
const lines = text.split('\n')
|
|
299
|
+
const list = ['packages:', ...rels.map((r) => ` - ${JSON.stringify(r)}`)]
|
|
300
|
+
const at = lines.findIndex((l) => /^packages\s*:/.test(l))
|
|
301
|
+
if (at === -1) return [...list, ...lines].join('\n')
|
|
302
|
+
let end = at + 1
|
|
303
|
+
if (/^packages\s*:\s*$/.test(lines[at]!)) {
|
|
304
|
+
while (end < lines.length && (lines[end] === '' || /^[\s-]/.test(lines[end]!))) end++
|
|
305
|
+
while (end > at + 1 && lines[end - 1]!.trim() === '') end--
|
|
306
|
+
}
|
|
307
|
+
return [...lines.slice(0, at), ...list, ...lines.slice(end)].join('\n')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Root files the install config names: patch files (bun's and pnpm's
|
|
312
|
+
* `patchedDependencies`, in the manifest or pnpm-workspace.yaml) and
|
|
313
|
+
* Yarn's `yarnPath` and plugins. A path outside the root is not the
|
|
314
|
+
* workspace's to copy.
|
|
315
|
+
*/
|
|
316
|
+
async function namedFiles(root: string, manifest: Record<string, unknown>): Promise<string[]> {
|
|
317
|
+
const values = (v: unknown): unknown[] =>
|
|
318
|
+
v !== null && typeof v === 'object' ? Object.values(v as Record<string, unknown>) : []
|
|
319
|
+
const named: unknown[] = [
|
|
320
|
+
...values(manifest['patchedDependencies']),
|
|
321
|
+
...values((manifest['pnpm'] as Record<string, unknown> | undefined)?.['patchedDependencies']),
|
|
322
|
+
]
|
|
323
|
+
const yaml = async (file: string): Promise<Record<string, unknown>> => {
|
|
324
|
+
const p = path.join(root, file)
|
|
325
|
+
if (!(await exists(p))) return {}
|
|
326
|
+
const doc: unknown = Bun.YAML.parse(await Bun.file(p).text())
|
|
327
|
+
return doc !== null && typeof doc === 'object' && !Array.isArray(doc)
|
|
328
|
+
? (doc as Record<string, unknown>)
|
|
329
|
+
: {}
|
|
330
|
+
}
|
|
331
|
+
named.push(...values((await yaml('pnpm-workspace.yaml'))['patchedDependencies']))
|
|
332
|
+
const yarnrc = await yaml('.yarnrc.yml')
|
|
333
|
+
named.push(yarnrc['yarnPath'])
|
|
334
|
+
for (const p of Array.isArray(yarnrc['plugins']) ? yarnrc['plugins'] : []) {
|
|
335
|
+
named.push(typeof p === 'string' ? p : (p as Record<string, unknown> | null)?.['path'])
|
|
336
|
+
}
|
|
337
|
+
return named.filter(
|
|
338
|
+
(f): f is string =>
|
|
339
|
+
typeof f === 'string' && !path.isAbsolute(f) && !path.normalize(f).startsWith('..'),
|
|
340
|
+
)
|
|
341
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** What a lockfile is pruned to. Dirs are workspace-relative, POSIX, `.` the root. */
|
|
2
|
+
export interface PruneScope {
|
|
3
|
+
/** The workspaces kept: the root and every pruned project. */
|
|
4
|
+
readonly dirs: ReadonlySet<string>
|
|
5
|
+
/** Every workspace project, kept or not: a path the lockfile reaches that is one of these and not kept is a broken subset. */
|
|
6
|
+
readonly members: ReadonlySet<string>
|
|
7
|
+
/** The root manifest's rewritten `workspaces` list. */
|
|
8
|
+
readonly workspaces: readonly string[]
|
|
9
|
+
/** Each kept dir's manifest dependencies (every bucket): yarn classic records no workspaces, so its walk starts here. */
|
|
10
|
+
readonly manifests: ReadonlyMap<string, ReadonlyMap<string, string>>
|
|
11
|
+
}
|
package/src/yarn.ts
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
// lockfile's root descriptors only, so a workspace's digest is the root's
|
|
12
12
|
// reach through the descriptors the file resolves.
|
|
13
13
|
|
|
14
|
+
import path from 'node:path'
|
|
14
15
|
import { reachDigests } from '@vzn/vx'
|
|
15
16
|
import { stable } from './pnpm.js'
|
|
17
|
+
import { pruneEntries, yamlKey } from './blocks.js'
|
|
18
|
+
import type { PruneScope } from './scope.js'
|
|
16
19
|
|
|
17
20
|
export interface Lockfile {
|
|
18
21
|
readonly generation: 'berry' | 'classic'
|
|
@@ -36,6 +39,8 @@ export interface Lockfile {
|
|
|
36
39
|
|
|
37
40
|
export interface Entry {
|
|
38
41
|
readonly deps: ReadonlyMap<string, string>
|
|
42
|
+
/** What the entry installs: `deps` less berry's peers, which its parent provides. */
|
|
43
|
+
readonly installs: ReadonlyMap<string, string>
|
|
39
44
|
/** resolution + checksum / version + resolved + integrity */
|
|
40
45
|
readonly resolution: string
|
|
41
46
|
}
|
|
@@ -79,6 +84,7 @@ function parseBerry(text: string): Lockfile {
|
|
|
79
84
|
)
|
|
80
85
|
entries.set(resolution, {
|
|
81
86
|
deps: depsOf(e, ['dependencies', 'peerDependencies']),
|
|
87
|
+
installs: depsOf(e, ['dependencies']),
|
|
82
88
|
resolution: `${resolution}\0${stable(material)}`,
|
|
83
89
|
})
|
|
84
90
|
// Yarn joins a key's descriptors with ', '; a range's own trailing
|
|
@@ -136,6 +142,7 @@ function parseClassic(text: string): Lockfile {
|
|
|
136
142
|
// to carry that edge, so each entry carries its own descriptors.
|
|
137
143
|
entries.set(id, {
|
|
138
144
|
deps,
|
|
145
|
+
installs: deps,
|
|
139
146
|
resolution: `${fields['version'] ?? ''}\0${fields['resolved'] ?? ''}\0${fields['integrity'] ?? ''}\0${[...keys].sort().join(',')}`,
|
|
140
147
|
})
|
|
141
148
|
for (const k of keys) descriptors.set(k, id)
|
|
@@ -292,3 +299,54 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
|
|
|
292
299
|
for (const [dir, id] of lock.workspaces) out.set(dir, fold(digests[index.get(id)!]!))
|
|
293
300
|
return out
|
|
294
301
|
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The lockfile cut to the entries the workspaces at `dirs` install, each
|
|
305
|
+
* entry's text as written. Berry walks from the kept workspace entries;
|
|
306
|
+
* classic records no workspaces, so its walk starts at each kept
|
|
307
|
+
* manifest's dependencies (a workspace sibling has no entry and is
|
|
308
|
+
* skipped).
|
|
309
|
+
*/
|
|
310
|
+
export function pruneLockfile(text: string, scope: PruneScope): string {
|
|
311
|
+
const { dirs, members, manifests } = scope
|
|
312
|
+
const lock = parseLockfile(text)
|
|
313
|
+
const reached = new Set<string>()
|
|
314
|
+
const visit = (id: string | undefined): void => {
|
|
315
|
+
if (id === undefined || reached.has(id)) return
|
|
316
|
+
const ws = id.indexOf('@workspace:')
|
|
317
|
+
const dir = ws === -1 ? undefined : id.slice(ws + '@workspace:'.length)
|
|
318
|
+
if (dir !== undefined && members.has(dir) && !dirs.has(dir)) {
|
|
319
|
+
throw new Error(`yarn.lock: ${id} is a workspace the subset leaves out`)
|
|
320
|
+
}
|
|
321
|
+
reached.add(id)
|
|
322
|
+
for (const [name, range] of lock.entries.get(id)!.installs) {
|
|
323
|
+
for (const target of resolveDescriptor(lock, id, name, range)) visit(target)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const berry = lock.generation === 'berry'
|
|
327
|
+
if (berry) {
|
|
328
|
+
for (const [dir, id] of lock.workspaces) if (dirs.has(dir)) visit(id)
|
|
329
|
+
} else {
|
|
330
|
+
for (const [dir, deps] of manifests) {
|
|
331
|
+
if (!dirs.has(dir)) continue
|
|
332
|
+
for (const [name, range] of deps) {
|
|
333
|
+
// Classic keys a workspace's `file:` / `link:` path from the root:
|
|
334
|
+
// `file:./vendor/x` in packages/a is `file:./packages/a/vendor/x`.
|
|
335
|
+
const local = /^(file|link):(?!\/)(.*)$/.exec(range)
|
|
336
|
+
const keyed =
|
|
337
|
+
local === null || dir === '.' ? range : `${local[1]}:./${path.posix.join(dir, local[2]!)}`
|
|
338
|
+
visit(lock.descriptors.get(`${name}@${keyed}`))
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const lines = text.split('\n')
|
|
343
|
+
const first = (line: string): string =>
|
|
344
|
+
berry
|
|
345
|
+
? yamlKey(line).split(',')[0]!.trimStart()
|
|
346
|
+
: unquote(line.replace(/:\s*$/, '').split(',')[0]!.trim())
|
|
347
|
+
return pruneEntries(lines, 0, lines.length, 0, (line) => {
|
|
348
|
+
if (line.startsWith('__metadata:')) return true
|
|
349
|
+
const id = lock.descriptors.get(first(line))
|
|
350
|
+
return id !== undefined && reached.has(id)
|
|
351
|
+
}).join('\n')
|
|
352
|
+
}
|