@vzn/vx-lockfile 0.0.636 → 0.0.638

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/src/prune.ts DELETED
@@ -1,400 +0,0 @@
1
- // `vx prune <project...> [--out-dir <dir>] [--docker] [--production]`: 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. `--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`).
10
- //
11
- // <out>/ the subset (with --docker: <out>/full/)
12
- // package.json `workspaces` rewritten to the subset's dirs
13
- // pnpm-workspace.yaml `packages` rewritten the same way, the rest as written
14
- // <lockfile> pruned
15
- // vx.workspace.*, the root vx.config.*, install config, patch files
16
- // <project dirs> whole, less node_modules / .git / .vx / .turbo
17
- // <out>/json/ (--docker) the install layer: root install files and
18
- // each project's package.json, nothing else
19
-
20
- import { cp, mkdir, readdir, stat, writeFile } from 'node:fs/promises'
21
- import path from 'node:path'
22
- import {
23
- buildPackageGraph,
24
- listProjectMetas,
25
- loadWorkspace,
26
- UserError,
27
- type CommandContext,
28
- type ProjectMeta,
29
- } from '@vzn/vx'
30
- import * as bunLock from './bun.js'
31
- import { record } from './json.js'
32
- import * as npmLock from './npm.js'
33
- import * as pnpmLock from './pnpm.js'
34
- import * as yarnLock from './yarn.js'
35
- import type { PruneScope } from './scope.js'
36
-
37
- const USAGE = 'usage: vx prune <project...> [--out-dir <dir>] [--docker] [--production]'
38
-
39
- const LOCKFILES: Readonly<Record<string, (text: string, scope: PruneScope) => string>> = {
40
- 'pnpm-lock.yaml': pnpmLock.pruneLockfile,
41
- 'bun.lock': bunLock.pruneLockfile,
42
- 'package-lock.json': npmLock.pruneLockfile,
43
- 'yarn.lock': yarnLock.pruneLockfile,
44
- }
45
-
46
- /** Root files an install reads, copied when present (the manifest and workspace file are rewritten). */
47
- const INSTALL_FILES = [
48
- '.npmrc',
49
- '.yarnrc.yml',
50
- '.pnpmfile.cjs',
51
- 'bunfig.toml',
52
- '.nvmrc',
53
- '.node-version',
54
- ]
55
- const VX_FILES = [
56
- 'vx.workspace.ts',
57
- 'vx.workspace.mts',
58
- 'vx.workspace.js',
59
- 'vx.workspace.mjs',
60
- 'vx.config.ts',
61
- 'vx.config.mts',
62
- 'vx.config.js',
63
- 'vx.config.mjs',
64
- 'vx.config.cts',
65
- 'vx.config.cjs',
66
- ]
67
- const COPY_EXCLUDES = new Set(['node_modules', '.git', '.vx', '.turbo'])
68
- const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'] as const
69
- /** `from '…'`, `import '…'`, `import('…')`: static specifiers only. */
70
- const SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
71
-
72
- interface Args {
73
- readonly projects: readonly string[]
74
- readonly outDir: string
75
- readonly docker: boolean
76
- readonly production: boolean
77
- }
78
-
79
- function parseArgs(argv: readonly string[]): Args | null {
80
- const projects: string[] = []
81
- let outDir = 'out'
82
- let docker = false
83
- let production = false
84
- for (let i = 0; i < argv.length; i++) {
85
- const a = argv[i]!
86
- if (a === '--help' || a === '-h') return null
87
- if (a === '--docker') docker = true
88
- else if (a === '--production') production = 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
- // `--out-dir --docker` wrote to a directory named `--docker` and lost
94
- // docker mode; a path that starts with '-' is spelt `--out-dir=-x`.
95
- if (a === '--out-dir' && v.startsWith('-'))
96
- throw new UserError(`vx prune: --out-dir needs a path, got the flag ${v}\n${USAGE}`)
97
- outDir = v
98
- } else if (a.startsWith('-')) throw new UserError(`vx prune: unknown flag ${a}\n${USAGE}`)
99
- else projects.push(a)
100
- }
101
- if (projects.length === 0) throw new UserError(`vx prune: name at least one project\n${USAGE}`)
102
- return { projects, outDir, docker, production }
103
- }
104
-
105
- async function exists(p: string): Promise<boolean> {
106
- return stat(p).then(
107
- () => true,
108
- () => false,
109
- )
110
- }
111
-
112
- const posix = (p: string): string => p.split(path.sep).join('/')
113
-
114
- /** `a` within `dir`, or `dir` itself. */
115
- const within = (a: string, dir: string): boolean => a === dir || a.startsWith(dir + path.sep)
116
-
117
- export async function prune(argv: readonly string[], ctx: CommandContext): Promise<number> {
118
- const args = parseArgs(argv)
119
- if (args === null) {
120
- process.stdout.write(`${USAGE}\n`)
121
- return 0
122
- }
123
- const root = ctx.workspaceRoot
124
- const projects = (await listProjectMetas(await loadWorkspace(root))).filter((p) => p.dir !== root)
125
- const byName = new Map(projects.map((p) => [p.name, p]))
126
- const unknown = args.projects.filter((n) => !byName.has(n))
127
- if (unknown.length > 0) {
128
- throw new UserError(
129
- `vx prune: no project named ${unknown.map((n) => JSON.stringify(n)).join(', ')} (the root is always kept; name the projects under it)`,
130
- )
131
- }
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
- )
140
- const names = new Set<string>()
141
- const add = (name: string): void => {
142
- if (names.has(name) || !byName.has(name)) return
143
- names.add(name)
144
- for (const d of graph.transitiveDeps(name)) add(d)
145
- }
146
- for (const n of args.projects) add(n)
147
- // A vx config runs before any task, so a workspace package it imports (a
148
- // local plugin) is as load-bearing as a dependency: without it `vx run`
149
- // in the image cannot load the config. Pulled in with its closure, until
150
- // no config names another.
151
- const owner = (spec: string): ProjectMeta | undefined =>
152
- projects.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))
153
- const escapes: string[] = []
154
- const scanned = new Set<string>()
155
- const scan = async (dir: string, label: string): Promise<void> => {
156
- for (const f of VX_FILES) {
157
- const file = path.join(dir, f)
158
- if (!(await exists(file))) continue
159
- for (const [, spec] of (await Bun.file(file).text()).matchAll(SPECIFIER_RE)) {
160
- if (spec!.startsWith('.')) {
161
- const target = path.resolve(dir, spec!)
162
- const kept =
163
- [...names].some((n) => within(target, byName.get(n)!.dir)) ||
164
- path.dirname(target) === root
165
- if (!kept) escapes.push(`${label}: ${spec} resolves outside the subset`)
166
- } else {
167
- const p = owner(spec!)
168
- if (p !== undefined) add(p.name)
169
- }
170
- }
171
- }
172
- }
173
- await scan(root, 'the workspace root')
174
- for (let grew = true; grew;) {
175
- grew = false
176
- for (const n of [...names]) {
177
- if (scanned.has(n)) continue
178
- scanned.add(n)
179
- grew = true
180
- await scan(byName.get(n)!.dir, n)
181
- }
182
- }
183
- const subset = [...names].map((n) => byName.get(n)!).sort((a, b) => a.name.localeCompare(b.name))
184
- const rels = subset.map((p) => posix(path.relative(root, p.dir)))
185
-
186
- const out = path.resolve(process.cwd(), args.outDir)
187
- if (within(root, out)) {
188
- throw new UserError(`vx prune: --out-dir ${args.outDir} is or contains the workspace root`)
189
- }
190
- for (const p of subset) {
191
- if (within(out, p.dir)) {
192
- throw new UserError(
193
- `vx prune: --out-dir ${args.outDir} is inside ${p.name}, which the subset copies — pick a path outside it`,
194
- )
195
- }
196
- }
197
- const held = await readdir(out).then(
198
- (entries) => entries.length > 0,
199
- (err: NodeJS.ErrnoException) => err.code !== 'ENOENT',
200
- )
201
- if (held) {
202
- throw new UserError(
203
- `vx prune: --out-dir ${args.outDir} already has content — remove it or name an empty directory`,
204
- )
205
- }
206
-
207
- const rootManifest = JSON.parse(await Bun.file(path.join(root, 'package.json')).text()) as Record<
208
- string,
209
- unknown
210
- >
211
- const field = rootManifest['workspaces']
212
- const listed = Array.isArray(field)
213
- ? (field as string[])
214
- : ((field as { packages?: string[] } | undefined)?.packages ?? undefined)
215
- const patterns = rels.map(literalGlob)
216
- const workspaces = listed?.includes('.') ? ['.', ...patterns] : patterns
217
- if (listed !== undefined) {
218
- rootManifest['workspaces'] = Array.isArray(field)
219
- ? workspaces
220
- : { ...(field as object), packages: workspaces }
221
- }
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
- }
246
- const manifests = new Map<string, ReadonlyMap<string, string>>()
247
- type Deps = Partial<Record<(typeof DEP_FIELDS)[number], Record<string, string>>>
248
- const depsOf = (pkg: Deps): ReadonlyMap<string, string> =>
249
- new Map(DEP_FIELDS.flatMap((f) => Object.entries(pkg[f] ?? {})))
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
- }
254
- const scope: PruneScope = {
255
- dirs: new Set(['.', ...rels]),
256
- members: new Set(projects.map((p) => posix(path.relative(root, p.dir)))),
257
- workspaces,
258
- manifests,
259
- dropped,
260
- }
261
-
262
- if (
263
- (await exists(path.join(root, 'bun.lockb'))) &&
264
- !(await exists(path.join(root, 'bun.lock')))
265
- ) {
266
- throw new UserError(
267
- 'vx prune: bun.lockb is binary and cannot be pruned — `bun install --save-text-lockfile` writes bun.lock',
268
- )
269
- }
270
- const written = new Map<string, string>()
271
- for (const [file, pruneLock] of Object.entries(LOCKFILES)) {
272
- const src = path.join(root, file)
273
- if (!(await exists(src))) continue
274
- try {
275
- written.set(file, pruneLock(await Bun.file(src).text(), scope))
276
- } catch (err) {
277
- throw new UserError(`vx prune: ${err instanceof Error ? err.message : String(err)}`)
278
- }
279
- }
280
- const workspaceYaml = path.join(root, 'pnpm-workspace.yaml')
281
- if (await exists(workspaceYaml)) {
282
- written.set(
283
- 'pnpm-workspace.yaml',
284
- rewriteWorkspaceYaml(await Bun.file(workspaceYaml).text(), patterns),
285
- )
286
- }
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`)
291
- const installFiles = [
292
- ...(rootOut === undefined ? ['package.json'] : []),
293
- ...INSTALL_FILES,
294
- ...(await namedFiles(root, rootManifest)),
295
- ]
296
-
297
- const emit = async (dest: string, extra: readonly string[]): Promise<void> => {
298
- await mkdir(dest, { recursive: true })
299
- for (const [file, text] of written) await writeFile(path.join(dest, file), text)
300
- for (const f of [...installFiles, ...extra]) {
301
- const src = path.join(root, f)
302
- if (!(await exists(src))) continue
303
- await mkdir(path.dirname(path.join(dest, f)), { recursive: true })
304
- await cp(src, path.join(dest, f), { recursive: true })
305
- }
306
- }
307
- const full = args.docker ? path.join(out, 'full') : out
308
- await emit(full, VX_FILES)
309
- for (const [i, p] of subset.entries()) {
310
- await cp(p.dir, path.join(full, rels[i]!), {
311
- recursive: true,
312
- filter: (src) => !COPY_EXCLUDES.has(path.basename(src)),
313
- })
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
- }
323
- if (args.docker) {
324
- const json = path.join(out, 'json')
325
- await emit(json, [])
326
- for (const rel of rels) {
327
- await mkdir(path.join(json, rel), { recursive: true })
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'))
331
- }
332
- }
333
-
334
- const lockfiles = Object.keys(LOCKFILES).filter((f) => written.has(f))
335
- process.stdout.write(
336
- `vx prune: ${subset.length} project${subset.length === 1 ? '' : 's'} → ${args.outDir}` +
337
- `${args.docker ? ' (json/ + full/)' : ''}` +
338
- `${args.production ? ' (production)' : ''}` +
339
- `${lockfiles.length > 0 ? `, ${lockfiles.join(', ')} pruned` : ''}\n` +
340
- subset.map((p, i) => ` ${p.name} (${rels[i]})\n`).join(''),
341
- )
342
- for (const e of escapes) process.stderr.write(`vx prune: warning: ${e}\n`)
343
- return 0
344
- }
345
-
346
- /**
347
- * A dir as a workspace pattern that matches only itself: every package
348
- * manager and vx read the list as globs, and `packages/a[1]` matched
349
- * `packages/a1`, so the pruned copy had no such member. A class of one
350
- * (`[[]`) is the escape bun, npm, pnpm, yarn and `Bun.Glob` all read;
351
- * a backslash is not (bun found no workspace).
352
- */
353
- function literalGlob(rel: string): string {
354
- return rel.replace(/[*?[{]/g, '[$&]')
355
- }
356
-
357
- /** `packages:` cut to the subset's member patterns; every other key as written. */
358
- function rewriteWorkspaceYaml(text: string, members: readonly string[]): string {
359
- const lines = text.split('\n')
360
- const list = ['packages:', ...members.map((r) => ` - ${JSON.stringify(r)}`)]
361
- const at = lines.findIndex((l) => /^packages\s*:/.test(l))
362
- if (at === -1) return [...list, ...lines].join('\n')
363
- let end = at + 1
364
- if (/^packages\s*:\s*$/.test(lines[at]!)) {
365
- while (end < lines.length && (lines[end] === '' || /^[\s-]/.test(lines[end]!))) end++
366
- while (end > at + 1 && lines[end - 1]!.trim() === '') end--
367
- }
368
- return [...lines.slice(0, at), ...list, ...lines.slice(end)].join('\n')
369
- }
370
-
371
- /**
372
- * Root files the install config names: patch files (bun's and pnpm's
373
- * `patchedDependencies`, in the manifest or pnpm-workspace.yaml) and
374
- * Yarn's `yarnPath` and plugins. A path outside the root is not the
375
- * workspace's to copy.
376
- */
377
- async function namedFiles(root: string, manifest: Record<string, unknown>): Promise<string[]> {
378
- const values = (v: unknown): unknown[] =>
379
- v !== null && typeof v === 'object' ? Object.values(v as Record<string, unknown>) : []
380
- const named: unknown[] = [
381
- ...values(manifest['patchedDependencies']),
382
- ...values((manifest['pnpm'] as Record<string, unknown> | undefined)?.['patchedDependencies']),
383
- ]
384
- const yaml = async (file: string): Promise<Record<string, unknown>> => {
385
- const p = path.join(root, file)
386
- if (!(await exists(p))) return {}
387
- const doc: unknown = Bun.YAML.parse(await Bun.file(p).text())
388
- return record(doc) ?? {}
389
- }
390
- named.push(...values((await yaml('pnpm-workspace.yaml'))['patchedDependencies']))
391
- const yarnrc = await yaml('.yarnrc.yml')
392
- named.push(yarnrc['yarnPath'])
393
- for (const p of Array.isArray(yarnrc['plugins']) ? yarnrc['plugins'] : []) {
394
- named.push(typeof p === 'string' ? p : (p as Record<string, unknown> | null)?.['path'])
395
- }
396
- return named.filter(
397
- (f): f is string =>
398
- typeof f === 'string' && !path.isAbsolute(f) && !path.normalize(f).startsWith('..'),
399
- )
400
- }
package/src/scope.ts DELETED
@@ -1,17 +0,0 @@
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
- /**
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>
17
- }