@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,1273 @@
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 { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
40
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
41
+ import { normalizeSlashes, parseJsonRecord, readMissingFlags, readOption, readOptions, readString, runNpm, } from "../../orkestrel-dispatch/scripts/helpers.js";
42
+ const FIELDS = ['dependencies', 'devDependencies'];
43
+ const MODES = ['pin', 'restore', 'status'];
44
+ const TARBALL_DIRECTORY = 'tmp/tarballs';
45
+ const LEDGER_FILE = 'pins.json';
46
+ const RECEIPT_DIRECTORY = 'tmp/units';
47
+ const EXIT_FAILURE = 1;
48
+ const EXIT_DRIFT = 3;
49
+ const EXIT_USAGE = 64;
50
+ const FORMAT_PROBLEM = 'package.json is not tab-indented JSON with a trailing newline; the script would reformat it';
51
+ const VALUE_FLAGS = {
52
+ pin: ['--consumer', '--source', '--override'],
53
+ restore: ['--consumer', '--package'],
54
+ status: ['--consumer'],
55
+ };
56
+ const SWITCH_FLAGS = {
57
+ pin: ['--pin', '--no-build', '--dry-run', '--json'],
58
+ restore: ['--restore', '--json'],
59
+ status: ['--status', '--json'],
60
+ };
61
+ // oxfmt 0.72.0 sorts these `package.json` keys ahead of `overrides`; every other key it knows, and
62
+ // every key it does not know, follows it.
63
+ const OVERRIDE_PRECEDERS = [
64
+ '$schema',
65
+ 'name',
66
+ 'displayName',
67
+ 'version',
68
+ 'stableVersion',
69
+ 'private',
70
+ 'description',
71
+ 'categories',
72
+ 'keywords',
73
+ 'homepage',
74
+ 'bugs',
75
+ 'license',
76
+ 'author',
77
+ 'maintainers',
78
+ 'contributors',
79
+ 'repository',
80
+ 'funding',
81
+ 'qna',
82
+ 'publisher',
83
+ 'man',
84
+ 'style',
85
+ 'example',
86
+ 'examplestyle',
87
+ 'assets',
88
+ 'bin',
89
+ 'source',
90
+ 'directories',
91
+ 'workspaces',
92
+ 'binary',
93
+ 'files',
94
+ 'os',
95
+ 'cpu',
96
+ 'libc',
97
+ 'type',
98
+ 'sideEffects',
99
+ 'main',
100
+ 'module',
101
+ 'browser',
102
+ 'types',
103
+ 'typings',
104
+ 'typesVersions',
105
+ 'react-native',
106
+ 'svelte',
107
+ 'unpkg',
108
+ 'jsdelivr',
109
+ 'jsnext:main',
110
+ 'umd:main',
111
+ 'imports',
112
+ 'exports',
113
+ 'publishConfig',
114
+ 'scripts',
115
+ 'betterScripts',
116
+ 'dependencies',
117
+ 'devDependencies',
118
+ 'dependenciesMeta',
119
+ 'peerDependencies',
120
+ 'peerDependenciesMeta',
121
+ 'optionalDependencies',
122
+ 'bundledDependencies',
123
+ 'bundleDependencies',
124
+ 'resolutions',
125
+ ];
126
+ // The manifest blocks whose values are dependency specs; `overrides` nests records of them.
127
+ const SPEC_BLOCKS = [
128
+ 'dependencies',
129
+ 'devDependencies',
130
+ 'peerDependencies',
131
+ 'optionalDependencies',
132
+ 'overrides',
133
+ ];
134
+ // npm-package-arg reads a spec as local when it carries a `file:` or `link:` prefix or begins like a
135
+ // path: a dot, `~/`, a separator, or a drive letter.
136
+ const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu;
137
+ // It also reads a spec as local when the spec ends in an archive extension and carries no protocol...
138
+ const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu;
139
+ // ...unless the spec is a `user/repo` shorthand, which names a hosted repository whatever it ends in.
140
+ const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u;
141
+ // An optional `@scope/` and a name of the characters a registry URL carries untouched; a path
142
+ // separator beyond the scope's one `/`, an absolute form, and a drive form fall outside it.
143
+ const PACKAGE_NAME_PATTERN = /^(?:@[A-Za-z0-9~-][A-Za-z0-9._~-]*\/)?[A-Za-z0-9~-][A-Za-z0-9._~-]*$/u;
144
+ const PACKAGE_NAME_LIMIT = 214;
145
+ const USAGE = [
146
+ 'usage: swap.ts --pin --consumer DIR --source DIR [--source DIR ...] [--override NAME ...] [--no-build] [--dry-run] [--json]',
147
+ ' swap.ts --restore --consumer DIR [--package NAME ...] [--json]',
148
+ ' swap.ts --status --consumer DIR [--json]',
149
+ ].join('\n');
150
+ function isRecord(value) {
151
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ }
153
+ function isLocalSpec(spec) {
154
+ const text = spec.trimStart();
155
+ return (LOCAL_SPEC_PATTERN.test(text) ||
156
+ (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text)));
157
+ }
158
+ function isPackageName(name) {
159
+ return (name.length <= PACKAGE_NAME_LIMIT && !name.includes('..') && PACKAGE_NAME_PATTERN.test(name));
160
+ }
161
+ function isManifestField(value) {
162
+ return value === 'dependencies' || value === 'devDependencies';
163
+ }
164
+ function isLedgerEntry(value) {
165
+ return (isRecord(value) &&
166
+ typeof value.tarball === 'string' &&
167
+ typeof value.sha256 === 'string' &&
168
+ typeof value.source === 'string' &&
169
+ typeof value.at === 'string' &&
170
+ (value.field === undefined || isManifestField(value.field)) &&
171
+ (value.replaced === undefined || typeof value.replaced === 'string') &&
172
+ (value.commit === undefined || typeof value.commit === 'string') &&
173
+ (value.dirty === undefined || typeof value.dirty === 'boolean') &&
174
+ (value.inventory === undefined || typeof value.inventory === 'string'));
175
+ }
176
+ function isOverrideEntry(value) {
177
+ return (isRecord(value) &&
178
+ typeof value.value === 'string' &&
179
+ typeof value.at === 'string' &&
180
+ (value.replaced === undefined || typeof value.replaced === 'string'));
181
+ }
182
+ // A ledger with one malformed row is refused whole, so a restore never drops a range it cannot read.
183
+ function parseLedger(value) {
184
+ if (!isRecord(value) || !isRecord(value.packages) || !isRecord(value.overrides))
185
+ return undefined;
186
+ const rows = Object.entries(value.packages);
187
+ const packages = rows.flatMap(([name, entry]) => isLedgerEntry(entry) ? [[name, entry]] : []);
188
+ const entries = Object.entries(value.overrides);
189
+ const overrides = entries.flatMap(([name, entry]) => isOverrideEntry(entry) ? [[name, entry]] : []);
190
+ if (packages.length !== rows.length || overrides.length !== entries.length)
191
+ return undefined;
192
+ if (value.blockExisted !== undefined && typeof value.blockExisted !== 'boolean')
193
+ return undefined;
194
+ return {
195
+ packages: Object.fromEntries(packages),
196
+ overrides: Object.fromEntries(overrides),
197
+ ...(value.blockExisted === undefined ? {} : { blockExisted: value.blockExisted }),
198
+ };
199
+ }
200
+ function serializeJSON(value) {
201
+ return `${JSON.stringify(value, null, '\t')}\n`;
202
+ }
203
+ function readBlock(manifest, field) {
204
+ const block = manifest[field];
205
+ return isRecord(block) ? block : {};
206
+ }
207
+ function findDeclaration(manifest, name) {
208
+ const field = FIELDS.find((candidate) => typeof readBlock(manifest, candidate)[name] === 'string');
209
+ const spec = field === undefined ? undefined : readBlock(manifest, field)[name];
210
+ return field === undefined || typeof spec !== 'string' ? undefined : { field, spec };
211
+ }
212
+ function compareKeys(left, right) {
213
+ return left < right ? -1 : left > right ? 1 : 0;
214
+ }
215
+ // A block whose keys are sorted stays sorted, because oxfmt sorts a dependency or override block;
216
+ // any other block takes the added keys last.
217
+ function mergeEntries(kept, added) {
218
+ const sorted = kept.every(([name], index) => index === 0 || compareKeys(kept[index - 1]?.[0] ?? '', name) <= 0);
219
+ const merged = [...kept, ...added];
220
+ return sorted ? merged.toSorted(([left], [right]) => compareKeys(left, right)) : merged;
221
+ }
222
+ // A created block lands before the first key oxfmt sorts after `overrides`, or last when every key
223
+ // sorts ahead of it.
224
+ function placeBlock(manifest, field, block) {
225
+ const entries = Object.entries(manifest);
226
+ const follower = entries.findIndex(([key]) => !OVERRIDE_PRECEDERS.includes(key));
227
+ const position = follower === -1 ? entries.length : follower;
228
+ return Object.fromEntries([
229
+ ...entries.slice(0, position),
230
+ [field, block],
231
+ ...entries.slice(position),
232
+ ]);
233
+ }
234
+ // A changed key keeps its position, a key mapped to undefined is removed, and an `overrides` block
235
+ // left empty is removed unless `keepEmpty` is set. A block that is absent and gains nothing is not
236
+ // created.
237
+ function rewriteBlock(manifest, field, changes, keepEmpty) {
238
+ const present = Object.entries(readBlock(manifest, field));
239
+ const kept = present.flatMap(([name, value]) => {
240
+ if (!changes.has(name))
241
+ return [[name, value]];
242
+ const next = changes.get(name);
243
+ return next === undefined ? [] : [[name, next]];
244
+ });
245
+ const added = [...changes].flatMap(([name, spec]) => spec === undefined || present.some(([existing]) => existing === name) ? [] : [[name, spec]]);
246
+ const next = mergeEntries(kept, added);
247
+ if (!Object.hasOwn(manifest, field)) {
248
+ return next.length === 0 ? manifest : placeBlock(manifest, field, Object.fromEntries(next));
249
+ }
250
+ return Object.fromEntries(Object.entries(manifest).flatMap(([key, value]) => {
251
+ if (key !== field)
252
+ return [[key, value]];
253
+ return next.length === 0 && field === 'overrides' && !keepEmpty
254
+ ? []
255
+ : [[key, Object.fromEntries(next)]];
256
+ }));
257
+ }
258
+ function renderValue(value) {
259
+ if (value === undefined)
260
+ return undefined;
261
+ return typeof value === 'string' ? value : JSON.stringify(value);
262
+ }
263
+ function diffManifests(before, after) {
264
+ return ['dependencies', 'devDependencies', 'overrides'].flatMap((block) => {
265
+ const from = readBlock(before, block);
266
+ const to = readBlock(after, block);
267
+ const names = [...new Set([...Object.keys(from), ...Object.keys(to)])];
268
+ return names.flatMap((name) => {
269
+ const left = renderValue(from[name]);
270
+ const right = renderValue(to[name]);
271
+ return left === right ? [] : [{ block, name, from: left, to: right }];
272
+ });
273
+ });
274
+ }
275
+ function buildTarballName(name, version, sha256) {
276
+ return `${name.replace(/^@/u, '').replace('/', '-')}-${version}-${sha256.slice(0, 12)}.tgz`;
277
+ }
278
+ // npm resolves a dependency's relative file: spec against that dependency's own directory, so only an
279
+ // absolute spec resolves inside a packed consumer installed elsewhere.
280
+ function buildTarballSpec(consumer, file) {
281
+ return `file:${normalizeSlashes(resolve(consumer, ...TARBALL_DIRECTORY.split('/'), file))}`;
282
+ }
283
+ // The relative `file:tmp/tarballs/<file>` form is what earlier pins wrote, so a re-pin over it keeps
284
+ // the range the first pin replaced.
285
+ function isTarballSpec(spec) {
286
+ if (!spec.startsWith('file:'))
287
+ return false;
288
+ const path = normalizeSlashes(spec.slice('file:'.length));
289
+ return (path.startsWith(`${TARBALL_DIRECTORY}/`) ||
290
+ (isAbsolute(path) && path.includes(`/${TARBALL_DIRECTORY}/`)));
291
+ }
292
+ function buildLedgerEntry(manifest, ledger, tarball, at) {
293
+ const declaration = findDeclaration(manifest, tarball.source.name);
294
+ const prior = ledger.packages[tarball.source.name];
295
+ const repinned = prior !== undefined && declaration !== undefined && isTarballSpec(declaration.spec);
296
+ const replaced = repinned ? prior.replaced : declaration?.spec;
297
+ return {
298
+ ...(declaration === undefined ? {} : { field: declaration.field }),
299
+ ...(replaced === undefined ? {} : { replaced }),
300
+ tarball: tarball.file,
301
+ sha256: tarball.sha256,
302
+ source: normalizeSlashes(tarball.source.directory),
303
+ ...(tarball.commit === undefined ? {} : { commit: tarball.commit }),
304
+ ...(tarball.dirty === undefined ? {} : { dirty: tarball.dirty }),
305
+ at,
306
+ };
307
+ }
308
+ // The replaced range is kept from the first pin while the manifest still holds a tarball spec, and a
309
+ // package no field declares is recorded without a field and takes effect through its override alone.
310
+ // The first pin records whether the manifest held an overrides block, so a restore keeps one that
311
+ // existed and removes one the pin created.
312
+ function planPin(manifest, ledger, tarballs, overrides, at) {
313
+ const rows = tarballs.map((tarball) => ({
314
+ name: tarball.source.name,
315
+ spec: tarball.spec,
316
+ entry: buildLedgerEntry(manifest, ledger, tarball, at),
317
+ }));
318
+ const specs = new Map(tarballs.map((tarball) => [tarball.source.name, tarball.spec]));
319
+ const current = readBlock(manifest, 'overrides');
320
+ const added = overrides.flatMap((name) => {
321
+ const value = findDeclaration(manifest, name) === undefined ? specs.get(name) : `$${name}`;
322
+ if (value === undefined)
323
+ return [];
324
+ const prior = ledger.overrides[name];
325
+ const replaced = prior === undefined ? current[name] : prior.replaced;
326
+ return [{ name, entry: { value, ...(typeof replaced === 'string' ? { replaced } : {}), at } }];
327
+ });
328
+ const pinned = FIELDS.reduce((manifestSoFar, field) => rewriteBlock(manifestSoFar, field, new Map(rows.flatMap((row) => row.entry.field === field ? [[row.name, row.spec]] : [])), true), manifest);
329
+ const next = rewriteBlock(pinned, 'overrides', new Map(added.map((override) => [override.name, override.entry.value])), true);
330
+ const fresh = Object.keys(ledger.packages).length === 0 && Object.keys(ledger.overrides).length === 0;
331
+ const blockExisted = ledger.blockExisted ?? (fresh ? Object.hasOwn(manifest, 'overrides') : undefined);
332
+ return {
333
+ manifest: next,
334
+ ledger: {
335
+ packages: {
336
+ ...ledger.packages,
337
+ ...Object.fromEntries(rows.map((row) => [row.name, row.entry])),
338
+ },
339
+ overrides: {
340
+ ...ledger.overrides,
341
+ ...Object.fromEntries(added.map((override) => [override.name, override.entry])),
342
+ },
343
+ ...(blockExisted === undefined ? {} : { blockExisted }),
344
+ },
345
+ changes: diffManifests(manifest, next),
346
+ };
347
+ }
348
+ // An empty name list restores every recorded package and override.
349
+ function planRestore(manifest, ledger, names) {
350
+ const targets = names.length === 0 ? Object.keys(ledger.packages) : names;
351
+ const selected = new Set(targets);
352
+ const rows = targets.flatMap((name) => {
353
+ const entry = ledger.packages[name];
354
+ return entry === undefined ? [] : [{ name, entry }];
355
+ });
356
+ const dropped = Object.keys(ledger.overrides).filter((name) => names.length === 0 || selected.has(name));
357
+ const reverted = FIELDS.reduce((manifestSoFar, field) => rewriteBlock(manifestSoFar, field, new Map(rows.flatMap((row) => row.entry.field === field && row.entry.replaced !== undefined
358
+ ? [[row.name, row.entry.replaced]]
359
+ : [])), true), manifest);
360
+ const next = rewriteBlock(reverted, 'overrides', new Map(dropped.map((name) => [name, ledger.overrides[name]?.replaced])), ledger.blockExisted === true);
361
+ const remaining = {
362
+ packages: Object.fromEntries(Object.entries(ledger.packages).filter(([name]) => !selected.has(name))),
363
+ overrides: Object.fromEntries(Object.entries(ledger.overrides).filter(([name]) => !dropped.includes(name))),
364
+ };
365
+ const retained = Object.keys(remaining.packages).length > 0 || Object.keys(remaining.overrides).length > 0;
366
+ return {
367
+ manifest: next,
368
+ ledger: {
369
+ ...remaining,
370
+ ...(ledger.blockExisted === undefined || !retained
371
+ ? {}
372
+ : { blockExisted: ledger.blockExisted }),
373
+ },
374
+ changes: diffManifests(manifest, next),
375
+ rows,
376
+ dropped,
377
+ };
378
+ }
379
+ function scanPinRequest(text, manifest, ledger, sources, overrides) {
380
+ const names = sources.map((source) => source.name);
381
+ const current = readBlock(manifest, 'overrides');
382
+ return [
383
+ ...(serializeJSON(manifest) === text ? [] : [FORMAT_PROBLEM]),
384
+ ...names.flatMap((name, index) => names.indexOf(name) === index ? [] : [`${name} is named by more than one --source`]),
385
+ ...sources.flatMap((source) => {
386
+ const declaration = findDeclaration(manifest, source.name);
387
+ return [
388
+ ...(declaration === undefined && !overrides.includes(source.name)
389
+ ? [`${source.name} is declared by no dependencies field and named by no --override`]
390
+ : []),
391
+ ...(declaration !== undefined &&
392
+ isTarballSpec(declaration.spec) &&
393
+ ledger.packages[source.name] === undefined
394
+ ? [
395
+ `${source.name} holds a tarball spec the ledger has no record of; restore its range by hand first`,
396
+ ]
397
+ : []),
398
+ ];
399
+ }),
400
+ ...overrides.flatMap((name) => [
401
+ ...(findDeclaration(manifest, name) === undefined && !names.includes(name)
402
+ ? [`override ${name} is declared by the consumer and built by no --source`]
403
+ : []),
404
+ ...(current[name] !== undefined &&
405
+ typeof current[name] !== 'string' &&
406
+ ledger.overrides[name] === undefined
407
+ ? [`overrides.${name} is not a string and the script would overwrite it`]
408
+ : []),
409
+ ]),
410
+ ];
411
+ }
412
+ function parseVersion(text) {
413
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(text.trim());
414
+ if (match === null)
415
+ return undefined;
416
+ return {
417
+ major: Number(match[1]),
418
+ minor: Number(match[2]),
419
+ patch: Number(match[3]),
420
+ pre: match[4],
421
+ };
422
+ }
423
+ function comparePrerelease(left, right) {
424
+ const leftParts = left.split('.');
425
+ const rightParts = right.split('.');
426
+ const first = leftParts
427
+ .map((part, index) => {
428
+ const other = rightParts[index];
429
+ if (other === undefined)
430
+ return 1;
431
+ if (/^\d+$/u.test(part) && /^\d+$/u.test(other))
432
+ return Number(part) - Number(other);
433
+ if (/^\d+$/u.test(part))
434
+ return -1;
435
+ if (/^\d+$/u.test(other))
436
+ return 1;
437
+ return compareKeys(part, other);
438
+ })
439
+ .find((order) => order !== 0);
440
+ return first ?? leftParts.length - rightParts.length;
441
+ }
442
+ function compareVersions(left, right) {
443
+ const core = [left.major - right.major, left.minor - right.minor, left.patch - right.patch].find((order) => order !== 0);
444
+ if (core !== undefined)
445
+ return core;
446
+ if (left.pre === right.pre)
447
+ return 0;
448
+ if (left.pre === undefined)
449
+ return 1;
450
+ if (right.pre === undefined)
451
+ return -1;
452
+ return comparePrerelease(left.pre, right.pre);
453
+ }
454
+ // Reads `*`, `x`, an exact version, `^`, `~`, and the `>=`, `>`, `<=`, `<`, and `=` operators on full
455
+ // versions; an empty list matches every version.
456
+ function parseComparators(token) {
457
+ if (token === '*' || token === 'x' || token === 'X' || token === '')
458
+ return [];
459
+ const operator = /^(\^|~|>=|<=|>|<|=)?(.+)$/u.exec(token);
460
+ const version = operator === null ? undefined : parseVersion(operator[2] ?? '');
461
+ if (operator === null || version === undefined)
462
+ return undefined;
463
+ const symbol = operator[1];
464
+ if (symbol === '^') {
465
+ const upper = version.major > 0
466
+ ? { major: version.major + 1, minor: 0, patch: 0, pre: undefined }
467
+ : version.minor > 0
468
+ ? { major: 0, minor: version.minor + 1, patch: 0, pre: undefined }
469
+ : { major: 0, minor: 0, patch: version.patch + 1, pre: undefined };
470
+ return [
471
+ { operator: '>=', version },
472
+ { operator: '<', version: upper },
473
+ ];
474
+ }
475
+ if (symbol === '~') {
476
+ return [
477
+ { operator: '>=', version },
478
+ {
479
+ operator: '<',
480
+ version: { major: version.major, minor: version.minor + 1, patch: 0, pre: undefined },
481
+ },
482
+ ];
483
+ }
484
+ if (symbol === '>=' || symbol === '>' || symbol === '<=' || symbol === '<') {
485
+ return [{ operator: symbol, version }];
486
+ }
487
+ return [{ operator: '=', version }];
488
+ }
489
+ function matchesComparator(version, comparator) {
490
+ const order = compareVersions(version, comparator.version);
491
+ if (comparator.operator === '>=')
492
+ return order >= 0;
493
+ if (comparator.operator === '>')
494
+ return order > 0;
495
+ if (comparator.operator === '<=')
496
+ return order <= 0;
497
+ if (comparator.operator === '<')
498
+ return order < 0;
499
+ return order === 0;
500
+ }
501
+ function supportsRange(range) {
502
+ return range.split('||').every((set) => set
503
+ .trim()
504
+ .split(/\s+/u)
505
+ .every((token) => parseComparators(token) !== undefined));
506
+ }
507
+ // A prerelease satisfies a set only when one of its comparators names a prerelease of the same
508
+ // `major.minor.patch`, and an unreadable range is satisfied by nothing.
509
+ function matchesRange(text, range) {
510
+ const version = parseVersion(text);
511
+ if (version === undefined)
512
+ return false;
513
+ return range.split('||').some((set) => {
514
+ const parsed = set
515
+ .trim()
516
+ .split(/\s+/u)
517
+ .map((token) => parseComparators(token));
518
+ if (parsed.some((tokenComparators) => tokenComparators === undefined))
519
+ return false;
520
+ const comparators = parsed.flatMap((tokenComparators) => tokenComparators ?? []);
521
+ const admitted = version.pre === undefined ||
522
+ comparators.some((comparator) => comparator.version.pre !== undefined &&
523
+ comparator.version.major === version.major &&
524
+ comparator.version.minor === version.minor &&
525
+ comparator.version.patch === version.patch);
526
+ return admitted && comparators.every((comparator) => matchesComparator(version, comparator));
527
+ });
528
+ }
529
+ function parseCommand(argv) {
530
+ const given = MODES.filter((candidate) => argv.includes(`--${candidate}`));
531
+ const mode = MODES.find((candidate) => argv.includes(`--${candidate}`));
532
+ if (mode === undefined)
533
+ return { ok: false, message: 'Give one of --pin, --restore, and --status' };
534
+ if (given.length > 1) {
535
+ return {
536
+ ok: false,
537
+ message: `Give one mode, not ${given.map((candidate) => `--${candidate}`).join(' and ')}`,
538
+ };
539
+ }
540
+ const values = VALUE_FLAGS[mode];
541
+ const switches = SWITCH_FLAGS[mode];
542
+ const unknown = argv.filter((token, index) => token.startsWith('--')
543
+ ? !values.includes(token) && !switches.includes(token)
544
+ : !values.includes(argv[index - 1] ?? ''));
545
+ if (unknown.length > 0) {
546
+ return { ok: false, message: `--${mode} does not read ${unknown.join(', ')}` };
547
+ }
548
+ const missing = readMissingFlags(argv, values);
549
+ if (missing.length > 0) {
550
+ return { ok: false, message: `${missing.join(', ')} given with no value` };
551
+ }
552
+ const consumer = readOption(argv, '--consumer');
553
+ if (consumer === undefined)
554
+ return { ok: false, message: '--consumer DIR is required' };
555
+ const sources = readOptions(argv, '--source');
556
+ if (mode === 'pin' && sources.length === 0) {
557
+ return { ok: false, message: '--pin takes at least one --source DIR' };
558
+ }
559
+ return {
560
+ ok: true,
561
+ command: {
562
+ mode,
563
+ consumer: resolve(consumer),
564
+ sources: sources.map((source) => resolve(source)),
565
+ overrides: readOptions(argv, '--override'),
566
+ packages: readOptions(argv, '--package'),
567
+ build: !argv.includes('--no-build'),
568
+ dry: argv.includes('--dry-run'),
569
+ json: argv.includes('--json'),
570
+ },
571
+ };
572
+ }
573
+ function computeHash(value) {
574
+ return createHash('sha256').update(value).digest('hex');
575
+ }
576
+ function loadJSONRecord(path) {
577
+ return existsSync(path) ? parseJsonRecord(readFileSync(path, 'utf8')) : undefined;
578
+ }
579
+ function loadSource(directory) {
580
+ const manifest = loadJSONRecord(join(directory, 'package.json'));
581
+ const name = readString(manifest, 'name');
582
+ const version = readString(manifest, 'version');
583
+ return name === undefined || version === undefined ? undefined : { directory, name, version };
584
+ }
585
+ // npm 11 prints an array and npm 12 an object keyed by package name.
586
+ function readPack(text) {
587
+ const parsed = JSON.parse(text);
588
+ const entry = Array.isArray(parsed)
589
+ ? parsed[0]
590
+ : isRecord(parsed)
591
+ ? Object.values(parsed)[0]
592
+ : undefined;
593
+ if (!isRecord(entry) || typeof entry.filename !== 'string' || !Array.isArray(entry.files)) {
594
+ throw new Error('Unreadable npm pack output');
595
+ }
596
+ const files = entry.files.map((file) => {
597
+ if (!isRecord(file) || typeof file.path !== 'string') {
598
+ throw new Error('Unreadable npm pack file entry');
599
+ }
600
+ return normalizeSlashes(file.path);
601
+ });
602
+ return { filename: entry.filename, files: files.toSorted() };
603
+ }
604
+ function invokeNpm(args, cwd) {
605
+ const result = runNpm(args, cwd);
606
+ if (result.error !== undefined) {
607
+ throw new Error(`npm ${args.join(' ')} could not start in ${cwd}: ${result.error.message}`);
608
+ }
609
+ if (result.status !== 0) {
610
+ throw new Error(`npm ${args.join(' ')} exited ${String(result.status)} in ${cwd}: ${result.stderr.trim().slice(-2000)}`);
611
+ }
612
+ return result.stdout;
613
+ }
614
+ function inspectCheckout(directory) {
615
+ const head = spawnSync('git', ['rev-parse', 'HEAD'], {
616
+ cwd: directory,
617
+ encoding: 'utf8',
618
+ windowsHide: true,
619
+ });
620
+ const status = spawnSync('git', ['status', '--porcelain'], {
621
+ cwd: directory,
622
+ encoding: 'utf8',
623
+ windowsHide: true,
624
+ });
625
+ return {
626
+ commit: head.status === 0 ? head.stdout.trim() : undefined,
627
+ dirty: status.status === 0 ? status.stdout.trim() !== '' : undefined,
628
+ };
629
+ }
630
+ function replaceFile(path, text) {
631
+ const temporary = `${path}.${String(process.pid)}.tmp`;
632
+ writeFileSync(temporary, text);
633
+ renameSync(temporary, path);
634
+ }
635
+ function removeTree(path) {
636
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
637
+ }
638
+ // Vite keys its pre-bundle on the installed version, so a same-version tarball would serve stale bytes.
639
+ function installConsumer(consumer) {
640
+ invokeNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund'], consumer);
641
+ removeTree(join(consumer, 'node_modules', '.vite'));
642
+ }
643
+ // The package's own `node_modules` is skipped, and a link is listed as `path (link)`.
644
+ function collectFiles(directory, prefix = '') {
645
+ return readdirSync(directory, { withFileTypes: true })
646
+ .flatMap((entry) => {
647
+ const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
648
+ if (entry.isSymbolicLink())
649
+ return [`${path} (link)`];
650
+ if (entry.isDirectory()) {
651
+ return prefix === '' && entry.name === 'node_modules'
652
+ ? []
653
+ : collectFiles(join(directory, entry.name), path);
654
+ }
655
+ return entry.isFile() ? [path] : [];
656
+ })
657
+ .toSorted();
658
+ }
659
+ function computeInventory(directory) {
660
+ const lines = collectFiles(directory).map((path) => {
661
+ const file = join(directory, ...path.split('/'));
662
+ return `${path}\t${existsSync(file) ? computeHash(readFileSync(file)) : 'unreadable'}`;
663
+ });
664
+ return computeHash(Buffer.from(lines.join('\n')));
665
+ }
666
+ function computeScope(consumer, excluded) {
667
+ const scope = join(consumer, 'node_modules', '@orkestrel');
668
+ if (!existsSync(scope))
669
+ return {};
670
+ return Object.fromEntries(readdirSync(scope, { withFileTypes: true }).flatMap((entry) => {
671
+ const name = `@orkestrel/${entry.name}`;
672
+ if (excluded.has(name) || entry.name.startsWith('.'))
673
+ return [];
674
+ const path = join(scope, entry.name);
675
+ return [[name, lstatSync(path).isSymbolicLink() ? 'link' : computeInventory(path)]];
676
+ }));
677
+ }
678
+ function describeMoved(before, after) {
679
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((name) => {
680
+ const was = before[name];
681
+ const now = after[name];
682
+ if (was === now)
683
+ return [];
684
+ const verb = was === undefined ? 'added' : now === undefined ? 'removed' : 'changed';
685
+ return [`${name} was ${verb} by the install`];
686
+ });
687
+ }
688
+ // A link is reported and not entered.
689
+ function scanModules(directory, names) {
690
+ if (!existsSync(directory))
691
+ return [];
692
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
693
+ if (entry.name.startsWith('.'))
694
+ return [];
695
+ const packages = entry.name.startsWith('@')
696
+ ? readdirSync(join(directory, entry.name), { withFileTypes: true }).map((child) => ({
697
+ id: `${entry.name}/${child.name}`,
698
+ path: join(directory, entry.name, child.name),
699
+ link: child.isSymbolicLink(),
700
+ }))
701
+ : [{ id: entry.name, path: join(directory, entry.name), link: entry.isSymbolicLink() }];
702
+ return packages.flatMap((found) => {
703
+ const named = names.has(found.id)
704
+ ? [
705
+ {
706
+ name: found.id,
707
+ path: found.path,
708
+ version: readString(loadJSONRecord(join(found.path, 'package.json')), 'version'),
709
+ link: found.link,
710
+ },
711
+ ]
712
+ : [];
713
+ return found.link
714
+ ? named
715
+ : [...named, ...scanModules(join(found.path, 'node_modules'), names)];
716
+ });
717
+ });
718
+ }
719
+ function locateInstalled(consumer, name) {
720
+ return join(consumer, 'node_modules', ...name.split('/'));
721
+ }
722
+ // A source that is itself pinned was packed with its registry ranges, so its installed manifest is
723
+ // compared to those bytes rather than to the source's own manifest. Every pinned source must be
724
+ // installed at the top level, so an override that selects no installed dependency fails the proof.
725
+ function proveInstalled(consumer, tarball, declared, copies) {
726
+ const name = tarball.source.name;
727
+ const top = locateInstalled(consumer, name);
728
+ const named = copies.filter((copy) => copy.name === name);
729
+ const nested = named.filter((copy) => resolve(copy.path) !== resolve(top));
730
+ const installed = named.find((copy) => resolve(copy.path) === resolve(top));
731
+ const real = installed !== undefined && !installed.link;
732
+ const present = real ? collectFiles(top) : [];
733
+ const packed = readPackedManifest(tarball.source.directory);
734
+ const differing = real
735
+ ? tarball.files.filter((path) => {
736
+ const file = join(top, ...path.split('/'));
737
+ const expected = path === 'package.json' && packed !== undefined
738
+ ? Buffer.from(packed)
739
+ : readFileSync(join(tarball.source.directory, ...path.split('/')));
740
+ return !existsSync(file) || computeHash(readFileSync(file)) !== computeHash(expected);
741
+ })
742
+ : [];
743
+ return [
744
+ ...(installed === undefined
745
+ ? [
746
+ `${name} is not installed at ${normalizeSlashes(relative(consumer, top))}${declared ? '' : '; its override selects no installed dependency'}`,
747
+ ]
748
+ : []),
749
+ ...(installed?.link === true ? [`${name} is installed as a link`] : []),
750
+ ...(real && JSON.stringify(present) !== JSON.stringify(tarball.files)
751
+ ? [`${name} file set differs from its pack list`]
752
+ : []),
753
+ ...(differing.length > 0 ? [`${name} differs from its source in ${differing.join(', ')}`] : []),
754
+ ...nested.map((copy) => `${name} has another copy at ${normalizeSlashes(relative(consumer, copy.path))} (version ${copy.version ?? 'unknown'}); add an --override`),
755
+ ];
756
+ }
757
+ function loadResolved(consumer, name) {
758
+ const packages = loadJSONRecord(join(consumer, 'package-lock.json'))?.packages;
759
+ const entry = isRecord(packages) ? packages[`node_modules/${name}`] : undefined;
760
+ return readString(isRecord(entry) ? entry : undefined, 'resolved');
761
+ }
762
+ // A registry range needs a real directory whose version the range accepts and a lockfile `resolved`
763
+ // that is a registry URL; a `file:` directory range needs a link to that directory or a directory of
764
+ // its version; any other spec is reported as unproven.
765
+ function proveRestored(consumer, name, range) {
766
+ const installed = locateInstalled(consumer, name);
767
+ if (!existsSync(installed))
768
+ return [`${name} is not installed`];
769
+ const link = lstatSync(installed).isSymbolicLink();
770
+ const version = readString(loadJSONRecord(join(installed, 'package.json')), 'version');
771
+ if (range.startsWith('file:')) {
772
+ const target = resolve(consumer, range.slice('file:'.length));
773
+ const expected = readString(loadJSONRecord(join(target, 'package.json')), 'version');
774
+ const linked = link && existsSync(target) && realpathSync(installed) === realpathSync(target);
775
+ return linked || (!link && version !== undefined && version === expected)
776
+ ? []
777
+ : [`${name} is not installed from ${range}`];
778
+ }
779
+ if (!supportsRange(range))
780
+ return [`${name} range ${range} is not a registry range the script reads`];
781
+ const resolved = loadResolved(consumer, name);
782
+ return [
783
+ ...(link ? [`${name} is installed as a link`] : []),
784
+ ...(version === undefined || !matchesRange(version, range)
785
+ ? [`${name} ${version ?? 'unknown'} does not satisfy ${range}`]
786
+ : []),
787
+ ...(resolved === undefined || !/^https?:/u.test(resolved)
788
+ ? [`the lockfile records no registry source for ${name}`]
789
+ : []),
790
+ ];
791
+ }
792
+ function locateLedger(consumer) {
793
+ return join(consumer, ...TARBALL_DIRECTORY.split('/'), LEDGER_FILE);
794
+ }
795
+ // An absent ledger is an empty one; a malformed ledger is undefined.
796
+ function loadLedger(consumer) {
797
+ const path = locateLedger(consumer);
798
+ if (!existsSync(path))
799
+ return { packages: {}, overrides: {} };
800
+ return parseLedger(loadJSONRecord(path));
801
+ }
802
+ function readLedger(consumer) {
803
+ const ledger = loadLedger(consumer);
804
+ if (ledger === undefined)
805
+ throw new Error(`${normalizeSlashes(locateLedger(consumer))} is malformed`);
806
+ return ledger;
807
+ }
808
+ function renderStamp(at) {
809
+ return at.replace(/[:.]/gu, '-');
810
+ }
811
+ function writeReceipt(consumer, at, receipt) {
812
+ mkdirSync(RECEIPT_DIRECTORY, { recursive: true });
813
+ const path = join(RECEIPT_DIRECTORY, `swap-${basename(consumer)}-${renderStamp(at)}.json`);
814
+ writeFileSync(path, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx' });
815
+ return normalizeSlashes(path);
816
+ }
817
+ function printReport(json, lines, report) {
818
+ process.stdout.write(json ? `${JSON.stringify(report)}\n` : `${lines.join('\n')}\n`);
819
+ }
820
+ function reportUsage(message) {
821
+ process.stderr.write(`swap: ${message}\n${USAGE}\n`);
822
+ return EXIT_USAGE;
823
+ }
824
+ function hasPins(ledger) {
825
+ return Object.keys(ledger.packages).length > 0 || Object.keys(ledger.overrides).length > 0;
826
+ }
827
+ // The manifest a source is packed with: its own, with the ranges and overrides its ledger replaced
828
+ // put back when it carries pins.
829
+ function loadPackedManifest(directory) {
830
+ const manifest = loadJSONRecord(join(directory, 'package.json'));
831
+ if (manifest === undefined)
832
+ throw new Error(`${directory}/package.json is unreadable`);
833
+ const ledger = readLedger(directory);
834
+ return hasPins(ledger) ? planRestore(manifest, ledger, []).manifest : manifest;
835
+ }
836
+ // The text a pinned source's manifest is rewritten to for the pack; undefined when the source carries
837
+ // no pins and packs its own manifest.
838
+ function readPackedManifest(directory) {
839
+ return hasPins(readLedger(directory)) ? serializeJSON(loadPackedManifest(directory)) : undefined;
840
+ }
841
+ // A source that packs a local dependency spec ships a tarball that resolves only on this host, whether
842
+ // or not a ledger recorded the spec.
843
+ function scanPackedSpecs(source) {
844
+ const manifest = loadPackedManifest(source.directory);
845
+ return SPEC_BLOCKS.flatMap((block) => collectSpecs(block, manifest[block]).flatMap(([path, spec]) => isLocalSpec(spec)
846
+ ? [`${source.name} would pack a local dependency spec: ${path} is ${spec}`]
847
+ : []));
848
+ }
849
+ // An `overrides` value can nest records, so the path names every key down to the spec.
850
+ function collectSpecs(path, value) {
851
+ if (typeof value === 'string')
852
+ return [[path, value]];
853
+ if (!isRecord(value))
854
+ return [];
855
+ return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child));
856
+ }
857
+ // A source that carries a ledger of its own has its manifest rewritten to the recorded ranges for the
858
+ // pack and restored byte for byte whether the pack succeeds or throws, so a consumer never receives a
859
+ // tarball spec that resolves only inside the source.
860
+ function packSource(directory, scratch) {
861
+ const args = ['pack', '--ignore-scripts', '--json', '--pack-destination', scratch];
862
+ const packed = readPackedManifest(directory);
863
+ if (packed === undefined)
864
+ return invokeNpm(args, directory);
865
+ const path = join(directory, 'package.json');
866
+ const original = readFileSync(path);
867
+ writeFileSync(path, packed);
868
+ try {
869
+ return invokeNpm(args, directory);
870
+ }
871
+ finally {
872
+ writeFileSync(path, original);
873
+ }
874
+ }
875
+ function packSources(sources, build, consumer) {
876
+ const tarballs = join(consumer, ...TARBALL_DIRECTORY.split('/'));
877
+ mkdirSync(tarballs, { recursive: true });
878
+ const scratch = mkdtempSync(join(tarballs, '.scratch-'));
879
+ try {
880
+ return sources.map((source) => {
881
+ if (build)
882
+ invokeNpm(['run', 'build'], source.directory);
883
+ const pack = readPack(packSource(source.directory, scratch));
884
+ const packed = join(scratch, basename(pack.filename));
885
+ const sha256 = computeHash(readFileSync(packed));
886
+ const file = buildTarballName(source.name, source.version, sha256);
887
+ renameSync(packed, join(tarballs, file));
888
+ return {
889
+ source,
890
+ file,
891
+ spec: buildTarballSpec(consumer, file),
892
+ sha256,
893
+ files: pack.files,
894
+ ...inspectCheckout(source.directory),
895
+ };
896
+ });
897
+ }
898
+ finally {
899
+ removeTree(scratch);
900
+ }
901
+ }
902
+ function planTarballs(sources, consumer) {
903
+ return sources.map((source) => {
904
+ const file = buildTarballName(source.name, source.version, '<sha256:12>');
905
+ return {
906
+ source,
907
+ file,
908
+ spec: buildTarballSpec(consumer, file),
909
+ sha256: 'pending',
910
+ files: [],
911
+ ...inspectCheckout(source.directory),
912
+ };
913
+ });
914
+ }
915
+ function renderChange(change) {
916
+ return `swap: ${change.block}.${change.name} ${change.from ?? '(absent)'} -> ${change.to ?? '(absent)'}`;
917
+ }
918
+ // A proved pin records the digest of its installed bytes, which `--status` compares the install to.
919
+ function recordInventory(consumer, ledger, tarballs) {
920
+ const proved = new Map(tarballs.flatMap((tarball) => {
921
+ const top = locateInstalled(consumer, tarball.source.name);
922
+ return existsSync(top) && !lstatSync(top).isSymbolicLink()
923
+ ? [[tarball.source.name, computeInventory(top)]]
924
+ : [];
925
+ }));
926
+ return {
927
+ ...ledger,
928
+ packages: Object.fromEntries(Object.entries(ledger.packages).map(([name, entry]) => {
929
+ const inventory = proved.get(name);
930
+ return [name, inventory === undefined ? entry : { ...entry, inventory }];
931
+ })),
932
+ };
933
+ }
934
+ function executePin(command) {
935
+ const manifestPath = join(command.consumer, 'package.json');
936
+ if (!existsSync(manifestPath))
937
+ return reportUsage(`${command.consumer} has no package.json`);
938
+ const text = readFileSync(manifestPath, 'utf8');
939
+ const manifest = parseJsonRecord(text);
940
+ if (manifest === undefined)
941
+ return reportUsage(`${manifestPath} is not a JSON object`);
942
+ const ledger = readLedger(command.consumer);
943
+ const sources = command.sources.map((directory) => loadSource(directory));
944
+ const unreadable = command.sources.filter((_directory, index) => sources[index] === undefined);
945
+ if (unreadable.length > 0) {
946
+ return reportUsage(`No package name and version in ${unreadable.join(', ')}`);
947
+ }
948
+ const known = sources.flatMap((source) => (source === undefined ? [] : [source]));
949
+ const problems = [
950
+ ...scanPinRequest(text, manifest, ledger, known, command.overrides),
951
+ ...known.flatMap((source) => scanPackedSpecs(source)),
952
+ ];
953
+ if (problems.length > 0)
954
+ return reportUsage(problems.join('\n'));
955
+ const at = new Date().toISOString();
956
+ if (command.dry) {
957
+ const plan = planPin(manifest, ledger, planTarballs(known, command.consumer), command.overrides, at);
958
+ printReport(command.json, ['swap: dry run, nothing written', ...plan.changes.map(renderChange)], {
959
+ mode: 'pin',
960
+ dry: true,
961
+ consumer: normalizeSlashes(command.consumer),
962
+ build: command.build,
963
+ changes: plan.changes,
964
+ ledger: plan.ledger,
965
+ });
966
+ return 0;
967
+ }
968
+ const names = new Set(known.map((source) => source.name));
969
+ const before = computeScope(command.consumer, names);
970
+ const tarballs = packSources(known, command.build, command.consumer);
971
+ const plan = planPin(manifest, ledger, tarballs, command.overrides, at);
972
+ replaceFile(locateLedger(command.consumer), serializeJSON(plan.ledger));
973
+ replaceFile(manifestPath, serializeJSON(plan.manifest));
974
+ installConsumer(command.consumer);
975
+ const copies = scanModules(join(command.consumer, 'node_modules'), names);
976
+ const failures = [
977
+ ...tarballs.flatMap((tarball) => proveInstalled(command.consumer, tarball, findDeclaration(manifest, tarball.source.name) !== undefined, copies)),
978
+ ...describeMoved(before, computeScope(command.consumer, names)),
979
+ ];
980
+ const recorded = failures.length === 0 ? recordInventory(command.consumer, plan.ledger, tarballs) : plan.ledger;
981
+ if (failures.length === 0)
982
+ replaceFile(locateLedger(command.consumer), serializeJSON(recorded));
983
+ const receipt = {
984
+ mode: 'pin',
985
+ consumer: normalizeSlashes(command.consumer),
986
+ at,
987
+ packages: tarballs.map((tarball) => ({
988
+ name: tarball.source.name,
989
+ version: tarball.source.version,
990
+ ...recorded.packages[tarball.source.name],
991
+ spec: tarball.spec,
992
+ files: tarball.files.length,
993
+ })),
994
+ overrides: recorded.overrides,
995
+ changes: plan.changes,
996
+ copies: copies.map((copy) => ({
997
+ name: copy.name,
998
+ path: normalizeSlashes(relative(command.consumer, copy.path)),
999
+ version: copy.version,
1000
+ link: copy.link,
1001
+ })),
1002
+ ok: failures.length === 0,
1003
+ problems: failures,
1004
+ };
1005
+ const path = writeReceipt(command.consumer, at, receipt);
1006
+ printReport(command.json, [
1007
+ ...tarballs.map((tarball) => {
1008
+ const replaced = recorded.packages[tarball.source.name]?.replaced;
1009
+ return `swap: pinned ${tarball.source.name}@${tarball.source.version} to ${tarball.file}${replaced === undefined ? '' : ` (replaced ${replaced})`}`;
1010
+ }),
1011
+ ...Object.entries(recorded.overrides).map(([name, entry]) => `swap: overrides.${name} is ${entry.value}`),
1012
+ ...failures.map((failure) => `swap: problem: ${failure}`),
1013
+ `swap: receipt ${path}`,
1014
+ ], { ...receipt, receipt: path });
1015
+ return failures.length === 0 ? 0 : EXIT_FAILURE;
1016
+ }
1017
+ // The re-pin arguments name every source the restored rows recorded and every override they
1018
+ // dropped, so the pins can come back after a verb that needed the registry ranges.
1019
+ function buildRepin(consumer, plan) {
1020
+ return [
1021
+ '--pin',
1022
+ '--consumer',
1023
+ normalizeSlashes(consumer),
1024
+ ...plan.rows.flatMap((row) => ['--source', row.entry.source]),
1025
+ ...plan.dropped.flatMap((name) => ['--override', name]),
1026
+ ];
1027
+ }
1028
+ // npm keeps a lock entry whose version satisfies the restored range, so the tarball `resolved` of a
1029
+ // pinned package outlives the restore; removing the entry and the installed copy makes the install
1030
+ // resolve the package again from the registry.
1031
+ function removePinnedInstalls(consumer, names) {
1032
+ const path = join(consumer, 'package-lock.json');
1033
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
1034
+ const lock = text === undefined ? undefined : parseJsonRecord(text);
1035
+ if (text !== undefined && lock !== undefined && isRecord(lock.packages)) {
1036
+ const kept = Object.entries(lock.packages).filter(([key]) => !names.some((name) => key === `node_modules/${name}`));
1037
+ const indent = /^\{\r?\n([ \t]+)"/u.exec(text)?.[1] ?? ' ';
1038
+ replaceFile(path, `${JSON.stringify({ ...lock, packages: Object.fromEntries(kept) }, null, indent)}\n`);
1039
+ }
1040
+ for (const name of names)
1041
+ removeTree(locateInstalled(consumer, name));
1042
+ }
1043
+ // The packages whose lock entry and installed copy the restore removes: each restored row that
1044
+ // returns to a registry range, and each dropped override.
1045
+ function listRemovals(plan) {
1046
+ return [
1047
+ ...new Set([
1048
+ ...plan.rows.flatMap((row) => row.entry.field === undefined ||
1049
+ row.entry.replaced === undefined ||
1050
+ row.entry.replaced.startsWith('file:')
1051
+ ? []
1052
+ : [row.name]),
1053
+ ...plan.dropped,
1054
+ ]),
1055
+ ];
1056
+ }
1057
+ // Every ledger key becomes a manifest key, a lock key, and an install path, so each must be a package
1058
+ // name before the restore writes anything.
1059
+ function scanLedgerNames(ledger) {
1060
+ return [
1061
+ ...scanNames('packages', Object.keys(ledger.packages)),
1062
+ ...scanNames('overrides', Object.keys(ledger.overrides)),
1063
+ ];
1064
+ }
1065
+ function scanNames(block, names) {
1066
+ return names
1067
+ .filter((name) => !isPackageName(name))
1068
+ .map((name) => `The ledger ${block} key ${JSON.stringify(name)} is not a package name`);
1069
+ }
1070
+ // A name that passes the package-name rules still resolves outside `node_modules` when a scope
1071
+ // directory on its way is a link, so the target's real parent is compared to the real `node_modules`.
1072
+ function isContained(root, target) {
1073
+ const parent = dirname(target);
1074
+ const inside = relative(existsSync(root) ? realpathSync(root) : resolve(root), join(existsSync(parent) ? realpathSync(parent) : resolve(parent), basename(target)));
1075
+ return inside !== '' && !isAbsolute(inside) && inside.split(/[\\/]/u)[0] !== '..';
1076
+ }
1077
+ function scanRemovals(consumer, names) {
1078
+ const root = join(consumer, 'node_modules');
1079
+ return names
1080
+ .filter((name) => !isContained(root, locateInstalled(consumer, name)))
1081
+ .map((name) => `The install of ${name} resolves outside node_modules`);
1082
+ }
1083
+ function executeRestore(command) {
1084
+ const manifestPath = join(command.consumer, 'package.json');
1085
+ if (!existsSync(manifestPath))
1086
+ return reportUsage(`${command.consumer} has no package.json`);
1087
+ const text = readFileSync(manifestPath, 'utf8');
1088
+ const manifest = parseJsonRecord(text);
1089
+ if (manifest === undefined)
1090
+ return reportUsage(`${manifestPath} is not a JSON object`);
1091
+ if (serializeJSON(manifest) !== text)
1092
+ return reportUsage(FORMAT_PROBLEM);
1093
+ const ledger = readLedger(command.consumer);
1094
+ const invalid = scanLedgerNames(ledger);
1095
+ if (invalid.length > 0)
1096
+ return reportUsage(invalid.join('\n'));
1097
+ const unknown = command.packages.filter((name) => ledger.packages[name] === undefined);
1098
+ if (unknown.length > 0)
1099
+ return reportUsage(`The ledger records no pin for ${unknown.join(', ')}`);
1100
+ if (Object.keys(ledger.packages).length === 0) {
1101
+ return reportUsage('The ledger records no pin to restore');
1102
+ }
1103
+ const at = new Date().toISOString();
1104
+ const plan = planRestore(manifest, ledger, command.packages);
1105
+ const removals = listRemovals(plan);
1106
+ const escaping = scanRemovals(command.consumer, removals);
1107
+ if (escaping.length > 0)
1108
+ return reportUsage(escaping.join('\n'));
1109
+ replaceFile(manifestPath, serializeJSON(plan.manifest));
1110
+ replaceFile(locateLedger(command.consumer), serializeJSON(plan.ledger));
1111
+ removePinnedInstalls(command.consumer, removals);
1112
+ installConsumer(command.consumer);
1113
+ const proved = plan.rows.flatMap((row) => row.entry.field === undefined || row.entry.replaced === undefined
1114
+ ? []
1115
+ : [
1116
+ {
1117
+ name: row.name,
1118
+ range: row.entry.replaced,
1119
+ problems: proveRestored(command.consumer, row.name, row.entry.replaced),
1120
+ },
1121
+ ]);
1122
+ const failures = proved.flatMap((proof) => proof.problems);
1123
+ const receipt = {
1124
+ mode: 'restore',
1125
+ consumer: normalizeSlashes(command.consumer),
1126
+ at,
1127
+ restored: proved.map((proof) => ({
1128
+ name: proof.name,
1129
+ range: proof.range,
1130
+ source: plan.rows.find((row) => row.name === proof.name)?.entry.source,
1131
+ version: readString(loadJSONRecord(join(locateInstalled(command.consumer, proof.name), 'package.json')), 'version'),
1132
+ resolved: loadResolved(command.consumer, proof.name),
1133
+ })),
1134
+ dropped: plan.dropped,
1135
+ changes: plan.changes,
1136
+ repin: buildRepin(command.consumer, plan),
1137
+ ok: failures.length === 0,
1138
+ problems: failures,
1139
+ };
1140
+ const path = writeReceipt(command.consumer, at, receipt);
1141
+ printReport(command.json, [
1142
+ ...proved.map((proof) => `swap: restored ${proof.name} to ${proof.range}`),
1143
+ ...plan.dropped.map((name) => `swap: dropped overrides.${name}`),
1144
+ ...failures.map((failure) => `swap: problem: ${failure}`),
1145
+ `swap: re-pin with ${receipt.repin.join(' ')}`,
1146
+ `swap: receipt ${path}`,
1147
+ ], { ...receipt, receipt: path });
1148
+ return failures.length === 0 ? 0 : EXIT_FAILURE;
1149
+ }
1150
+ // The manifest spec of a tarball resolved to the location it names, relative specs against the
1151
+ // consumer, so a spec holds its tarball only when it names the ledger archive itself.
1152
+ function locateSpecTarget(consumer, spec) {
1153
+ return normalizeSlashes(resolve(consumer, spec.slice('file:'.length)));
1154
+ }
1155
+ function describeStatus(name, entry, archive, report) {
1156
+ return [
1157
+ ...(report.pinned ? [] : [`${name}: the manifest does not hold ${archive}`]),
1158
+ ...(report.tarball
1159
+ ? []
1160
+ : [`${name}: ${entry.tarball} is missing or does not match its recorded SHA-256`]),
1161
+ ...(report.installed === undefined ? [`${name}: not installed`] : []),
1162
+ ...(report.installed?.link === true ? [`${name}: installed as a link`] : []),
1163
+ ...(report.installed !== undefined &&
1164
+ !report.installed.link &&
1165
+ report.installed.intact === undefined
1166
+ ? [`${name}: the ledger records no proved install; re-pin`]
1167
+ : []),
1168
+ ...(report.installed?.intact === false
1169
+ ? [`${name}: the installed bytes differ from the pinned build`]
1170
+ : []),
1171
+ ...(report.moved ? [`${name}: ${entry.source} moved from ${entry.commit ?? '?'}; re-pin`] : []),
1172
+ ];
1173
+ }
1174
+ function executeStatus(command) {
1175
+ const manifest = loadJSONRecord(join(command.consumer, 'package.json'));
1176
+ if (manifest === undefined)
1177
+ return reportUsage(`${command.consumer} has no readable package.json`);
1178
+ const ledger = readLedger(command.consumer);
1179
+ const tarballs = join(command.consumer, ...TARBALL_DIRECTORY.split('/'));
1180
+ const current = readBlock(manifest, 'overrides');
1181
+ const packages = Object.entries(ledger.packages).map(([name, entry]) => {
1182
+ const declaration = findDeclaration(manifest, name);
1183
+ const top = locateInstalled(command.consumer, name);
1184
+ const archive = join(tarballs, entry.tarball);
1185
+ const git = existsSync(entry.source) ? inspectCheckout(entry.source) : undefined;
1186
+ const held = entry.field === undefined ? current[name] : declaration?.spec;
1187
+ const link = existsSync(top) && lstatSync(top).isSymbolicLink();
1188
+ const installed = existsSync(top)
1189
+ ? {
1190
+ version: readString(loadJSONRecord(join(top, 'package.json')), 'version'),
1191
+ link,
1192
+ intact: link || entry.inventory === undefined
1193
+ ? undefined
1194
+ : computeInventory(top) === entry.inventory,
1195
+ }
1196
+ : undefined;
1197
+ const report = {
1198
+ pinned: typeof held === 'string' &&
1199
+ isTarballSpec(held) &&
1200
+ locateSpecTarget(command.consumer, held) === normalizeSlashes(archive),
1201
+ tarball: existsSync(archive) && computeHash(readFileSync(archive)) === entry.sha256,
1202
+ installed,
1203
+ moved: entry.commit !== undefined && git?.commit !== undefined && git.commit !== entry.commit,
1204
+ };
1205
+ return {
1206
+ row: {
1207
+ name,
1208
+ field: entry.field,
1209
+ replaced: entry.replaced,
1210
+ manifest: declaration?.spec,
1211
+ ...report,
1212
+ file: entry.tarball,
1213
+ source: {
1214
+ directory: entry.source,
1215
+ commit: { recorded: entry.commit, current: git?.commit },
1216
+ dirty: { recorded: entry.dirty, current: git?.dirty },
1217
+ },
1218
+ at: entry.at,
1219
+ },
1220
+ problems: describeStatus(name, entry, normalizeSlashes(archive), report),
1221
+ };
1222
+ });
1223
+ const overrides = Object.entries(ledger.overrides).map(([name, entry]) => ({
1224
+ name,
1225
+ recorded: entry.value,
1226
+ replaced: entry.replaced,
1227
+ manifest: renderValue(current[name]),
1228
+ matches: current[name] === entry.value,
1229
+ }));
1230
+ const unrecorded = ['dependencies', 'devDependencies', 'overrides'].flatMap((block) => Object.entries(readBlock(manifest, block)).flatMap(([name, spec]) => typeof spec === 'string' &&
1231
+ isTarballSpec(spec) &&
1232
+ (block === 'overrides' ? ledger.overrides[name] : ledger.packages[name]) === undefined
1233
+ ? [`${block}.${name} holds a tarball spec the ledger has no record of`]
1234
+ : []));
1235
+ const problems = [
1236
+ ...packages.flatMap((pin) => pin.problems),
1237
+ ...overrides.flatMap((override) => override.matches
1238
+ ? []
1239
+ : [
1240
+ `overrides.${override.name} is ${override.manifest ?? '(absent)'}; the ledger records ${override.recorded}`,
1241
+ ]),
1242
+ ...unrecorded,
1243
+ ];
1244
+ printReport(command.json, [
1245
+ ...packages.map((pin) => `swap: ${pin.row.name} ${pin.row.pinned ? 'pinned' : 'unpinned'} ${pin.row.file}${pin.row.replaced === undefined ? '' : ` (replaced ${pin.row.replaced})`}`),
1246
+ ...problems.map((problem) => `swap: drift: ${problem}`),
1247
+ `swap: ${String(packages.length)} pin(s), ${String(problems.length)} drift(s)`,
1248
+ ], {
1249
+ mode: 'status',
1250
+ consumer: normalizeSlashes(command.consumer),
1251
+ packages: packages.map((pin) => pin.row),
1252
+ overrides,
1253
+ problems,
1254
+ });
1255
+ return problems.length === 0 ? 0 : EXIT_DRIFT;
1256
+ }
1257
+ function main(argv) {
1258
+ const parsed = parseCommand(argv);
1259
+ if (!parsed.ok)
1260
+ return reportUsage(parsed.message);
1261
+ try {
1262
+ if (parsed.command.mode === 'pin')
1263
+ return executePin(parsed.command);
1264
+ if (parsed.command.mode === 'restore')
1265
+ return executeRestore(parsed.command);
1266
+ return executeStatus(parsed.command);
1267
+ }
1268
+ catch (error) {
1269
+ process.stderr.write(`swap: ${error instanceof Error ? error.message : String(error)}\n`);
1270
+ return EXIT_FAILURE;
1271
+ }
1272
+ }
1273
+ process.exitCode = main(process.argv.slice(2));