@microwiseai/snapshot 0.6.0 → 0.7.0

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 (37) hide show
  1. package/dist/commands/current.d.ts +5 -1
  2. package/dist/commands/current.d.ts.map +1 -1
  3. package/dist/commands/current.js +89 -2
  4. package/dist/commands/current.js.map +1 -1
  5. package/dist/commands/doctor.d.ts.map +1 -1
  6. package/dist/commands/doctor.js +46 -0
  7. package/dist/commands/doctor.js.map +1 -1
  8. package/dist/commands/install.d.ts +41 -0
  9. package/dist/commands/install.d.ts.map +1 -1
  10. package/dist/commands/install.js +328 -10
  11. package/dist/commands/install.js.map +1 -1
  12. package/dist/commands/publish.d.ts.map +1 -1
  13. package/dist/commands/publish.js +44 -24
  14. package/dist/commands/publish.js.map +1 -1
  15. package/dist/index.js +5 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib/core-floor.d.ts +49 -0
  18. package/dist/lib/core-floor.d.ts.map +1 -0
  19. package/dist/lib/core-floor.js +78 -0
  20. package/dist/lib/core-floor.js.map +1 -0
  21. package/dist/lib/install-verify.d.ts +101 -0
  22. package/dist/lib/install-verify.d.ts.map +1 -0
  23. package/dist/lib/install-verify.js +143 -0
  24. package/dist/lib/install-verify.js.map +1 -0
  25. package/dist/lib/ledger.d.ts +212 -0
  26. package/dist/lib/ledger.d.ts.map +1 -0
  27. package/dist/lib/ledger.js +266 -0
  28. package/dist/lib/ledger.js.map +1 -0
  29. package/dist/lib/pin-override.d.ts +53 -0
  30. package/dist/lib/pin-override.d.ts.map +1 -0
  31. package/dist/lib/pin-override.js +77 -0
  32. package/dist/lib/pin-override.js.map +1 -0
  33. package/dist/lib/version-validator.d.ts +9 -0
  34. package/dist/lib/version-validator.d.ts.map +1 -1
  35. package/dist/lib/version-validator.js +69 -0
  36. package/dist/lib/version-validator.js.map +1 -1
  37. package/package.json +9 -2
@@ -14,6 +14,10 @@ import { authorizeSession, packagesToSpecs } from '../lib/session.js';
14
14
  import { parallelInstall, recordToEntries } from '@microwiseai/snapshot-parallel';
15
15
  import { dedupWithReport, dedupInstallBlock, recordInstalledSpecs, recordReferences, computeReplacePlan, applyReplaceHistory, } from '@microwiseai/snapshot-dedup';
16
16
  import { createInterface } from 'readline';
17
+ import { StepRecorder, buildLedger, writeLedger, queryDependencyTree, } from '../lib/ledger.js';
18
+ import { coreFloorAssert, formatCoreFloorOffenders, DEFAULT_CORE_FLOOR_PKG, DEFAULT_CORE_FLOOR_MIN_VERSION, } from '../lib/core-floor.js';
19
+ import { verifyInstallMap, runConverge, readInstalledNpmVersion, formatVerificationFailure, } from '../lib/install-verify.js';
20
+ import { parsePins, applyPinOverrides, unmatchedOverrides, PinParseError, } from '../lib/pin-override.js';
17
21
  // CLI version — dynamically read from package.json (SSOT)
18
22
  import { createRequire } from 'module';
19
23
  const _require = createRequire(import.meta.url);
@@ -251,7 +255,7 @@ function isNpmGlobalInstalled(name, version) {
251
255
  * Skit 패키지 설치 (lib 우선, CLI fallback)
252
256
  * @ist/skit lib가 있으면 직접 호출, 없으면 CLI 사용
253
257
  */
254
- async function installSkitPackages(packages, snapshotName, indent, sessionId) {
258
+ async function installSkitPackages(packages, snapshotName, indent, sessionId, recorder) {
255
259
  const result = { installed: [], failed: [] };
256
260
  const packageEntries = Object.entries(packages);
257
261
  if (packageEntries.length === 0) {
@@ -292,12 +296,20 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId) {
292
296
  });
293
297
  result.installed.push(...parallelResult.installed);
294
298
  result.failed.push(...parallelResult.failed);
299
+ // S3 — record the skit install as one ordered step.
300
+ recorder?.record({
301
+ phase: 'skit',
302
+ installer: 'skit',
303
+ command: `skit install (${entries.length} skits)`,
304
+ specs: entries.map((e) => `${e.name}@${e.version}`),
305
+ result: result.failed.length > 0 ? 'failed' : 'ok',
306
+ });
295
307
  return result;
296
308
  }
297
309
  /**
298
310
  * 타입별 패키지 설치 (non-skit)
299
311
  */
300
- async function installPackagesByType(packages, installerName, installers, snapshotName, indent, typeLabel, sessionId) {
312
+ async function installPackagesByType(packages, installerName, installers, snapshotName, indent, typeLabel, sessionId, recorder) {
301
313
  const result = { installed: [], failed: [] };
302
314
  const packageEntries = Object.entries(packages);
303
315
  if (packageEntries.length === 0) {
@@ -305,7 +317,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
305
317
  }
306
318
  // Skit packages use dedicated function
307
319
  if (installerName === 'skit') {
308
- return installSkitPackages(packages, snapshotName, indent, sessionId);
320
+ return installSkitPackages(packages, snapshotName, indent, sessionId, recorder);
309
321
  }
310
322
  const installerConfig = installers[installerName] || DEFAULT_INSTALLERS[installerName];
311
323
  if (!installerConfig) {
@@ -355,6 +367,14 @@ async function installPackagesByType(packages, installerName, installers, snapsh
355
367
  const batchCommand = installerConfig.command.replace('{pkg}', specs.join(' '));
356
368
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
357
369
  const success = runCommand(batchCommand);
370
+ // S3 — record the batch step (ok, or 'fallback' if it failed → retried below).
371
+ recorder?.record({
372
+ phase: 'layer-batch',
373
+ installer: installerName,
374
+ command: batchCommand,
375
+ specs,
376
+ result: success ? 'ok' : 'fallback',
377
+ });
358
378
  if (success) {
359
379
  for (const spec of specs) {
360
380
  console.log(`${indent}${chalk.blue('batch')} ${spec}...${chalk.green(' ✓')}`);
@@ -370,6 +390,13 @@ async function installPackagesByType(packages, installerName, installers, snapsh
370
390
  const pkgSpec = `${entry.name}@${entry.version}`;
371
391
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
372
392
  const ok = runCommand(command);
393
+ recorder?.record({
394
+ phase: 'layer-fallback',
395
+ installer: installerName,
396
+ command,
397
+ specs: [pkgSpec],
398
+ result: ok ? 'ok' : 'failed',
399
+ });
373
400
  if (ok) {
374
401
  console.log(`${indent}${chalk.blue('fallback')} ${pkgSpec}...${chalk.green(' ✓')}`);
375
402
  result.installed.push(pkgSpec);
@@ -404,15 +431,65 @@ async function installPackagesByType(packages, installerName, installers, snapsh
404
431
  });
405
432
  result.installed.push(...parallelResult.installed);
406
433
  result.failed.push(...parallelResult.failed);
434
+ // S3 — record the whole parallel (non-npm) install as one step.
435
+ recorder?.record({
436
+ phase: 'layer-parallel',
437
+ installer: installerName,
438
+ command: installerConfig.command.replace('{pkg}', '<each>'),
439
+ specs: entries.map((e) => `${e.name}@${e.version}`),
440
+ result: parallelResult.failed.length > 0 ? 'failed' : 'ok',
441
+ });
407
442
  }
408
443
  return result;
409
444
  }
445
+ /**
446
+ * FB — rewrite a snapshot's install/packages maps so any package with a pin
447
+ * override uses the override version. Returns a new snapshot (input untouched);
448
+ * only keys already present are overridden (no injection of undeclared pkgs).
449
+ */
450
+ function applySnapshotOverrides(snapshot, overrides) {
451
+ if (!overrides || Object.keys(overrides).length === 0)
452
+ return snapshot;
453
+ const next = { ...snapshot };
454
+ if (snapshot.install) {
455
+ const install = {};
456
+ for (const [installer, pkgs] of Object.entries(snapshot.install)) {
457
+ install[installer] = applyPinOverrides(pkgs, overrides);
458
+ }
459
+ next.install = install;
460
+ }
461
+ if (snapshot.packages) {
462
+ next.packages = applyPinOverrides(snapshot.packages, overrides);
463
+ }
464
+ return next;
465
+ }
466
+ /**
467
+ * FB — the subset of pin overrides that actually apply to this install (i.e. the
468
+ * overridden package IS declared in the effective npm map). These are the
469
+ * packages we targeted-converge so a --pin always holds even without a global
470
+ * --converge. Overrides for packages not in the snapshot are dropped (already
471
+ * warned via unmatchedOverrides). Returns name → override version.
472
+ */
473
+ export function overrideConvergeTargets(effectiveNpmMap, overrides) {
474
+ const targets = {};
475
+ for (const [name, version] of Object.entries(overrides)) {
476
+ if (name in effectiveNpmMap)
477
+ targets[name] = version;
478
+ }
479
+ return targets;
480
+ }
410
481
  export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}, options = {}) {
411
482
  const indent = ' '.repeat(depth);
412
483
  const result = {
413
484
  installed: [],
414
485
  failed: [],
415
486
  };
487
+ // FB — apply pin overrides to THIS layer's install/packages maps before doing
488
+ // anything, so both this layer and the extends recursion (which carries the
489
+ // same options.overrides) install the override version, never the original pin.
490
+ if (options.overrides) {
491
+ snapshot = applySnapshotOverrides(snapshot, options.overrides);
492
+ }
416
493
  // Merge installers: snapshot + parent (parent takes precedence for overrides like npm-proxy)
417
494
  let installers = { ...DEFAULT_INSTALLERS };
418
495
  if (snapshot.installers) {
@@ -512,21 +589,21 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
512
589
  const resolved = await resolveSnapshotDependencies(snapshot, { skipRegistry: true });
513
590
  // skit 패키지 설치 (skit-adapter 사용: lib 우선, CLI fallback)
514
591
  if (Object.keys(resolved.skit).length > 0) {
515
- const skitResult = await installSkitPackages(resolved.skit, snapshot.name, indent, options.sessionId);
592
+ const skitResult = await installSkitPackages(resolved.skit, snapshot.name, indent, options.sessionId, options.recorder);
516
593
  result.installed.push(...skitResult.installed);
517
594
  result.failed.push(...skitResult.failed);
518
595
  }
519
596
  // npm 패키지 설치 (proxy 사용 시 npm-proxy, 아니면 glpkg)
520
597
  if (Object.keys(resolved.npm).length > 0) {
521
598
  const npmInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
522
- const npmResult = await installPackagesByType(resolved.npm, npmInstaller, installers, snapshot.name, indent, 'npm packages', options.sessionId);
599
+ const npmResult = await installPackagesByType(resolved.npm, npmInstaller, installers, snapshot.name, indent, 'npm packages', options.sessionId, options.recorder);
523
600
  result.installed.push(...npmResult.installed);
524
601
  result.failed.push(...npmResult.failed);
525
602
  }
526
603
  // generic 패키지 설치 (glpkg)
527
604
  if (Object.keys(resolved.generic).length > 0) {
528
605
  const genericInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
529
- const genericResult = await installPackagesByType(resolved.generic, genericInstaller, installers, snapshot.name, indent, 'packages', options.sessionId);
606
+ const genericResult = await installPackagesByType(resolved.generic, genericInstaller, installers, snapshot.name, indent, 'packages', options.sessionId, options.recorder);
530
607
  result.installed.push(...genericResult.installed);
531
608
  result.failed.push(...genericResult.failed);
532
609
  }
@@ -543,7 +620,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
543
620
  continue;
544
621
  // Skit packages: use skit-adapter (lib 우선, CLI fallback)
545
622
  if (installerName === 'skit') {
546
- const skitResult = await installSkitPackages(packages, snapshot.name, indent, options.sessionId);
623
+ const skitResult = await installSkitPackages(packages, snapshot.name, indent, options.sessionId, options.recorder);
547
624
  result.installed.push(...skitResult.installed);
548
625
  result.failed.push(...skitResult.failed);
549
626
  continue;
@@ -597,6 +674,14 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
597
674
  const batchCommand = installCfg.command.replace('{pkg}', specs.join(' '));
598
675
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
599
676
  const success = runCommand(batchCommand);
677
+ // S3 — record the batch step (ok, or 'fallback' if it failed → retried below).
678
+ options.recorder?.record({
679
+ phase: 'layer-batch',
680
+ installer: installerName,
681
+ command: batchCommand,
682
+ specs,
683
+ result: success ? 'ok' : 'fallback',
684
+ });
600
685
  if (success) {
601
686
  for (const spec of specs) {
602
687
  console.log(`${indent}${chalk.blue('batch')} ${spec}...${chalk.green(' ✓')}`);
@@ -612,6 +697,13 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
612
697
  const pkgSpec = `${entry.name}@${entry.version}`;
613
698
  const command = installCfg.command.replace('{pkg}', pkgSpec);
614
699
  const ok = runCommand(command);
700
+ options.recorder?.record({
701
+ phase: 'layer-fallback',
702
+ installer: installerName,
703
+ command,
704
+ specs: [pkgSpec],
705
+ result: ok ? 'ok' : 'failed',
706
+ });
615
707
  if (ok) {
616
708
  console.log(`${indent}${chalk.blue('fallback')} ${pkgSpec}...${chalk.green(' ✓')}`);
617
709
  result.installed.push(pkgSpec);
@@ -646,6 +738,14 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
646
738
  });
647
739
  result.installed.push(...parallelResult.installed);
648
740
  result.failed.push(...parallelResult.failed);
741
+ // S3 — record the whole parallel (non-npm) install as one step.
742
+ options.recorder?.record({
743
+ phase: 'layer-parallel',
744
+ installer: installerName,
745
+ command: installCfg.command.replace('{pkg}', '<each>'),
746
+ specs: installEntries.map((e) => `${e.name}@${e.version}`),
747
+ result: parallelResult.failed.length > 0 ? 'failed' : 'ok',
748
+ });
649
749
  }
650
750
  }
651
751
  }
@@ -694,11 +794,19 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
694
794
  });
695
795
  result.installed.push(...parallelResult.installed);
696
796
  result.failed.push(...parallelResult.failed);
797
+ // S3 — record the legacy glpkg parallel install as one step.
798
+ options.recorder?.record({
799
+ phase: 'layer-parallel',
800
+ installer: 'glpkg',
801
+ command: legacyCfg.command.replace('{pkg}', '<each>'),
802
+ specs: legacyEntries.map((e) => `${e.name}@${e.version}`),
803
+ result: parallelResult.failed.length > 0 ? 'failed' : 'ok',
804
+ });
697
805
  }
698
806
  }
699
807
  // Legacy: skits (use skit-adapter: lib 우선, CLI fallback)
700
808
  if (snapshot.skits && Object.keys(snapshot.skits).length > 0) {
701
- const skitResult = await installSkitPackages(snapshot.skits, snapshot.name, indent, options.sessionId);
809
+ const skitResult = await installSkitPackages(snapshot.skits, snapshot.name, indent, options.sessionId, options.recorder);
702
810
  result.installed.push(...skitResult.installed);
703
811
  result.failed.push(...skitResult.failed);
704
812
  }
@@ -758,7 +866,7 @@ async function collectAllNpmPackages(snapshot, useProxy = true) {
758
866
  * This ensures .npmrc is configured with ALL scopes before installation begins,
759
867
  * including scopes from base snapshots (e.g., @microwiseai from @ist/minimal).
760
868
  */
761
- async function collectAllPackages(snapshot) {
869
+ export async function collectAllPackages(snapshot) {
762
870
  const allPackages = {};
763
871
  // Collect from current snapshot
764
872
  if (snapshot.packages) {
@@ -1036,6 +1144,31 @@ export async function installCommand(name, options = {}) {
1036
1144
  await previewInstall(snapshot, options);
1037
1145
  return;
1038
1146
  }
1147
+ // ========== FB — parse pin overrides (--pin) ==========
1148
+ // Malformed specs are fail-loud (exit 1) BEFORE any install side effect. The
1149
+ // top snapshot's maps are rewritten now so downstream session authorization,
1150
+ // scope collection, the pre-install batch AND installSnapshot all see the
1151
+ // override version. The extends chain is covered via options.overrides.
1152
+ let overrides = {};
1153
+ try {
1154
+ overrides = parsePins(options.pin);
1155
+ }
1156
+ catch (err) {
1157
+ if (err instanceof PinParseError) {
1158
+ console.log(chalk.red(`⛔ Invalid --pin: ${err.message}`));
1159
+ console.log(chalk.gray(' Expected form: --pin @scope/name@exact-version (e.g. --pin @ist/sesh@0.4.0)'));
1160
+ process.exit(1);
1161
+ }
1162
+ throw err;
1163
+ }
1164
+ if (Object.keys(overrides).length > 0) {
1165
+ snapshot = applySnapshotOverrides(snapshot, overrides);
1166
+ console.log('');
1167
+ console.log(chalk.cyan('► FB pin overrides:'));
1168
+ for (const [name, version] of Object.entries(overrides)) {
1169
+ console.log(chalk.cyan(` ⇄ ${name} → ${version}`));
1170
+ }
1171
+ }
1039
1172
  // ========== Access Check (based on snapshot.json access field) ==========
1040
1173
  // Determine access type from snapshot.json:
1041
1174
  // - access: "public" → no login/license required
@@ -1200,11 +1333,24 @@ export async function installCommand(name, options = {}) {
1200
1333
  effectiveInstallers['glpkg'] = DEFAULT_INSTALLERS['npm-proxy'];
1201
1334
  effectiveInstallers['gitlab-install'] = DEFAULT_INSTALLERS['npm-proxy'];
1202
1335
  }
1336
+ // S3 — one step recorder for the whole install, threaded through the
1337
+ // pre-install batch AND installSnapshot (via options.recorder), so the ledger
1338
+ // captures the ordered install steps.
1339
+ const recorder = new StepRecorder();
1203
1340
  // ========== Pre-install: batch all npm packages from extends chain ==========
1204
1341
  // Collect all npm-type packages across the entire extends chain and install
1205
1342
  // them in a single `npm install -g` call, instead of one per snapshot layer.
1206
1343
  const allNpmPackages = await collectAllNpmPackages(snapshot, useProxy);
1207
- const npmEntries = Object.entries(allNpmPackages);
1344
+ // FB — flatten overrides across the whole chain. `snapshot` was already
1345
+ // rewritten, but base layers fetched inside collectAllNpmPackages were not, so
1346
+ // apply once more to the flattened map. This is the "expected" pin set (what
1347
+ // SHOULD be on disk) used for the pre-install batch AND verification.
1348
+ const effectiveNpmMap = applyPinOverrides(allNpmPackages, overrides);
1349
+ const unmatched = unmatchedOverrides(allNpmPackages, overrides);
1350
+ if (unmatched.length > 0) {
1351
+ console.log(chalk.yellow(`⚠ --pin ignored for package(s) not in this snapshot: ${unmatched.join(', ')}`));
1352
+ }
1353
+ const npmEntries = Object.entries(effectiveNpmMap);
1208
1354
  if (npmEntries.length > 0) {
1209
1355
  console.log('');
1210
1356
  console.log(chalk.cyan(`► Pre-installing ${npmEntries.length} npm packages from entire extends chain...`));
@@ -1223,6 +1369,15 @@ export async function installCommand(name, options = {}) {
1223
1369
  const npmCommand = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', specs.join(' '));
1224
1370
  console.log(chalk.gray(` Running single batch: npm install -g (${specs.length} packages)`));
1225
1371
  const batchSuccess = runCommand(npmCommand);
1372
+ // S3 — record the up-front batch (the single step where the beta npm tree
1373
+ // is actually resolved). 'fallback' means it failed and we retried below.
1374
+ recorder.record({
1375
+ phase: 'pre-install-batch',
1376
+ installer: 'npm-proxy',
1377
+ command: npmCommand,
1378
+ specs,
1379
+ result: batchSuccess ? 'ok' : 'fallback',
1380
+ });
1226
1381
  if (batchSuccess) {
1227
1382
  for (const spec of specs) {
1228
1383
  console.log(` ${chalk.blue('batch')} ${spec}...${chalk.green(' ✓')}`);
@@ -1239,6 +1394,13 @@ export async function installCommand(name, options = {}) {
1239
1394
  const pkgSpec = `${entry.name}@${entry.version}`;
1240
1395
  const cmd = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', pkgSpec);
1241
1396
  const ok = runCommand(cmd);
1397
+ recorder.record({
1398
+ phase: 'pre-install-fallback',
1399
+ installer: 'npm-proxy',
1400
+ command: cmd,
1401
+ specs: [pkgSpec],
1402
+ result: ok ? 'ok' : 'failed',
1403
+ });
1242
1404
  if (ok) {
1243
1405
  console.log(` ${chalk.blue('fallback')} ${pkgSpec}...${chalk.green(' ✓')}`);
1244
1406
  }
@@ -1257,6 +1419,14 @@ export async function installCommand(name, options = {}) {
1257
1419
  // installSnapshot's internal isNpmGlobalInstalled checks will skip already-installed packages
1258
1420
  let installError = null;
1259
1421
  let result = { installed: [], failed: [] };
1422
+ // S1 — post-install verification verdict (disk vs manifest pins). Computed
1423
+ // inside the try BELOW so it runs while the registry-proxy .npmrc + session
1424
+ // token are still active (runConverge issues `npm install -g`).
1425
+ let verification;
1426
+ // ★ Convergence is ON BY DEFAULT (owner decision). `--no-converge` opts out
1427
+ // for a raw/fast install (commander sets options.converge=false). Programmatic
1428
+ // lib callers get the default (true) unless they pass converge:false.
1429
+ const converge = options.converge !== false;
1260
1430
  try {
1261
1431
  // Auto-detect 모드: snapshot.install이 없으면 자동 판별
1262
1432
  const autoDetect = !snapshot.install;
@@ -1264,8 +1434,51 @@ export async function installCommand(name, options = {}) {
1264
1434
  useProxy,
1265
1435
  autoDetect,
1266
1436
  sessionId,
1437
+ recorder,
1438
+ overrides,
1267
1439
  });
1268
1440
  result = installResult.result;
1441
+ // ===== S1/FB — verify (and optionally converge) BEFORE cleanup =====
1442
+ // SSOT enforcement: the manifest pins (with FB overrides applied) are the
1443
+ // truth. Check the disk actually matches. This MUST run before the finally
1444
+ // restores .npmrc, because re-pinning via `npm install -g` needs the
1445
+ // registry-proxy config + token still in place.
1446
+ if (Object.keys(effectiveNpmMap).length > 0) {
1447
+ // Re-pin one package to an exact version and record it as a ledger step.
1448
+ const repin = (phase) => (name, version) => {
1449
+ const spec = `${name}@${version}`;
1450
+ const cmd = `npm install -g ${spec}`;
1451
+ const ok = runCommand(cmd);
1452
+ recorder.record({ phase, installer: 'npm-proxy', command: cmd, specs: [spec], result: ok ? 'ok' : 'failed' });
1453
+ };
1454
+ if (converge) {
1455
+ // Default: re-pin EVERY package that diverged from its pin (self-healing).
1456
+ console.log('');
1457
+ console.log(chalk.cyan('► Verifying & converging install to manifest pins...'));
1458
+ verification = runConverge(allNpmPackages, overrides, {
1459
+ install: repin('converge'),
1460
+ readVersion: readInstalledNpmVersion,
1461
+ maxRounds: 3,
1462
+ });
1463
+ }
1464
+ else {
1465
+ // --no-converge (raw/fast): do NOT auto-repair general divergence. But a
1466
+ // --pin must STILL hold (npm dedup can revert it), so converge JUST the
1467
+ // overridden packages (targeted), then take the final verdict over the
1468
+ // full map. Non-overridden divergences fail-loud (see below).
1469
+ const pinTargets = overrideConvergeTargets(effectiveNpmMap, overrides);
1470
+ if (Object.keys(pinTargets).length > 0) {
1471
+ console.log('');
1472
+ console.log(chalk.cyan(`► Holding ${Object.keys(pinTargets).length} pinned package(s) to the override version...`));
1473
+ runConverge(pinTargets, {}, {
1474
+ install: repin('pin-converge'),
1475
+ readVersion: readInstalledNpmVersion,
1476
+ maxRounds: 3,
1477
+ });
1478
+ }
1479
+ verification = verifyInstallMap(effectiveNpmMap, { readVersion: readInstalledNpmVersion });
1480
+ }
1481
+ }
1269
1482
  }
1270
1483
  catch (err) {
1271
1484
  installError = err instanceof Error ? err : new Error(String(err));
@@ -1289,6 +1502,16 @@ export async function installCommand(name, options = {}) {
1289
1502
  console.log(chalk.yellow('═══════════════════════════════════'));
1290
1503
  const total = result.installed.length + result.failed.length;
1291
1504
  console.log(`Installed: ${chalk.green(result.installed.length)}/${total}`);
1505
+ // S1 — promote "Installed N/N" to the real health signal: "verified N/N"
1506
+ // (disk actually matches the manifest pins), not just "npm exited 0".
1507
+ if (verification) {
1508
+ if (verification.ok) {
1509
+ console.log(`Verified: ${chalk.green(verification.verifiedCount)}/${verification.total} ${chalk.gray('(disk matches manifest pins)')}`);
1510
+ }
1511
+ else {
1512
+ console.log(`Verified: ${chalk.red(verification.verifiedCount)}/${verification.total} ${chalk.red('(disk diverged from manifest pins)')}`);
1513
+ }
1514
+ }
1292
1515
  if (result.failed.length > 0) {
1293
1516
  console.log('');
1294
1517
  console.log(chalk.red('Failed:'));
@@ -1309,6 +1532,101 @@ export async function installCommand(name, options = {}) {
1309
1532
  console.log('');
1310
1533
  console.log(chalk.green('✓') + ' Snapshot info saved');
1311
1534
  }
1535
+ // ========== S3: install ledger (provenance + step order) ==========
1536
+ // Record where every declared npm package's on-disk version came from
1537
+ // (resolved version, tarball URL, resolution source, install-step order),
1538
+ // so "이 버전이 어디서 왔나" is answerable from the ledger ALONE (AC3.1).
1539
+ // Written on every install with npm packages, best-effort — a ledger failure
1540
+ // must never fail the install itself.
1541
+ // AC1.4 — core-floor verdict (computed inside the ledger block, folded in +
1542
+ // used for fail-loud below). undefined when there are no npm packages.
1543
+ let coreFloor;
1544
+ if (Object.keys(allNpmPackages).length > 0) {
1545
+ try {
1546
+ // ★ DEEP, nested-visible tree (NOT --depth=0). Scope to the install's npm
1547
+ // packages + the singleton so an OLD @ist/adapters-core buried under
1548
+ // another package (which re-fragments the S2 registry) is surfaced.
1549
+ const deepNames = Array.from(new Set([...Object.keys(effectiveNpmMap), DEFAULT_CORE_FLOOR_PKG]));
1550
+ const dependencyTreeRaw = queryDependencyTree(deepNames);
1551
+ // AC1.4 (R-A) — no copy of @ist/adapters-core (top-level OR nested) may be
1552
+ // below the 0.4.2 floor; an old nested copy silently re-breaks codex.
1553
+ coreFloor = coreFloorAssert(dependencyTreeRaw); // defaults: @ist/adapters-core >= 0.4.2
1554
+ const ledger = buildLedger({
1555
+ snapshotName: snapshot.name,
1556
+ snapshotVersion: snapshot.version,
1557
+ cliVersion: CLI_VERSION,
1558
+ registry: REGISTRY_PROXY_URL,
1559
+ // "expected" = the pins we intended to land = manifest pins with FB
1560
+ // overrides applied. This is what verification checks against too.
1561
+ expected: effectiveNpmMap,
1562
+ installer: 'npm-proxy',
1563
+ steps: recorder.getSteps(),
1564
+ overrides, // FB pin overrides (name → version); {} when no --pin
1565
+ dependencyTreeRaw, // deep tree (nested-visible) — provenance + AC1.4
1566
+ }, new Date().toISOString());
1567
+ // S1 — fold the verification verdict into the ledger (single artifact for
1568
+ // provenance + verdict). VerificationResult is structurally the ledger's
1569
+ // verification shape { ok, verifiedCount, total, mismatches[{name,expected,got}] }.
1570
+ if (verification)
1571
+ ledger.verification = verification;
1572
+ // AC1.4 — fold the core-floor verdict too.
1573
+ ledger.coreFloor = {
1574
+ ok: coreFloor.ok,
1575
+ pkg: DEFAULT_CORE_FLOOR_PKG,
1576
+ minVersion: DEFAULT_CORE_FLOOR_MIN_VERSION,
1577
+ offenders: coreFloor.offenders,
1578
+ };
1579
+ if (writeLedger(ledger)) {
1580
+ console.log(chalk.green('✓') + ' Install ledger written (see: snapshot current --json)');
1581
+ }
1582
+ }
1583
+ catch {
1584
+ // Ledger is diagnostic-only — swallow any failure.
1585
+ }
1586
+ }
1587
+ // ========== Divergence handling — S1 verify + AC1.4 core-floor ==========
1588
+ // Owner decision: NO "green on broken". Any residual divergence is a hard
1589
+ // fail-loud (exit 1), in BOTH modes:
1590
+ // • core-floor offenders → ALWAYS exit 1 (convergence cannot repair an old
1591
+ // NESTED @ist/adapters-core copy; AC1.4 / R-A).
1592
+ // • verify divergence → exit 1 in both modes. The only difference: default
1593
+ // (converge) mode ATTEMPTED auto-repair first and still couldn't reconcile
1594
+ // (e.g. an unpublished pin), whereas --no-converge (strict/raw) never
1595
+ // attempted repair. The ledger persisted the full evidence either way.
1596
+ const verifyFailed = !!(verification && !verification.ok);
1597
+ const floorFailed = !!(coreFloor && !coreFloor.ok);
1598
+ if (floorFailed) {
1599
+ console.log('');
1600
+ console.log(chalk.red(formatCoreFloorOffenders(coreFloor.offenders)));
1601
+ console.log(chalk.gray(` Every ${DEFAULT_CORE_FLOOR_PKG} copy (incl. nested) must be >= ${DEFAULT_CORE_FLOOR_MIN_VERSION} for the S2 singleton to hold.`));
1602
+ }
1603
+ if (verifyFailed) {
1604
+ console.log('');
1605
+ if (!converge) {
1606
+ // strict (--no-converge): no auto-repair was attempted.
1607
+ console.log(chalk.red(formatVerificationFailure(verification.mismatches)));
1608
+ if (Object.keys(overrides).length > 0) {
1609
+ // --pin packages were held via targeted convergence; the divergences
1610
+ // above are OTHER packages — don't suggest re-pinning what's pinned.
1611
+ console.log(chalk.gray(' The pinned package(s) are held; the divergence(s) above are other packages — drop --no-converge to auto-repair them.'));
1612
+ }
1613
+ else {
1614
+ console.log(chalk.gray(' Drop --no-converge to auto-repair, or --pin <pkg>@<version> to force a specific version.'));
1615
+ }
1616
+ }
1617
+ else {
1618
+ // default (converge ON): repair was attempted but couldn't reconcile → still
1619
+ // a hard failure (no silent green-on-broken).
1620
+ console.log(chalk.red('✗ Converge could not reconcile the disk to the manifest pins after 3 rounds:'));
1621
+ console.log(chalk.red(formatVerificationFailure(verification.mismatches)));
1622
+ console.log(chalk.gray(' The above may be unpublished/unavailable versions. See: snapshot current --json (ledger has full evidence).'));
1623
+ }
1624
+ }
1625
+ // No green-on-broken: exit non-zero on ANY core-floor offender OR verify
1626
+ // divergence, in both converge and --no-converge modes.
1627
+ if (floorFailed || verifyFailed) {
1628
+ process.exit(1);
1629
+ }
1312
1630
  // Record references for the ENTIRE declared set (installed + dedup-skipped),
1313
1631
  // so replace mode has accurate referencedBy data (ADR 001 §c). This runs on
1314
1632
  // every install, not just --replace, to build up the reference graph.