@orkestrel/scaffold 0.0.100 → 0.0.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +36 -12
  39. package/dist/host/configs/helpers.ts +214 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/dotfiles/gitignore +1 -0
  42. package/dist/host/dotfiles/oxlintrc.json +10 -0
  43. package/dist/host/guides/scaffold.md +244 -170
  44. package/dist/host/manifest.json +37 -31
  45. package/dist/host/tests/config.test.ts +1305 -619
  46. package/dist/host/tests/policy.test.ts +19 -0
  47. package/dist/host/tests/setupPolicy.ts +1710 -995
  48. package/dist/src/core/index.cjs +1595 -1686
  49. package/dist/src/core/index.cjs.map +1 -1
  50. package/dist/src/core/index.d.cts +202 -18
  51. package/dist/src/core/index.d.ts +202 -18
  52. package/dist/src/core/index.js +1590 -1687
  53. package/dist/src/core/index.js.map +1 -1
  54. package/dist/src/server/index.cjs +448 -392
  55. package/dist/src/server/index.cjs.map +1 -1
  56. package/dist/src/server/index.d.cts +1182 -1052
  57. package/dist/src/server/index.d.ts +1182 -1052
  58. package/dist/src/server/index.js +447 -395
  59. package/dist/src/server/index.js.map +1 -1
  60. package/package.json +2 -2
@@ -0,0 +1,1655 @@
1
+ // Pin a consumer to fresh local tarballs of the packages it depends on, report every pin against the
2
+ // ledger that records it, and restore the ranges the pins replaced. Run from any directory:
3
+ // node .agents/skills/orkestrel-publish/scripts/swap.ts --pin --consumer DIR --source DIR [--source DIR ...] [--override NAME ...] [--no-build] [--dry-run] [--json]
4
+ // node .agents/skills/orkestrel-publish/scripts/swap.ts --restore --consumer DIR [--package NAME ...] [--json]
5
+ // node .agents/skills/orkestrel-publish/scripts/swap.ts --status --consumer DIR [--json]
6
+ // Give exactly one of --pin, --restore, and --status; --source, --override, and --package repeat.
7
+ // --pin refuses a source whose packed manifest names a local dependency in any dependency field:
8
+ // npm's local forms, a file: or link: prefix, a path start (., ~/, /, \, or a drive letter), or an
9
+ // archive name ending in .tgz, .tar.gz, or .tar that carries no protocol and is not a user/repo
10
+ // shorthand. It builds each source (`npm run build`, unless --no-build), and packs it with
11
+ // --ignore-scripts, a source that carries a ledger of its own packing with the ranges that ledger
12
+ // replaced. Each archive moves to
13
+ // <consumer>/tmp/tarballs/<scope>-<name>-<version>-<12 hex of its SHA-256>.tgz. The ledger
14
+ // <consumer>/tmp/tarballs/pins.json records the replaced range, the tarball and its SHA-256, the
15
+ // source with its commit and dirty flag, whether the overrides block existed before the first pin,
16
+ // and the time, and is written before the manifest. The field that declares each package then takes
17
+ // the tarball's absolute file: spec, because a relative spec breaks after the consumer is itself
18
+ // packed and installed elsewhere. --override NAME sets overrides[NAME] to $NAME when the consumer
19
+ // declares NAME, else to the tarball spec of the source named NAME, and a created block lands where
20
+ // oxfmt sorts it. The install runs with --ignore-scripts and deletes node_modules/.vite, then the
21
+ // proof requires each pinned package, an override-only one included, to be a directory whose file
22
+ // set is its pack list and whose bytes are its source's, no other copy of it to be installed, and
23
+ // every other top-level @orkestrel package to be byte-unchanged; a proved pin records the digest of
24
+ // its installed bytes. --dry-run builds, packs, and writes nothing, and prints the plan.
25
+ // --restore puts back each recorded range (all, or each --package) and every override the pins
26
+ // replaced, keeps an overrides block that existed before the pin, drops the lock entry and the
27
+ // installed copy of each package it restores from the registry or whose override it drops so the
28
+ // install resolves them again, installs, and proves each package is installed at what its range
29
+ // names. Before it writes anything, --restore refuses with 64 a ledger whose package or override
30
+ // keys are not package names, and a removal whose install path resolves outside node_modules.
31
+ // --status writes nothing and reports each drift between the ledger, the manifest, the tarballs, the
32
+ // installed bytes, and the source commits; a manifest spec holds its tarball only when it names the
33
+ // ledger archive's full location.
34
+ // --pin and --restore write a receipt create-only to tmp/units/swap-<consumer>-<time>.json under the
35
+ // working directory. Every mode prints text lines, or one JSON line with --json.
36
+ // Exit 0 on success, 1 when a command or a proof failed, 3 when --status reports drift, 64 on usage.
37
+ import { spawnSync } from 'node:child_process'
38
+ import { createHash } from 'node:crypto'
39
+ import {
40
+ existsSync,
41
+ lstatSync,
42
+ mkdirSync,
43
+ mkdtempSync,
44
+ readdirSync,
45
+ readFileSync,
46
+ realpathSync,
47
+ renameSync,
48
+ rmSync,
49
+ writeFileSync,
50
+ } from 'node:fs'
51
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
52
+ import {
53
+ normalizeSlashes,
54
+ parseJsonRecord,
55
+ readMissingFlags,
56
+ readOption,
57
+ readOptions,
58
+ readString,
59
+ runNpm,
60
+ } from '../../orkestrel-dispatch/scripts/helpers.ts'
61
+
62
+ type Manifest = Readonly<Record<string, unknown>>
63
+
64
+ // The manifest fields a pin rewrites; `peerDependencies` stays a registry range.
65
+ type ManifestField = 'dependencies' | 'devDependencies'
66
+
67
+ type SwapMode = 'pin' | 'restore' | 'status'
68
+
69
+ interface Command {
70
+ readonly mode: SwapMode
71
+ readonly consumer: string
72
+ readonly sources: readonly string[]
73
+ readonly overrides: readonly string[]
74
+ readonly packages: readonly string[]
75
+ readonly build: boolean
76
+ readonly dry: boolean
77
+ readonly json: boolean
78
+ }
79
+
80
+ type CommandResult =
81
+ | { readonly ok: true; readonly command: Command }
82
+ | { readonly ok: false; readonly message: string }
83
+
84
+ interface Source {
85
+ readonly directory: string
86
+ readonly name: string
87
+ readonly version: string
88
+ }
89
+
90
+ interface Declaration {
91
+ readonly field: ManifestField
92
+ readonly spec: string
93
+ }
94
+
95
+ // One source after its pack, or planned with a `pending` hash under --dry-run.
96
+ interface Tarball {
97
+ readonly source: Source
98
+ readonly file: string
99
+ readonly spec: string
100
+ readonly sha256: string
101
+ readonly files: readonly string[]
102
+ readonly commit: string | undefined
103
+ readonly dirty: boolean | undefined
104
+ }
105
+
106
+ interface LedgerEntry {
107
+ readonly field?: ManifestField
108
+ readonly replaced?: string
109
+ readonly tarball: string
110
+ readonly sha256: string
111
+ readonly source: string
112
+ readonly commit?: string
113
+ readonly dirty?: boolean
114
+ readonly inventory?: string
115
+ readonly at: string
116
+ }
117
+
118
+ interface OverrideEntry {
119
+ readonly value: string
120
+ readonly replaced?: string
121
+ readonly at: string
122
+ }
123
+
124
+ interface Ledger {
125
+ readonly packages: Readonly<Record<string, LedgerEntry>>
126
+ readonly overrides: Readonly<Record<string, OverrideEntry>>
127
+ readonly blockExisted?: boolean
128
+ }
129
+
130
+ interface Change {
131
+ readonly block: string
132
+ readonly name: string
133
+ readonly from: string | undefined
134
+ readonly to: string | undefined
135
+ }
136
+
137
+ interface PinPlan {
138
+ readonly manifest: Manifest
139
+ readonly ledger: Ledger
140
+ readonly changes: readonly Change[]
141
+ }
142
+
143
+ interface RestorePlan {
144
+ readonly manifest: Manifest
145
+ readonly ledger: Ledger
146
+ readonly changes: readonly Change[]
147
+ readonly rows: ReadonlyArray<{ readonly name: string; readonly entry: LedgerEntry }>
148
+ readonly dropped: readonly string[]
149
+ }
150
+
151
+ interface Copy {
152
+ readonly name: string
153
+ readonly path: string
154
+ readonly version: string | undefined
155
+ readonly link: boolean
156
+ }
157
+
158
+ interface Version {
159
+ readonly major: number
160
+ readonly minor: number
161
+ readonly patch: number
162
+ readonly pre: string | undefined
163
+ }
164
+
165
+ interface Comparator {
166
+ readonly operator: '>=' | '>' | '<=' | '<' | '='
167
+ readonly version: Version
168
+ }
169
+
170
+ const FIELDS: readonly ManifestField[] = ['dependencies', 'devDependencies']
171
+ const MODES: readonly SwapMode[] = ['pin', 'restore', 'status']
172
+ const TARBALL_DIRECTORY = 'tmp/tarballs'
173
+ const LEDGER_FILE = 'pins.json'
174
+ const RECEIPT_DIRECTORY = 'tmp/units'
175
+ const EXIT_FAILURE = 1
176
+ const EXIT_DRIFT = 3
177
+ const EXIT_USAGE = 64
178
+ const FORMAT_PROBLEM =
179
+ 'package.json is not tab-indented JSON with a trailing newline; the script would reformat it'
180
+
181
+ const VALUE_FLAGS: Readonly<Record<SwapMode, readonly string[]>> = {
182
+ pin: ['--consumer', '--source', '--override'],
183
+ restore: ['--consumer', '--package'],
184
+ status: ['--consumer'],
185
+ }
186
+
187
+ const SWITCH_FLAGS: Readonly<Record<SwapMode, readonly string[]>> = {
188
+ pin: ['--pin', '--no-build', '--dry-run', '--json'],
189
+ restore: ['--restore', '--json'],
190
+ status: ['--status', '--json'],
191
+ }
192
+
193
+ // oxfmt 0.72.0 sorts these `package.json` keys ahead of `overrides`; every other key it knows, and
194
+ // every key it does not know, follows it.
195
+ const OVERRIDE_PRECEDERS: readonly string[] = [
196
+ '$schema',
197
+ 'name',
198
+ 'displayName',
199
+ 'version',
200
+ 'stableVersion',
201
+ 'private',
202
+ 'description',
203
+ 'categories',
204
+ 'keywords',
205
+ 'homepage',
206
+ 'bugs',
207
+ 'license',
208
+ 'author',
209
+ 'maintainers',
210
+ 'contributors',
211
+ 'repository',
212
+ 'funding',
213
+ 'qna',
214
+ 'publisher',
215
+ 'man',
216
+ 'style',
217
+ 'example',
218
+ 'examplestyle',
219
+ 'assets',
220
+ 'bin',
221
+ 'source',
222
+ 'directories',
223
+ 'workspaces',
224
+ 'binary',
225
+ 'files',
226
+ 'os',
227
+ 'cpu',
228
+ 'libc',
229
+ 'type',
230
+ 'sideEffects',
231
+ 'main',
232
+ 'module',
233
+ 'browser',
234
+ 'types',
235
+ 'typings',
236
+ 'typesVersions',
237
+ 'react-native',
238
+ 'svelte',
239
+ 'unpkg',
240
+ 'jsdelivr',
241
+ 'jsnext:main',
242
+ 'umd:main',
243
+ 'imports',
244
+ 'exports',
245
+ 'publishConfig',
246
+ 'scripts',
247
+ 'betterScripts',
248
+ 'dependencies',
249
+ 'devDependencies',
250
+ 'dependenciesMeta',
251
+ 'peerDependencies',
252
+ 'peerDependenciesMeta',
253
+ 'optionalDependencies',
254
+ 'bundledDependencies',
255
+ 'bundleDependencies',
256
+ 'resolutions',
257
+ ]
258
+
259
+ // The manifest blocks whose values are dependency specs; `overrides` nests records of them.
260
+ const SPEC_BLOCKS: readonly string[] = [
261
+ 'dependencies',
262
+ 'devDependencies',
263
+ 'peerDependencies',
264
+ 'optionalDependencies',
265
+ 'overrides',
266
+ ]
267
+
268
+ // npm-package-arg reads a spec as local when it carries a `file:` or `link:` prefix or begins like a
269
+ // path: a dot, `~/`, a separator, or a drive letter.
270
+ const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu
271
+
272
+ // It also reads a spec as local when the spec ends in an archive extension and carries no protocol...
273
+ const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu
274
+
275
+ // ...unless the spec is a `user/repo` shorthand, which names a hosted repository whatever it ends in.
276
+ const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u
277
+
278
+ // An optional `@scope/` and a name of the characters a registry URL carries untouched; a path
279
+ // separator beyond the scope's one `/`, an absolute form, and a drive form fall outside it.
280
+ const PACKAGE_NAME_PATTERN = /^(?:@[A-Za-z0-9~-][A-Za-z0-9._~-]*\/)?[A-Za-z0-9~-][A-Za-z0-9._~-]*$/u
281
+
282
+ const PACKAGE_NAME_LIMIT = 214
283
+
284
+ const USAGE = [
285
+ 'usage: swap.ts --pin --consumer DIR --source DIR [--source DIR ...] [--override NAME ...] [--no-build] [--dry-run] [--json]',
286
+ ' swap.ts --restore --consumer DIR [--package NAME ...] [--json]',
287
+ ' swap.ts --status --consumer DIR [--json]',
288
+ ].join('\n')
289
+
290
+ function isRecord(value: unknown): value is Record<string, unknown> {
291
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
292
+ }
293
+
294
+ function isLocalSpec(spec: string): boolean {
295
+ const text = spec.trimStart()
296
+ return (
297
+ LOCAL_SPEC_PATTERN.test(text) ||
298
+ (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text))
299
+ )
300
+ }
301
+
302
+ function isPackageName(name: string): boolean {
303
+ return (
304
+ name.length <= PACKAGE_NAME_LIMIT && !name.includes('..') && PACKAGE_NAME_PATTERN.test(name)
305
+ )
306
+ }
307
+
308
+ function isManifestField(value: unknown): value is ManifestField {
309
+ return value === 'dependencies' || value === 'devDependencies'
310
+ }
311
+
312
+ function isLedgerEntry(value: unknown): value is LedgerEntry {
313
+ return (
314
+ isRecord(value) &&
315
+ typeof value.tarball === 'string' &&
316
+ typeof value.sha256 === 'string' &&
317
+ typeof value.source === 'string' &&
318
+ typeof value.at === 'string' &&
319
+ (value.field === undefined || isManifestField(value.field)) &&
320
+ (value.replaced === undefined || typeof value.replaced === 'string') &&
321
+ (value.commit === undefined || typeof value.commit === 'string') &&
322
+ (value.dirty === undefined || typeof value.dirty === 'boolean') &&
323
+ (value.inventory === undefined || typeof value.inventory === 'string')
324
+ )
325
+ }
326
+
327
+ function isOverrideEntry(value: unknown): value is OverrideEntry {
328
+ return (
329
+ isRecord(value) &&
330
+ typeof value.value === 'string' &&
331
+ typeof value.at === 'string' &&
332
+ (value.replaced === undefined || typeof value.replaced === 'string')
333
+ )
334
+ }
335
+
336
+ // A ledger with one malformed row is refused whole, so a restore never drops a range it cannot read.
337
+ function parseLedger(value: unknown): Ledger | undefined {
338
+ if (!isRecord(value) || !isRecord(value.packages) || !isRecord(value.overrides)) return undefined
339
+ const rows = Object.entries(value.packages)
340
+ const packages = rows.flatMap(([name, entry]): Array<[string, LedgerEntry]> =>
341
+ isLedgerEntry(entry) ? [[name, entry]] : [],
342
+ )
343
+ const entries = Object.entries(value.overrides)
344
+ const overrides = entries.flatMap(([name, entry]): Array<[string, OverrideEntry]> =>
345
+ isOverrideEntry(entry) ? [[name, entry]] : [],
346
+ )
347
+ if (packages.length !== rows.length || overrides.length !== entries.length) return undefined
348
+ if (value.blockExisted !== undefined && typeof value.blockExisted !== 'boolean') return undefined
349
+ return {
350
+ packages: Object.fromEntries(packages),
351
+ overrides: Object.fromEntries(overrides),
352
+ ...(value.blockExisted === undefined ? {} : { blockExisted: value.blockExisted }),
353
+ }
354
+ }
355
+
356
+ function serializeJSON(value: unknown): string {
357
+ return `${JSON.stringify(value, null, '\t')}\n`
358
+ }
359
+
360
+ function readBlock(manifest: Manifest, field: string): Readonly<Record<string, unknown>> {
361
+ const block = manifest[field]
362
+ return isRecord(block) ? block : {}
363
+ }
364
+
365
+ function findDeclaration(manifest: Manifest, name: string): Declaration | undefined {
366
+ const field = FIELDS.find((candidate) => typeof readBlock(manifest, candidate)[name] === 'string')
367
+ const spec = field === undefined ? undefined : readBlock(manifest, field)[name]
368
+ return field === undefined || typeof spec !== 'string' ? undefined : { field, spec }
369
+ }
370
+
371
+ function compareKeys(left: string, right: string): number {
372
+ return left < right ? -1 : left > right ? 1 : 0
373
+ }
374
+
375
+ // A block whose keys are sorted stays sorted, because oxfmt sorts a dependency or override block;
376
+ // any other block takes the added keys last.
377
+ function mergeEntries(
378
+ kept: ReadonlyArray<readonly [string, unknown]>,
379
+ added: ReadonlyArray<readonly [string, unknown]>,
380
+ ): ReadonlyArray<readonly [string, unknown]> {
381
+ const sorted = kept.every(
382
+ ([name], index) => index === 0 || compareKeys(kept[index - 1]?.[0] ?? '', name) <= 0,
383
+ )
384
+ const merged = [...kept, ...added]
385
+ return sorted ? merged.toSorted(([left], [right]) => compareKeys(left, right)) : merged
386
+ }
387
+
388
+ // A created block lands before the first key oxfmt sorts after `overrides`, or last when every key
389
+ // sorts ahead of it.
390
+ function placeBlock(manifest: Manifest, field: string, block: Record<string, unknown>): Manifest {
391
+ const entries = Object.entries(manifest)
392
+ const follower = entries.findIndex(([key]) => !OVERRIDE_PRECEDERS.includes(key))
393
+ const position = follower === -1 ? entries.length : follower
394
+ return Object.fromEntries([
395
+ ...entries.slice(0, position),
396
+ [field, block],
397
+ ...entries.slice(position),
398
+ ])
399
+ }
400
+
401
+ // A changed key keeps its position, a key mapped to undefined is removed, and an `overrides` block
402
+ // left empty is removed unless `keepEmpty` is set. A block that is absent and gains nothing is not
403
+ // created.
404
+ function rewriteBlock(
405
+ manifest: Manifest,
406
+ field: string,
407
+ changes: ReadonlyMap<string, string | undefined>,
408
+ keepEmpty: boolean,
409
+ ): Manifest {
410
+ const present = Object.entries(readBlock(manifest, field))
411
+ const kept = present.flatMap(([name, value]): Array<readonly [string, unknown]> => {
412
+ if (!changes.has(name)) return [[name, value]]
413
+ const next = changes.get(name)
414
+ return next === undefined ? [] : [[name, next]]
415
+ })
416
+ const added = [...changes].flatMap(([name, spec]): Array<readonly [string, unknown]> =>
417
+ spec === undefined || present.some(([existing]) => existing === name) ? [] : [[name, spec]],
418
+ )
419
+ const next = mergeEntries(kept, added)
420
+ if (!Object.hasOwn(manifest, field)) {
421
+ return next.length === 0 ? manifest : placeBlock(manifest, field, Object.fromEntries(next))
422
+ }
423
+ return Object.fromEntries(
424
+ Object.entries(manifest).flatMap(([key, value]): Array<[string, unknown]> => {
425
+ if (key !== field) return [[key, value]]
426
+ return next.length === 0 && field === 'overrides' && !keepEmpty
427
+ ? []
428
+ : [[key, Object.fromEntries(next)]]
429
+ }),
430
+ )
431
+ }
432
+
433
+ function renderValue(value: unknown): string | undefined {
434
+ if (value === undefined) return undefined
435
+ return typeof value === 'string' ? value : JSON.stringify(value)
436
+ }
437
+
438
+ function diffManifests(before: Manifest, after: Manifest): readonly Change[] {
439
+ return ['dependencies', 'devDependencies', 'overrides'].flatMap((block) => {
440
+ const from = readBlock(before, block)
441
+ const to = readBlock(after, block)
442
+ const names = [...new Set([...Object.keys(from), ...Object.keys(to)])]
443
+ return names.flatMap((name): Change[] => {
444
+ const left = renderValue(from[name])
445
+ const right = renderValue(to[name])
446
+ return left === right ? [] : [{ block, name, from: left, to: right }]
447
+ })
448
+ })
449
+ }
450
+
451
+ function buildTarballName(name: string, version: string, sha256: string): string {
452
+ return `${name.replace(/^@/u, '').replace('/', '-')}-${version}-${sha256.slice(0, 12)}.tgz`
453
+ }
454
+
455
+ // npm resolves a dependency's relative file: spec against that dependency's own directory, so only an
456
+ // absolute spec resolves inside a packed consumer installed elsewhere.
457
+ function buildTarballSpec(consumer: string, file: string): string {
458
+ return `file:${normalizeSlashes(resolve(consumer, ...TARBALL_DIRECTORY.split('/'), file))}`
459
+ }
460
+
461
+ // The relative `file:tmp/tarballs/<file>` form is what earlier pins wrote, so a re-pin over it keeps
462
+ // the range the first pin replaced.
463
+ function isTarballSpec(spec: string): boolean {
464
+ if (!spec.startsWith('file:')) return false
465
+ const path = normalizeSlashes(spec.slice('file:'.length))
466
+ return (
467
+ path.startsWith(`${TARBALL_DIRECTORY}/`) ||
468
+ (isAbsolute(path) && path.includes(`/${TARBALL_DIRECTORY}/`))
469
+ )
470
+ }
471
+
472
+ function buildLedgerEntry(
473
+ manifest: Manifest,
474
+ ledger: Ledger,
475
+ tarball: Tarball,
476
+ at: string,
477
+ ): LedgerEntry {
478
+ const declaration = findDeclaration(manifest, tarball.source.name)
479
+ const prior = ledger.packages[tarball.source.name]
480
+ const repinned =
481
+ prior !== undefined && declaration !== undefined && isTarballSpec(declaration.spec)
482
+ const replaced = repinned ? prior.replaced : declaration?.spec
483
+ return {
484
+ ...(declaration === undefined ? {} : { field: declaration.field }),
485
+ ...(replaced === undefined ? {} : { replaced }),
486
+ tarball: tarball.file,
487
+ sha256: tarball.sha256,
488
+ source: normalizeSlashes(tarball.source.directory),
489
+ ...(tarball.commit === undefined ? {} : { commit: tarball.commit }),
490
+ ...(tarball.dirty === undefined ? {} : { dirty: tarball.dirty }),
491
+ at,
492
+ }
493
+ }
494
+
495
+ // The replaced range is kept from the first pin while the manifest still holds a tarball spec, and a
496
+ // package no field declares is recorded without a field and takes effect through its override alone.
497
+ // The first pin records whether the manifest held an overrides block, so a restore keeps one that
498
+ // existed and removes one the pin created.
499
+ function planPin(
500
+ manifest: Manifest,
501
+ ledger: Ledger,
502
+ tarballs: readonly Tarball[],
503
+ overrides: readonly string[],
504
+ at: string,
505
+ ): PinPlan {
506
+ const rows = tarballs.map((tarball) => ({
507
+ name: tarball.source.name,
508
+ spec: tarball.spec,
509
+ entry: buildLedgerEntry(manifest, ledger, tarball, at),
510
+ }))
511
+ const specs = new Map(tarballs.map((tarball) => [tarball.source.name, tarball.spec]))
512
+ const current = readBlock(manifest, 'overrides')
513
+ const added = overrides.flatMap((name): Array<{ name: string; entry: OverrideEntry }> => {
514
+ const value = findDeclaration(manifest, name) === undefined ? specs.get(name) : `$${name}`
515
+ if (value === undefined) return []
516
+ const prior = ledger.overrides[name]
517
+ const replaced = prior === undefined ? current[name] : prior.replaced
518
+ return [{ name, entry: { value, ...(typeof replaced === 'string' ? { replaced } : {}), at } }]
519
+ })
520
+ const pinned = FIELDS.reduce(
521
+ (manifestSoFar, field) =>
522
+ rewriteBlock(
523
+ manifestSoFar,
524
+ field,
525
+ new Map(
526
+ rows.flatMap((row): Array<[string, string]> =>
527
+ row.entry.field === field ? [[row.name, row.spec]] : [],
528
+ ),
529
+ ),
530
+ true,
531
+ ),
532
+ manifest,
533
+ )
534
+ const next = rewriteBlock(
535
+ pinned,
536
+ 'overrides',
537
+ new Map(
538
+ added.map((override): [string, string | undefined] => [override.name, override.entry.value]),
539
+ ),
540
+ true,
541
+ )
542
+ const fresh =
543
+ Object.keys(ledger.packages).length === 0 && Object.keys(ledger.overrides).length === 0
544
+ const blockExisted =
545
+ ledger.blockExisted ?? (fresh ? Object.hasOwn(manifest, 'overrides') : undefined)
546
+ return {
547
+ manifest: next,
548
+ ledger: {
549
+ packages: {
550
+ ...ledger.packages,
551
+ ...Object.fromEntries(rows.map((row) => [row.name, row.entry])),
552
+ },
553
+ overrides: {
554
+ ...ledger.overrides,
555
+ ...Object.fromEntries(added.map((override) => [override.name, override.entry])),
556
+ },
557
+ ...(blockExisted === undefined ? {} : { blockExisted }),
558
+ },
559
+ changes: diffManifests(manifest, next),
560
+ }
561
+ }
562
+
563
+ // An empty name list restores every recorded package and override.
564
+ function planRestore(manifest: Manifest, ledger: Ledger, names: readonly string[]): RestorePlan {
565
+ const targets = names.length === 0 ? Object.keys(ledger.packages) : names
566
+ const selected = new Set(targets)
567
+ const rows = targets.flatMap((name): Array<{ name: string; entry: LedgerEntry }> => {
568
+ const entry = ledger.packages[name]
569
+ return entry === undefined ? [] : [{ name, entry }]
570
+ })
571
+ const dropped = Object.keys(ledger.overrides).filter(
572
+ (name) => names.length === 0 || selected.has(name),
573
+ )
574
+ const reverted = FIELDS.reduce(
575
+ (manifestSoFar, field) =>
576
+ rewriteBlock(
577
+ manifestSoFar,
578
+ field,
579
+ new Map(
580
+ rows.flatMap((row): Array<[string, string]> =>
581
+ row.entry.field === field && row.entry.replaced !== undefined
582
+ ? [[row.name, row.entry.replaced]]
583
+ : [],
584
+ ),
585
+ ),
586
+ true,
587
+ ),
588
+ manifest,
589
+ )
590
+ const next = rewriteBlock(
591
+ reverted,
592
+ 'overrides',
593
+ new Map(
594
+ dropped.map((name): [string, string | undefined] => [name, ledger.overrides[name]?.replaced]),
595
+ ),
596
+ ledger.blockExisted === true,
597
+ )
598
+ const remaining = {
599
+ packages: Object.fromEntries(
600
+ Object.entries(ledger.packages).filter(([name]) => !selected.has(name)),
601
+ ),
602
+ overrides: Object.fromEntries(
603
+ Object.entries(ledger.overrides).filter(([name]) => !dropped.includes(name)),
604
+ ),
605
+ }
606
+ const retained =
607
+ Object.keys(remaining.packages).length > 0 || Object.keys(remaining.overrides).length > 0
608
+ return {
609
+ manifest: next,
610
+ ledger: {
611
+ ...remaining,
612
+ ...(ledger.blockExisted === undefined || !retained
613
+ ? {}
614
+ : { blockExisted: ledger.blockExisted }),
615
+ },
616
+ changes: diffManifests(manifest, next),
617
+ rows,
618
+ dropped,
619
+ }
620
+ }
621
+
622
+ function scanPinRequest(
623
+ text: string,
624
+ manifest: Manifest,
625
+ ledger: Ledger,
626
+ sources: readonly Source[],
627
+ overrides: readonly string[],
628
+ ): readonly string[] {
629
+ const names = sources.map((source) => source.name)
630
+ const current = readBlock(manifest, 'overrides')
631
+ return [
632
+ ...(serializeJSON(manifest) === text ? [] : [FORMAT_PROBLEM]),
633
+ ...names.flatMap((name, index) =>
634
+ names.indexOf(name) === index ? [] : [`${name} is named by more than one --source`],
635
+ ),
636
+ ...sources.flatMap((source) => {
637
+ const declaration = findDeclaration(manifest, source.name)
638
+ return [
639
+ ...(declaration === undefined && !overrides.includes(source.name)
640
+ ? [`${source.name} is declared by no dependencies field and named by no --override`]
641
+ : []),
642
+ ...(declaration !== undefined &&
643
+ isTarballSpec(declaration.spec) &&
644
+ ledger.packages[source.name] === undefined
645
+ ? [
646
+ `${source.name} holds a tarball spec the ledger has no record of; restore its range by hand first`,
647
+ ]
648
+ : []),
649
+ ]
650
+ }),
651
+ ...overrides.flatMap((name) => [
652
+ ...(findDeclaration(manifest, name) === undefined && !names.includes(name)
653
+ ? [`override ${name} is declared by the consumer and built by no --source`]
654
+ : []),
655
+ ...(current[name] !== undefined &&
656
+ typeof current[name] !== 'string' &&
657
+ ledger.overrides[name] === undefined
658
+ ? [`overrides.${name} is not a string and the script would overwrite it`]
659
+ : []),
660
+ ]),
661
+ ]
662
+ }
663
+
664
+ function parseVersion(text: string): Version | undefined {
665
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(
666
+ text.trim(),
667
+ )
668
+ if (match === null) return undefined
669
+ return {
670
+ major: Number(match[1]),
671
+ minor: Number(match[2]),
672
+ patch: Number(match[3]),
673
+ pre: match[4],
674
+ }
675
+ }
676
+
677
+ function comparePrerelease(left: string, right: string): number {
678
+ const leftParts = left.split('.')
679
+ const rightParts = right.split('.')
680
+ const first = leftParts
681
+ .map((part, index) => {
682
+ const other = rightParts[index]
683
+ if (other === undefined) return 1
684
+ if (/^\d+$/u.test(part) && /^\d+$/u.test(other)) return Number(part) - Number(other)
685
+ if (/^\d+$/u.test(part)) return -1
686
+ if (/^\d+$/u.test(other)) return 1
687
+ return compareKeys(part, other)
688
+ })
689
+ .find((order) => order !== 0)
690
+ return first ?? leftParts.length - rightParts.length
691
+ }
692
+
693
+ function compareVersions(left: Version, right: Version): number {
694
+ const core = [left.major - right.major, left.minor - right.minor, left.patch - right.patch].find(
695
+ (order) => order !== 0,
696
+ )
697
+ if (core !== undefined) return core
698
+ if (left.pre === right.pre) return 0
699
+ if (left.pre === undefined) return 1
700
+ if (right.pre === undefined) return -1
701
+ return comparePrerelease(left.pre, right.pre)
702
+ }
703
+
704
+ // Reads `*`, `x`, an exact version, `^`, `~`, and the `>=`, `>`, `<=`, `<`, and `=` operators on full
705
+ // versions; an empty list matches every version.
706
+ function parseComparators(token: string): readonly Comparator[] | undefined {
707
+ if (token === '*' || token === 'x' || token === 'X' || token === '') return []
708
+ const operator = /^(\^|~|>=|<=|>|<|=)?(.+)$/u.exec(token)
709
+ const version = operator === null ? undefined : parseVersion(operator[2] ?? '')
710
+ if (operator === null || version === undefined) return undefined
711
+ const symbol = operator[1]
712
+ if (symbol === '^') {
713
+ const upper: Version =
714
+ version.major > 0
715
+ ? { major: version.major + 1, minor: 0, patch: 0, pre: undefined }
716
+ : version.minor > 0
717
+ ? { major: 0, minor: version.minor + 1, patch: 0, pre: undefined }
718
+ : { major: 0, minor: 0, patch: version.patch + 1, pre: undefined }
719
+ return [
720
+ { operator: '>=', version },
721
+ { operator: '<', version: upper },
722
+ ]
723
+ }
724
+ if (symbol === '~') {
725
+ return [
726
+ { operator: '>=', version },
727
+ {
728
+ operator: '<',
729
+ version: { major: version.major, minor: version.minor + 1, patch: 0, pre: undefined },
730
+ },
731
+ ]
732
+ }
733
+ if (symbol === '>=' || symbol === '>' || symbol === '<=' || symbol === '<') {
734
+ return [{ operator: symbol, version }]
735
+ }
736
+ return [{ operator: '=', version }]
737
+ }
738
+
739
+ function matchesComparator(version: Version, comparator: Comparator): boolean {
740
+ const order = compareVersions(version, comparator.version)
741
+ if (comparator.operator === '>=') return order >= 0
742
+ if (comparator.operator === '>') return order > 0
743
+ if (comparator.operator === '<=') return order <= 0
744
+ if (comparator.operator === '<') return order < 0
745
+ return order === 0
746
+ }
747
+
748
+ function supportsRange(range: string): boolean {
749
+ return range.split('||').every((set) =>
750
+ set
751
+ .trim()
752
+ .split(/\s+/u)
753
+ .every((token) => parseComparators(token) !== undefined),
754
+ )
755
+ }
756
+
757
+ // A prerelease satisfies a set only when one of its comparators names a prerelease of the same
758
+ // `major.minor.patch`, and an unreadable range is satisfied by nothing.
759
+ function matchesRange(text: string, range: string): boolean {
760
+ const version = parseVersion(text)
761
+ if (version === undefined) return false
762
+ return range.split('||').some((set) => {
763
+ const parsed = set
764
+ .trim()
765
+ .split(/\s+/u)
766
+ .map((token) => parseComparators(token))
767
+ if (parsed.some((tokenComparators) => tokenComparators === undefined)) return false
768
+ const comparators = parsed.flatMap((tokenComparators) => tokenComparators ?? [])
769
+ const admitted =
770
+ version.pre === undefined ||
771
+ comparators.some(
772
+ (comparator) =>
773
+ comparator.version.pre !== undefined &&
774
+ comparator.version.major === version.major &&
775
+ comparator.version.minor === version.minor &&
776
+ comparator.version.patch === version.patch,
777
+ )
778
+ return admitted && comparators.every((comparator) => matchesComparator(version, comparator))
779
+ })
780
+ }
781
+
782
+ function parseCommand(argv: readonly string[]): CommandResult {
783
+ const given = MODES.filter((candidate) => argv.includes(`--${candidate}`))
784
+ const mode = MODES.find((candidate) => argv.includes(`--${candidate}`))
785
+ if (mode === undefined)
786
+ return { ok: false, message: 'Give one of --pin, --restore, and --status' }
787
+ if (given.length > 1) {
788
+ return {
789
+ ok: false,
790
+ message: `Give one mode, not ${given.map((candidate) => `--${candidate}`).join(' and ')}`,
791
+ }
792
+ }
793
+ const values = VALUE_FLAGS[mode]
794
+ const switches = SWITCH_FLAGS[mode]
795
+ const unknown = argv.filter((token, index) =>
796
+ token.startsWith('--')
797
+ ? !values.includes(token) && !switches.includes(token)
798
+ : !values.includes(argv[index - 1] ?? ''),
799
+ )
800
+ if (unknown.length > 0) {
801
+ return { ok: false, message: `--${mode} does not read ${unknown.join(', ')}` }
802
+ }
803
+ const missing = readMissingFlags(argv, values)
804
+ if (missing.length > 0) {
805
+ return { ok: false, message: `${missing.join(', ')} given with no value` }
806
+ }
807
+ const consumer = readOption(argv, '--consumer')
808
+ if (consumer === undefined) return { ok: false, message: '--consumer DIR is required' }
809
+ const sources = readOptions(argv, '--source')
810
+ if (mode === 'pin' && sources.length === 0) {
811
+ return { ok: false, message: '--pin takes at least one --source DIR' }
812
+ }
813
+ return {
814
+ ok: true,
815
+ command: {
816
+ mode,
817
+ consumer: resolve(consumer),
818
+ sources: sources.map((source) => resolve(source)),
819
+ overrides: readOptions(argv, '--override'),
820
+ packages: readOptions(argv, '--package'),
821
+ build: !argv.includes('--no-build'),
822
+ dry: argv.includes('--dry-run'),
823
+ json: argv.includes('--json'),
824
+ },
825
+ }
826
+ }
827
+
828
+ function computeHash(value: Uint8Array): string {
829
+ return createHash('sha256').update(value).digest('hex')
830
+ }
831
+
832
+ function loadJSONRecord(path: string): Record<string, unknown> | undefined {
833
+ return existsSync(path) ? parseJsonRecord(readFileSync(path, 'utf8')) : undefined
834
+ }
835
+
836
+ function loadSource(directory: string): Source | undefined {
837
+ const manifest = loadJSONRecord(join(directory, 'package.json'))
838
+ const name = readString(manifest, 'name')
839
+ const version = readString(manifest, 'version')
840
+ return name === undefined || version === undefined ? undefined : { directory, name, version }
841
+ }
842
+
843
+ // npm 11 prints an array and npm 12 an object keyed by package name.
844
+ function readPack(text: string): { readonly filename: string; readonly files: readonly string[] } {
845
+ const parsed: unknown = JSON.parse(text)
846
+ const entry: unknown = Array.isArray(parsed)
847
+ ? parsed[0]
848
+ : isRecord(parsed)
849
+ ? Object.values(parsed)[0]
850
+ : undefined
851
+ if (!isRecord(entry) || typeof entry.filename !== 'string' || !Array.isArray(entry.files)) {
852
+ throw new Error('Unreadable npm pack output')
853
+ }
854
+ const files = entry.files.map((file: unknown) => {
855
+ if (!isRecord(file) || typeof file.path !== 'string') {
856
+ throw new Error('Unreadable npm pack file entry')
857
+ }
858
+ return normalizeSlashes(file.path)
859
+ })
860
+ return { filename: entry.filename, files: files.toSorted() }
861
+ }
862
+
863
+ function invokeNpm(args: readonly string[], cwd: string): string {
864
+ const result = runNpm(args, cwd)
865
+ if (result.error !== undefined) {
866
+ throw new Error(`npm ${args.join(' ')} could not start in ${cwd}: ${result.error.message}`)
867
+ }
868
+ if (result.status !== 0) {
869
+ throw new Error(
870
+ `npm ${args.join(' ')} exited ${String(result.status)} in ${cwd}: ${result.stderr.trim().slice(-2000)}`,
871
+ )
872
+ }
873
+ return result.stdout
874
+ }
875
+
876
+ function inspectCheckout(directory: string): {
877
+ readonly commit: string | undefined
878
+ readonly dirty: boolean | undefined
879
+ } {
880
+ const head = spawnSync('git', ['rev-parse', 'HEAD'], {
881
+ cwd: directory,
882
+ encoding: 'utf8',
883
+ windowsHide: true,
884
+ })
885
+ const status = spawnSync('git', ['status', '--porcelain'], {
886
+ cwd: directory,
887
+ encoding: 'utf8',
888
+ windowsHide: true,
889
+ })
890
+ return {
891
+ commit: head.status === 0 ? head.stdout.trim() : undefined,
892
+ dirty: status.status === 0 ? status.stdout.trim() !== '' : undefined,
893
+ }
894
+ }
895
+
896
+ function replaceFile(path: string, text: string): void {
897
+ const temporary = `${path}.${String(process.pid)}.tmp`
898
+ writeFileSync(temporary, text)
899
+ renameSync(temporary, path)
900
+ }
901
+
902
+ function removeTree(path: string): void {
903
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
904
+ }
905
+
906
+ // Vite keys its pre-bundle on the installed version, so a same-version tarball would serve stale bytes.
907
+ function installConsumer(consumer: string): void {
908
+ invokeNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund'], consumer)
909
+ removeTree(join(consumer, 'node_modules', '.vite'))
910
+ }
911
+
912
+ // The package's own `node_modules` is skipped, and a link is listed as `path (link)`.
913
+ function collectFiles(directory: string, prefix = ''): readonly string[] {
914
+ return readdirSync(directory, { withFileTypes: true })
915
+ .flatMap((entry): readonly string[] => {
916
+ const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`
917
+ if (entry.isSymbolicLink()) return [`${path} (link)`]
918
+ if (entry.isDirectory()) {
919
+ return prefix === '' && entry.name === 'node_modules'
920
+ ? []
921
+ : collectFiles(join(directory, entry.name), path)
922
+ }
923
+ return entry.isFile() ? [path] : []
924
+ })
925
+ .toSorted()
926
+ }
927
+
928
+ function computeInventory(directory: string): string {
929
+ const lines = collectFiles(directory).map((path) => {
930
+ const file = join(directory, ...path.split('/'))
931
+ return `${path}\t${existsSync(file) ? computeHash(readFileSync(file)) : 'unreadable'}`
932
+ })
933
+ return computeHash(Buffer.from(lines.join('\n')))
934
+ }
935
+
936
+ function computeScope(
937
+ consumer: string,
938
+ excluded: ReadonlySet<string>,
939
+ ): Readonly<Record<string, string>> {
940
+ const scope = join(consumer, 'node_modules', '@orkestrel')
941
+ if (!existsSync(scope)) return {}
942
+ return Object.fromEntries(
943
+ readdirSync(scope, { withFileTypes: true }).flatMap((entry): Array<[string, string]> => {
944
+ const name = `@orkestrel/${entry.name}`
945
+ if (excluded.has(name) || entry.name.startsWith('.')) return []
946
+ const path = join(scope, entry.name)
947
+ return [[name, lstatSync(path).isSymbolicLink() ? 'link' : computeInventory(path)]]
948
+ }),
949
+ )
950
+ }
951
+
952
+ function describeMoved(
953
+ before: Readonly<Record<string, string>>,
954
+ after: Readonly<Record<string, string>>,
955
+ ): readonly string[] {
956
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((name) => {
957
+ const was = before[name]
958
+ const now = after[name]
959
+ if (was === now) return []
960
+ const verb = was === undefined ? 'added' : now === undefined ? 'removed' : 'changed'
961
+ return [`${name} was ${verb} by the install`]
962
+ })
963
+ }
964
+
965
+ // A link is reported and not entered.
966
+ function scanModules(directory: string, names: ReadonlySet<string>): readonly Copy[] {
967
+ if (!existsSync(directory)) return []
968
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
969
+ if (entry.name.startsWith('.')) return []
970
+ const packages = entry.name.startsWith('@')
971
+ ? readdirSync(join(directory, entry.name), { withFileTypes: true }).map((child) => ({
972
+ id: `${entry.name}/${child.name}`,
973
+ path: join(directory, entry.name, child.name),
974
+ link: child.isSymbolicLink(),
975
+ }))
976
+ : [{ id: entry.name, path: join(directory, entry.name), link: entry.isSymbolicLink() }]
977
+ return packages.flatMap((found): readonly Copy[] => {
978
+ const named: readonly Copy[] = names.has(found.id)
979
+ ? [
980
+ {
981
+ name: found.id,
982
+ path: found.path,
983
+ version: readString(loadJSONRecord(join(found.path, 'package.json')), 'version'),
984
+ link: found.link,
985
+ },
986
+ ]
987
+ : []
988
+ return found.link
989
+ ? named
990
+ : [...named, ...scanModules(join(found.path, 'node_modules'), names)]
991
+ })
992
+ })
993
+ }
994
+
995
+ function locateInstalled(consumer: string, name: string): string {
996
+ return join(consumer, 'node_modules', ...name.split('/'))
997
+ }
998
+
999
+ // A source that is itself pinned was packed with its registry ranges, so its installed manifest is
1000
+ // compared to those bytes rather than to the source's own manifest. Every pinned source must be
1001
+ // installed at the top level, so an override that selects no installed dependency fails the proof.
1002
+ function proveInstalled(
1003
+ consumer: string,
1004
+ tarball: Tarball,
1005
+ declared: boolean,
1006
+ copies: readonly Copy[],
1007
+ ): readonly string[] {
1008
+ const name = tarball.source.name
1009
+ const top = locateInstalled(consumer, name)
1010
+ const named = copies.filter((copy) => copy.name === name)
1011
+ const nested = named.filter((copy) => resolve(copy.path) !== resolve(top))
1012
+ const installed = named.find((copy) => resolve(copy.path) === resolve(top))
1013
+ const real = installed !== undefined && !installed.link
1014
+ const present = real ? collectFiles(top) : []
1015
+ const packed = readPackedManifest(tarball.source.directory)
1016
+ const differing = real
1017
+ ? tarball.files.filter((path) => {
1018
+ const file = join(top, ...path.split('/'))
1019
+ const expected =
1020
+ path === 'package.json' && packed !== undefined
1021
+ ? Buffer.from(packed)
1022
+ : readFileSync(join(tarball.source.directory, ...path.split('/')))
1023
+ return !existsSync(file) || computeHash(readFileSync(file)) !== computeHash(expected)
1024
+ })
1025
+ : []
1026
+ return [
1027
+ ...(installed === undefined
1028
+ ? [
1029
+ `${name} is not installed at ${normalizeSlashes(relative(consumer, top))}${declared ? '' : '; its override selects no installed dependency'}`,
1030
+ ]
1031
+ : []),
1032
+ ...(installed?.link === true ? [`${name} is installed as a link`] : []),
1033
+ ...(real && JSON.stringify(present) !== JSON.stringify(tarball.files)
1034
+ ? [`${name} file set differs from its pack list`]
1035
+ : []),
1036
+ ...(differing.length > 0 ? [`${name} differs from its source in ${differing.join(', ')}`] : []),
1037
+ ...nested.map(
1038
+ (copy) =>
1039
+ `${name} has another copy at ${normalizeSlashes(relative(consumer, copy.path))} (version ${copy.version ?? 'unknown'}); add an --override`,
1040
+ ),
1041
+ ]
1042
+ }
1043
+
1044
+ function loadResolved(consumer: string, name: string): string | undefined {
1045
+ const packages = loadJSONRecord(join(consumer, 'package-lock.json'))?.packages
1046
+ const entry = isRecord(packages) ? packages[`node_modules/${name}`] : undefined
1047
+ return readString(isRecord(entry) ? entry : undefined, 'resolved')
1048
+ }
1049
+
1050
+ // A registry range needs a real directory whose version the range accepts and a lockfile `resolved`
1051
+ // that is a registry URL; a `file:` directory range needs a link to that directory or a directory of
1052
+ // its version; any other spec is reported as unproven.
1053
+ function proveRestored(consumer: string, name: string, range: string): readonly string[] {
1054
+ const installed = locateInstalled(consumer, name)
1055
+ if (!existsSync(installed)) return [`${name} is not installed`]
1056
+ const link = lstatSync(installed).isSymbolicLink()
1057
+ const version = readString(loadJSONRecord(join(installed, 'package.json')), 'version')
1058
+ if (range.startsWith('file:')) {
1059
+ const target = resolve(consumer, range.slice('file:'.length))
1060
+ const expected = readString(loadJSONRecord(join(target, 'package.json')), 'version')
1061
+ const linked = link && existsSync(target) && realpathSync(installed) === realpathSync(target)
1062
+ return linked || (!link && version !== undefined && version === expected)
1063
+ ? []
1064
+ : [`${name} is not installed from ${range}`]
1065
+ }
1066
+ if (!supportsRange(range))
1067
+ return [`${name} range ${range} is not a registry range the script reads`]
1068
+ const resolved = loadResolved(consumer, name)
1069
+ return [
1070
+ ...(link ? [`${name} is installed as a link`] : []),
1071
+ ...(version === undefined || !matchesRange(version, range)
1072
+ ? [`${name} ${version ?? 'unknown'} does not satisfy ${range}`]
1073
+ : []),
1074
+ ...(resolved === undefined || !/^https?:/u.test(resolved)
1075
+ ? [`the lockfile records no registry source for ${name}`]
1076
+ : []),
1077
+ ]
1078
+ }
1079
+
1080
+ function locateLedger(consumer: string): string {
1081
+ return join(consumer, ...TARBALL_DIRECTORY.split('/'), LEDGER_FILE)
1082
+ }
1083
+
1084
+ // An absent ledger is an empty one; a malformed ledger is undefined.
1085
+ function loadLedger(consumer: string): Ledger | undefined {
1086
+ const path = locateLedger(consumer)
1087
+ if (!existsSync(path)) return { packages: {}, overrides: {} }
1088
+ return parseLedger(loadJSONRecord(path))
1089
+ }
1090
+
1091
+ function readLedger(consumer: string): Ledger {
1092
+ const ledger = loadLedger(consumer)
1093
+ if (ledger === undefined)
1094
+ throw new Error(`${normalizeSlashes(locateLedger(consumer))} is malformed`)
1095
+ return ledger
1096
+ }
1097
+
1098
+ function renderStamp(at: string): string {
1099
+ return at.replace(/[:.]/gu, '-')
1100
+ }
1101
+
1102
+ function writeReceipt(
1103
+ consumer: string,
1104
+ at: string,
1105
+ receipt: Readonly<Record<string, unknown>>,
1106
+ ): string {
1107
+ mkdirSync(RECEIPT_DIRECTORY, { recursive: true })
1108
+ const path = join(RECEIPT_DIRECTORY, `swap-${basename(consumer)}-${renderStamp(at)}.json`)
1109
+ writeFileSync(path, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx' })
1110
+ return normalizeSlashes(path)
1111
+ }
1112
+
1113
+ function printReport(
1114
+ json: boolean,
1115
+ lines: readonly string[],
1116
+ report: Readonly<Record<string, unknown>>,
1117
+ ): void {
1118
+ process.stdout.write(json ? `${JSON.stringify(report)}\n` : `${lines.join('\n')}\n`)
1119
+ }
1120
+
1121
+ function reportUsage(message: string): number {
1122
+ process.stderr.write(`swap: ${message}\n${USAGE}\n`)
1123
+ return EXIT_USAGE
1124
+ }
1125
+
1126
+ function hasPins(ledger: Ledger): boolean {
1127
+ return Object.keys(ledger.packages).length > 0 || Object.keys(ledger.overrides).length > 0
1128
+ }
1129
+
1130
+ // The manifest a source is packed with: its own, with the ranges and overrides its ledger replaced
1131
+ // put back when it carries pins.
1132
+ function loadPackedManifest(directory: string): Manifest {
1133
+ const manifest = loadJSONRecord(join(directory, 'package.json'))
1134
+ if (manifest === undefined) throw new Error(`${directory}/package.json is unreadable`)
1135
+ const ledger = readLedger(directory)
1136
+ return hasPins(ledger) ? planRestore(manifest, ledger, []).manifest : manifest
1137
+ }
1138
+
1139
+ // The text a pinned source's manifest is rewritten to for the pack; undefined when the source carries
1140
+ // no pins and packs its own manifest.
1141
+ function readPackedManifest(directory: string): string | undefined {
1142
+ return hasPins(readLedger(directory)) ? serializeJSON(loadPackedManifest(directory)) : undefined
1143
+ }
1144
+
1145
+ // A source that packs a local dependency spec ships a tarball that resolves only on this host, whether
1146
+ // or not a ledger recorded the spec.
1147
+ function scanPackedSpecs(source: Source): readonly string[] {
1148
+ const manifest = loadPackedManifest(source.directory)
1149
+ return SPEC_BLOCKS.flatMap((block) =>
1150
+ collectSpecs(block, manifest[block]).flatMap(([path, spec]) =>
1151
+ isLocalSpec(spec)
1152
+ ? [`${source.name} would pack a local dependency spec: ${path} is ${spec}`]
1153
+ : [],
1154
+ ),
1155
+ )
1156
+ }
1157
+
1158
+ // An `overrides` value can nest records, so the path names every key down to the spec.
1159
+ function collectSpecs(path: string, value: unknown): ReadonlyArray<readonly [string, string]> {
1160
+ if (typeof value === 'string') return [[path, value]]
1161
+ if (!isRecord(value)) return []
1162
+ return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child))
1163
+ }
1164
+
1165
+ // A source that carries a ledger of its own has its manifest rewritten to the recorded ranges for the
1166
+ // pack and restored byte for byte whether the pack succeeds or throws, so a consumer never receives a
1167
+ // tarball spec that resolves only inside the source.
1168
+ function packSource(directory: string, scratch: string): string {
1169
+ const args = ['pack', '--ignore-scripts', '--json', '--pack-destination', scratch]
1170
+ const packed = readPackedManifest(directory)
1171
+ if (packed === undefined) return invokeNpm(args, directory)
1172
+ const path = join(directory, 'package.json')
1173
+ const original = readFileSync(path)
1174
+ writeFileSync(path, packed)
1175
+ try {
1176
+ return invokeNpm(args, directory)
1177
+ } finally {
1178
+ writeFileSync(path, original)
1179
+ }
1180
+ }
1181
+
1182
+ function packSources(
1183
+ sources: readonly Source[],
1184
+ build: boolean,
1185
+ consumer: string,
1186
+ ): readonly Tarball[] {
1187
+ const tarballs = join(consumer, ...TARBALL_DIRECTORY.split('/'))
1188
+ mkdirSync(tarballs, { recursive: true })
1189
+ const scratch = mkdtempSync(join(tarballs, '.scratch-'))
1190
+ try {
1191
+ return sources.map((source) => {
1192
+ if (build) invokeNpm(['run', 'build'], source.directory)
1193
+ const pack = readPack(packSource(source.directory, scratch))
1194
+ const packed = join(scratch, basename(pack.filename))
1195
+ const sha256 = computeHash(readFileSync(packed))
1196
+ const file = buildTarballName(source.name, source.version, sha256)
1197
+ renameSync(packed, join(tarballs, file))
1198
+ return {
1199
+ source,
1200
+ file,
1201
+ spec: buildTarballSpec(consumer, file),
1202
+ sha256,
1203
+ files: pack.files,
1204
+ ...inspectCheckout(source.directory),
1205
+ }
1206
+ })
1207
+ } finally {
1208
+ removeTree(scratch)
1209
+ }
1210
+ }
1211
+
1212
+ function planTarballs(sources: readonly Source[], consumer: string): readonly Tarball[] {
1213
+ return sources.map((source) => {
1214
+ const file = buildTarballName(source.name, source.version, '<sha256:12>')
1215
+ return {
1216
+ source,
1217
+ file,
1218
+ spec: buildTarballSpec(consumer, file),
1219
+ sha256: 'pending',
1220
+ files: [],
1221
+ ...inspectCheckout(source.directory),
1222
+ }
1223
+ })
1224
+ }
1225
+
1226
+ function renderChange(change: Change): string {
1227
+ return `swap: ${change.block}.${change.name} ${change.from ?? '(absent)'} -> ${change.to ?? '(absent)'}`
1228
+ }
1229
+
1230
+ // A proved pin records the digest of its installed bytes, which `--status` compares the install to.
1231
+ function recordInventory(consumer: string, ledger: Ledger, tarballs: readonly Tarball[]): Ledger {
1232
+ const proved = new Map(
1233
+ tarballs.flatMap((tarball): Array<[string, string]> => {
1234
+ const top = locateInstalled(consumer, tarball.source.name)
1235
+ return existsSync(top) && !lstatSync(top).isSymbolicLink()
1236
+ ? [[tarball.source.name, computeInventory(top)]]
1237
+ : []
1238
+ }),
1239
+ )
1240
+ return {
1241
+ ...ledger,
1242
+ packages: Object.fromEntries(
1243
+ Object.entries(ledger.packages).map(([name, entry]) => {
1244
+ const inventory = proved.get(name)
1245
+ return [name, inventory === undefined ? entry : { ...entry, inventory }]
1246
+ }),
1247
+ ),
1248
+ }
1249
+ }
1250
+
1251
+ function executePin(command: Command): number {
1252
+ const manifestPath = join(command.consumer, 'package.json')
1253
+ if (!existsSync(manifestPath)) return reportUsage(`${command.consumer} has no package.json`)
1254
+ const text = readFileSync(manifestPath, 'utf8')
1255
+ const manifest = parseJsonRecord(text)
1256
+ if (manifest === undefined) return reportUsage(`${manifestPath} is not a JSON object`)
1257
+ const ledger = readLedger(command.consumer)
1258
+ const sources = command.sources.map((directory) => loadSource(directory))
1259
+ const unreadable = command.sources.filter((_directory, index) => sources[index] === undefined)
1260
+ if (unreadable.length > 0) {
1261
+ return reportUsage(`No package name and version in ${unreadable.join(', ')}`)
1262
+ }
1263
+ const known = sources.flatMap((source) => (source === undefined ? [] : [source]))
1264
+ const problems = [
1265
+ ...scanPinRequest(text, manifest, ledger, known, command.overrides),
1266
+ ...known.flatMap((source) => scanPackedSpecs(source)),
1267
+ ]
1268
+ if (problems.length > 0) return reportUsage(problems.join('\n'))
1269
+ const at = new Date().toISOString()
1270
+ if (command.dry) {
1271
+ const plan = planPin(
1272
+ manifest,
1273
+ ledger,
1274
+ planTarballs(known, command.consumer),
1275
+ command.overrides,
1276
+ at,
1277
+ )
1278
+ printReport(
1279
+ command.json,
1280
+ ['swap: dry run, nothing written', ...plan.changes.map(renderChange)],
1281
+ {
1282
+ mode: 'pin',
1283
+ dry: true,
1284
+ consumer: normalizeSlashes(command.consumer),
1285
+ build: command.build,
1286
+ changes: plan.changes,
1287
+ ledger: plan.ledger,
1288
+ },
1289
+ )
1290
+ return 0
1291
+ }
1292
+ const names = new Set(known.map((source) => source.name))
1293
+ const before = computeScope(command.consumer, names)
1294
+ const tarballs = packSources(known, command.build, command.consumer)
1295
+ const plan = planPin(manifest, ledger, tarballs, command.overrides, at)
1296
+ replaceFile(locateLedger(command.consumer), serializeJSON(plan.ledger))
1297
+ replaceFile(manifestPath, serializeJSON(plan.manifest))
1298
+ installConsumer(command.consumer)
1299
+ const copies = scanModules(join(command.consumer, 'node_modules'), names)
1300
+ const failures = [
1301
+ ...tarballs.flatMap((tarball) =>
1302
+ proveInstalled(
1303
+ command.consumer,
1304
+ tarball,
1305
+ findDeclaration(manifest, tarball.source.name) !== undefined,
1306
+ copies,
1307
+ ),
1308
+ ),
1309
+ ...describeMoved(before, computeScope(command.consumer, names)),
1310
+ ]
1311
+ const recorded =
1312
+ failures.length === 0 ? recordInventory(command.consumer, plan.ledger, tarballs) : plan.ledger
1313
+ if (failures.length === 0) replaceFile(locateLedger(command.consumer), serializeJSON(recorded))
1314
+ const receipt = {
1315
+ mode: 'pin',
1316
+ consumer: normalizeSlashes(command.consumer),
1317
+ at,
1318
+ packages: tarballs.map((tarball) => ({
1319
+ name: tarball.source.name,
1320
+ version: tarball.source.version,
1321
+ ...recorded.packages[tarball.source.name],
1322
+ spec: tarball.spec,
1323
+ files: tarball.files.length,
1324
+ })),
1325
+ overrides: recorded.overrides,
1326
+ changes: plan.changes,
1327
+ copies: copies.map((copy) => ({
1328
+ name: copy.name,
1329
+ path: normalizeSlashes(relative(command.consumer, copy.path)),
1330
+ version: copy.version,
1331
+ link: copy.link,
1332
+ })),
1333
+ ok: failures.length === 0,
1334
+ problems: failures,
1335
+ }
1336
+ const path = writeReceipt(command.consumer, at, receipt)
1337
+ printReport(
1338
+ command.json,
1339
+ [
1340
+ ...tarballs.map((tarball) => {
1341
+ const replaced = recorded.packages[tarball.source.name]?.replaced
1342
+ return `swap: pinned ${tarball.source.name}@${tarball.source.version} to ${tarball.file}${replaced === undefined ? '' : ` (replaced ${replaced})`}`
1343
+ }),
1344
+ ...Object.entries(recorded.overrides).map(
1345
+ ([name, entry]) => `swap: overrides.${name} is ${entry.value}`,
1346
+ ),
1347
+ ...failures.map((failure) => `swap: problem: ${failure}`),
1348
+ `swap: receipt ${path}`,
1349
+ ],
1350
+ { ...receipt, receipt: path },
1351
+ )
1352
+ return failures.length === 0 ? 0 : EXIT_FAILURE
1353
+ }
1354
+
1355
+ // The re-pin arguments name every source the restored rows recorded and every override they
1356
+ // dropped, so the pins can come back after a verb that needed the registry ranges.
1357
+ function buildRepin(consumer: string, plan: RestorePlan): readonly string[] {
1358
+ return [
1359
+ '--pin',
1360
+ '--consumer',
1361
+ normalizeSlashes(consumer),
1362
+ ...plan.rows.flatMap((row) => ['--source', row.entry.source]),
1363
+ ...plan.dropped.flatMap((name) => ['--override', name]),
1364
+ ]
1365
+ }
1366
+
1367
+ // npm keeps a lock entry whose version satisfies the restored range, so the tarball `resolved` of a
1368
+ // pinned package outlives the restore; removing the entry and the installed copy makes the install
1369
+ // resolve the package again from the registry.
1370
+ function removePinnedInstalls(consumer: string, names: readonly string[]): void {
1371
+ const path = join(consumer, 'package-lock.json')
1372
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : undefined
1373
+ const lock = text === undefined ? undefined : parseJsonRecord(text)
1374
+ if (text !== undefined && lock !== undefined && isRecord(lock.packages)) {
1375
+ const kept = Object.entries(lock.packages).filter(
1376
+ ([key]) => !names.some((name) => key === `node_modules/${name}`),
1377
+ )
1378
+ const indent = /^\{\r?\n([ \t]+)"/u.exec(text)?.[1] ?? ' '
1379
+ replaceFile(
1380
+ path,
1381
+ `${JSON.stringify({ ...lock, packages: Object.fromEntries(kept) }, null, indent)}\n`,
1382
+ )
1383
+ }
1384
+ for (const name of names) removeTree(locateInstalled(consumer, name))
1385
+ }
1386
+
1387
+ // The packages whose lock entry and installed copy the restore removes: each restored row that
1388
+ // returns to a registry range, and each dropped override.
1389
+ function listRemovals(plan: RestorePlan): readonly string[] {
1390
+ return [
1391
+ ...new Set([
1392
+ ...plan.rows.flatMap((row) =>
1393
+ row.entry.field === undefined ||
1394
+ row.entry.replaced === undefined ||
1395
+ row.entry.replaced.startsWith('file:')
1396
+ ? []
1397
+ : [row.name],
1398
+ ),
1399
+ ...plan.dropped,
1400
+ ]),
1401
+ ]
1402
+ }
1403
+
1404
+ // Every ledger key becomes a manifest key, a lock key, and an install path, so each must be a package
1405
+ // name before the restore writes anything.
1406
+ function scanLedgerNames(ledger: Ledger): readonly string[] {
1407
+ return [
1408
+ ...scanNames('packages', Object.keys(ledger.packages)),
1409
+ ...scanNames('overrides', Object.keys(ledger.overrides)),
1410
+ ]
1411
+ }
1412
+
1413
+ function scanNames(block: string, names: readonly string[]): readonly string[] {
1414
+ return names
1415
+ .filter((name) => !isPackageName(name))
1416
+ .map((name) => `The ledger ${block} key ${JSON.stringify(name)} is not a package name`)
1417
+ }
1418
+
1419
+ // A name that passes the package-name rules still resolves outside `node_modules` when a scope
1420
+ // directory on its way is a link, so the target's real parent is compared to the real `node_modules`.
1421
+ function isContained(root: string, target: string): boolean {
1422
+ const parent = dirname(target)
1423
+ const inside = relative(
1424
+ existsSync(root) ? realpathSync(root) : resolve(root),
1425
+ join(existsSync(parent) ? realpathSync(parent) : resolve(parent), basename(target)),
1426
+ )
1427
+ return inside !== '' && !isAbsolute(inside) && inside.split(/[\\/]/u)[0] !== '..'
1428
+ }
1429
+
1430
+ function scanRemovals(consumer: string, names: readonly string[]): readonly string[] {
1431
+ const root = join(consumer, 'node_modules')
1432
+ return names
1433
+ .filter((name) => !isContained(root, locateInstalled(consumer, name)))
1434
+ .map((name) => `The install of ${name} resolves outside node_modules`)
1435
+ }
1436
+
1437
+ function executeRestore(command: Command): number {
1438
+ const manifestPath = join(command.consumer, 'package.json')
1439
+ if (!existsSync(manifestPath)) return reportUsage(`${command.consumer} has no package.json`)
1440
+ const text = readFileSync(manifestPath, 'utf8')
1441
+ const manifest = parseJsonRecord(text)
1442
+ if (manifest === undefined) return reportUsage(`${manifestPath} is not a JSON object`)
1443
+ if (serializeJSON(manifest) !== text) return reportUsage(FORMAT_PROBLEM)
1444
+ const ledger = readLedger(command.consumer)
1445
+ const invalid = scanLedgerNames(ledger)
1446
+ if (invalid.length > 0) return reportUsage(invalid.join('\n'))
1447
+ const unknown = command.packages.filter((name) => ledger.packages[name] === undefined)
1448
+ if (unknown.length > 0) return reportUsage(`The ledger records no pin for ${unknown.join(', ')}`)
1449
+ if (Object.keys(ledger.packages).length === 0) {
1450
+ return reportUsage('The ledger records no pin to restore')
1451
+ }
1452
+ const at = new Date().toISOString()
1453
+ const plan = planRestore(manifest, ledger, command.packages)
1454
+ const removals = listRemovals(plan)
1455
+ const escaping = scanRemovals(command.consumer, removals)
1456
+ if (escaping.length > 0) return reportUsage(escaping.join('\n'))
1457
+ replaceFile(manifestPath, serializeJSON(plan.manifest))
1458
+ replaceFile(locateLedger(command.consumer), serializeJSON(plan.ledger))
1459
+ removePinnedInstalls(command.consumer, removals)
1460
+ installConsumer(command.consumer)
1461
+ const proved = plan.rows.flatMap((row) =>
1462
+ row.entry.field === undefined || row.entry.replaced === undefined
1463
+ ? []
1464
+ : [
1465
+ {
1466
+ name: row.name,
1467
+ range: row.entry.replaced,
1468
+ problems: proveRestored(command.consumer, row.name, row.entry.replaced),
1469
+ },
1470
+ ],
1471
+ )
1472
+ const failures = proved.flatMap((proof) => proof.problems)
1473
+ const receipt = {
1474
+ mode: 'restore',
1475
+ consumer: normalizeSlashes(command.consumer),
1476
+ at,
1477
+ restored: proved.map((proof) => ({
1478
+ name: proof.name,
1479
+ range: proof.range,
1480
+ source: plan.rows.find((row) => row.name === proof.name)?.entry.source,
1481
+ version: readString(
1482
+ loadJSONRecord(join(locateInstalled(command.consumer, proof.name), 'package.json')),
1483
+ 'version',
1484
+ ),
1485
+ resolved: loadResolved(command.consumer, proof.name),
1486
+ })),
1487
+ dropped: plan.dropped,
1488
+ changes: plan.changes,
1489
+ repin: buildRepin(command.consumer, plan),
1490
+ ok: failures.length === 0,
1491
+ problems: failures,
1492
+ }
1493
+ const path = writeReceipt(command.consumer, at, receipt)
1494
+ printReport(
1495
+ command.json,
1496
+ [
1497
+ ...proved.map((proof) => `swap: restored ${proof.name} to ${proof.range}`),
1498
+ ...plan.dropped.map((name) => `swap: dropped overrides.${name}`),
1499
+ ...failures.map((failure) => `swap: problem: ${failure}`),
1500
+ `swap: re-pin with ${receipt.repin.join(' ')}`,
1501
+ `swap: receipt ${path}`,
1502
+ ],
1503
+ { ...receipt, receipt: path },
1504
+ )
1505
+ return failures.length === 0 ? 0 : EXIT_FAILURE
1506
+ }
1507
+
1508
+ // The manifest spec of a tarball resolved to the location it names, relative specs against the
1509
+ // consumer, so a spec holds its tarball only when it names the ledger archive itself.
1510
+ function locateSpecTarget(consumer: string, spec: string): string {
1511
+ return normalizeSlashes(resolve(consumer, spec.slice('file:'.length)))
1512
+ }
1513
+
1514
+ function describeStatus(
1515
+ name: string,
1516
+ entry: LedgerEntry,
1517
+ archive: string,
1518
+ report: {
1519
+ readonly pinned: boolean
1520
+ readonly tarball: boolean
1521
+ readonly installed: { readonly link: boolean; readonly intact: boolean | undefined } | undefined
1522
+ readonly moved: boolean
1523
+ },
1524
+ ): readonly string[] {
1525
+ return [
1526
+ ...(report.pinned ? [] : [`${name}: the manifest does not hold ${archive}`]),
1527
+ ...(report.tarball
1528
+ ? []
1529
+ : [`${name}: ${entry.tarball} is missing or does not match its recorded SHA-256`]),
1530
+ ...(report.installed === undefined ? [`${name}: not installed`] : []),
1531
+ ...(report.installed?.link === true ? [`${name}: installed as a link`] : []),
1532
+ ...(report.installed !== undefined &&
1533
+ !report.installed.link &&
1534
+ report.installed.intact === undefined
1535
+ ? [`${name}: the ledger records no proved install; re-pin`]
1536
+ : []),
1537
+ ...(report.installed?.intact === false
1538
+ ? [`${name}: the installed bytes differ from the pinned build`]
1539
+ : []),
1540
+ ...(report.moved ? [`${name}: ${entry.source} moved from ${entry.commit ?? '?'}; re-pin`] : []),
1541
+ ]
1542
+ }
1543
+
1544
+ function executeStatus(command: Command): number {
1545
+ const manifest = loadJSONRecord(join(command.consumer, 'package.json'))
1546
+ if (manifest === undefined) return reportUsage(`${command.consumer} has no readable package.json`)
1547
+ const ledger = readLedger(command.consumer)
1548
+ const tarballs = join(command.consumer, ...TARBALL_DIRECTORY.split('/'))
1549
+ const current = readBlock(manifest, 'overrides')
1550
+ const packages = Object.entries(ledger.packages).map(([name, entry]) => {
1551
+ const declaration = findDeclaration(manifest, name)
1552
+ const top = locateInstalled(command.consumer, name)
1553
+ const archive = join(tarballs, entry.tarball)
1554
+ const git = existsSync(entry.source) ? inspectCheckout(entry.source) : undefined
1555
+ const held = entry.field === undefined ? current[name] : declaration?.spec
1556
+ const link = existsSync(top) && lstatSync(top).isSymbolicLink()
1557
+ const installed = existsSync(top)
1558
+ ? {
1559
+ version: readString(loadJSONRecord(join(top, 'package.json')), 'version'),
1560
+ link,
1561
+ intact:
1562
+ link || entry.inventory === undefined
1563
+ ? undefined
1564
+ : computeInventory(top) === entry.inventory,
1565
+ }
1566
+ : undefined
1567
+ const report = {
1568
+ pinned:
1569
+ typeof held === 'string' &&
1570
+ isTarballSpec(held) &&
1571
+ locateSpecTarget(command.consumer, held) === normalizeSlashes(archive),
1572
+ tarball: existsSync(archive) && computeHash(readFileSync(archive)) === entry.sha256,
1573
+ installed,
1574
+ moved: entry.commit !== undefined && git?.commit !== undefined && git.commit !== entry.commit,
1575
+ }
1576
+ return {
1577
+ row: {
1578
+ name,
1579
+ field: entry.field,
1580
+ replaced: entry.replaced,
1581
+ manifest: declaration?.spec,
1582
+ ...report,
1583
+ file: entry.tarball,
1584
+ source: {
1585
+ directory: entry.source,
1586
+ commit: { recorded: entry.commit, current: git?.commit },
1587
+ dirty: { recorded: entry.dirty, current: git?.dirty },
1588
+ },
1589
+ at: entry.at,
1590
+ },
1591
+ problems: describeStatus(name, entry, normalizeSlashes(archive), report),
1592
+ }
1593
+ })
1594
+ const overrides = Object.entries(ledger.overrides).map(([name, entry]) => ({
1595
+ name,
1596
+ recorded: entry.value,
1597
+ replaced: entry.replaced,
1598
+ manifest: renderValue(current[name]),
1599
+ matches: current[name] === entry.value,
1600
+ }))
1601
+ const unrecorded = ['dependencies', 'devDependencies', 'overrides'].flatMap((block) =>
1602
+ Object.entries(readBlock(manifest, block)).flatMap(([name, spec]) =>
1603
+ typeof spec === 'string' &&
1604
+ isTarballSpec(spec) &&
1605
+ (block === 'overrides' ? ledger.overrides[name] : ledger.packages[name]) === undefined
1606
+ ? [`${block}.${name} holds a tarball spec the ledger has no record of`]
1607
+ : [],
1608
+ ),
1609
+ )
1610
+ const problems = [
1611
+ ...packages.flatMap((pin) => pin.problems),
1612
+ ...overrides.flatMap((override) =>
1613
+ override.matches
1614
+ ? []
1615
+ : [
1616
+ `overrides.${override.name} is ${override.manifest ?? '(absent)'}; the ledger records ${override.recorded}`,
1617
+ ],
1618
+ ),
1619
+ ...unrecorded,
1620
+ ]
1621
+ printReport(
1622
+ command.json,
1623
+ [
1624
+ ...packages.map(
1625
+ (pin) =>
1626
+ `swap: ${pin.row.name} ${pin.row.pinned ? 'pinned' : 'unpinned'} ${pin.row.file}${pin.row.replaced === undefined ? '' : ` (replaced ${pin.row.replaced})`}`,
1627
+ ),
1628
+ ...problems.map((problem) => `swap: drift: ${problem}`),
1629
+ `swap: ${String(packages.length)} pin(s), ${String(problems.length)} drift(s)`,
1630
+ ],
1631
+ {
1632
+ mode: 'status',
1633
+ consumer: normalizeSlashes(command.consumer),
1634
+ packages: packages.map((pin) => pin.row),
1635
+ overrides,
1636
+ problems,
1637
+ },
1638
+ )
1639
+ return problems.length === 0 ? 0 : EXIT_DRIFT
1640
+ }
1641
+
1642
+ function main(argv: readonly string[]): number {
1643
+ const parsed = parseCommand(argv)
1644
+ if (!parsed.ok) return reportUsage(parsed.message)
1645
+ try {
1646
+ if (parsed.command.mode === 'pin') return executePin(parsed.command)
1647
+ if (parsed.command.mode === 'restore') return executeRestore(parsed.command)
1648
+ return executeStatus(parsed.command)
1649
+ } catch (error) {
1650
+ process.stderr.write(`swap: ${error instanceof Error ? error.message : String(error)}\n`)
1651
+ return EXIT_FAILURE
1652
+ }
1653
+ }
1654
+
1655
+ process.exitCode = main(process.argv.slice(2))