@microwiseai/snapshot 0.5.2 → 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 (159) 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 +2 -0
  6. package/dist/commands/doctor.d.ts.map +1 -0
  7. package/dist/commands/doctor.js +83 -0
  8. package/dist/commands/doctor.js.map +1 -0
  9. package/dist/commands/install.d.ts +41 -0
  10. package/dist/commands/install.d.ts.map +1 -1
  11. package/dist/commands/install.js +396 -216
  12. package/dist/commands/install.js.map +1 -1
  13. package/dist/commands/publish.d.ts +0 -4
  14. package/dist/commands/publish.d.ts.map +1 -1
  15. package/dist/commands/publish.js +44 -58
  16. package/dist/commands/publish.js.map +1 -1
  17. package/dist/commands/setup.d.ts +5 -0
  18. package/dist/commands/setup.d.ts.map +1 -0
  19. package/dist/commands/setup.js +52 -0
  20. package/dist/commands/setup.js.map +1 -0
  21. package/dist/index.js +22 -28
  22. package/dist/index.js.map +1 -1
  23. package/dist/lib/config.js +1 -1
  24. package/dist/lib/config.js.map +1 -1
  25. package/dist/lib/core-floor.d.ts +49 -0
  26. package/dist/lib/core-floor.d.ts.map +1 -0
  27. package/dist/lib/core-floor.js +78 -0
  28. package/dist/lib/core-floor.js.map +1 -0
  29. package/dist/lib/gitlab.d.ts.map +1 -1
  30. package/dist/lib/gitlab.js +8 -6
  31. package/dist/lib/gitlab.js.map +1 -1
  32. package/dist/lib/install-verify.d.ts +101 -0
  33. package/dist/lib/install-verify.d.ts.map +1 -0
  34. package/dist/lib/install-verify.js +143 -0
  35. package/dist/lib/install-verify.js.map +1 -0
  36. package/dist/lib/ledger.d.ts +212 -0
  37. package/dist/lib/ledger.d.ts.map +1 -0
  38. package/dist/lib/ledger.js +266 -0
  39. package/dist/lib/ledger.js.map +1 -0
  40. package/dist/lib/pin-override.d.ts +53 -0
  41. package/dist/lib/pin-override.d.ts.map +1 -0
  42. package/dist/lib/pin-override.js +77 -0
  43. package/dist/lib/pin-override.js.map +1 -0
  44. package/dist/lib/prerequisites.d.ts +33 -0
  45. package/dist/lib/prerequisites.d.ts.map +1 -0
  46. package/dist/lib/prerequisites.js +123 -0
  47. package/dist/lib/prerequisites.js.map +1 -0
  48. package/dist/lib/transitive-resolver.d.ts +0 -121
  49. package/dist/lib/transitive-resolver.d.ts.map +1 -1
  50. package/dist/lib/transitive-resolver.js +0 -190
  51. package/dist/lib/transitive-resolver.js.map +1 -1
  52. package/dist/lib/version-validator.d.ts +9 -0
  53. package/dist/lib/version-validator.d.ts.map +1 -1
  54. package/dist/lib/version-validator.js +69 -0
  55. package/dist/lib/version-validator.js.map +1 -1
  56. package/package.json +9 -19
  57. package/dist/commands/clone-all.d.ts +0 -21
  58. package/dist/commands/clone-all.d.ts.map +0 -1
  59. package/dist/commands/clone-all.js +0 -57
  60. package/dist/commands/clone-all.js.map +0 -1
  61. package/dist/commands/preflight.d.ts +0 -31
  62. package/dist/commands/preflight.d.ts.map +0 -1
  63. package/dist/commands/preflight.js +0 -83
  64. package/dist/commands/preflight.js.map +0 -1
  65. package/dist/lib/distkit-closure-bridge.d.ts +0 -102
  66. package/dist/lib/distkit-closure-bridge.d.ts.map +0 -1
  67. package/dist/lib/distkit-closure-bridge.js +0 -177
  68. package/dist/lib/distkit-closure-bridge.js.map +0 -1
  69. package/dist/lib/distkit-closure-parity.d.ts +0 -110
  70. package/dist/lib/distkit-closure-parity.d.ts.map +0 -1
  71. package/dist/lib/distkit-closure-parity.js +0 -134
  72. package/dist/lib/distkit-closure-parity.js.map +0 -1
  73. package/dist/lib/distkit-routing-port.d.ts +0 -37
  74. package/dist/lib/distkit-routing-port.d.ts.map +0 -1
  75. package/dist/lib/distkit-routing-port.js +0 -72
  76. package/dist/lib/distkit-routing-port.js.map +0 -1
  77. package/dist/lib/install-timeline.d.ts +0 -81
  78. package/dist/lib/install-timeline.d.ts.map +0 -1
  79. package/dist/lib/install-timeline.js +0 -243
  80. package/dist/lib/install-timeline.js.map +0 -1
  81. package/dist/lib/preflight-adapter.d.ts +0 -52
  82. package/dist/lib/preflight-adapter.d.ts.map +0 -1
  83. package/dist/lib/preflight-adapter.js +0 -224
  84. package/dist/lib/preflight-adapter.js.map +0 -1
  85. package/node_modules/@gitdot/clone-lib/README.md +0 -117
  86. package/node_modules/@gitdot/clone-lib/dist/bin/gitdot-resolve.d.ts +0 -9
  87. package/node_modules/@gitdot/clone-lib/dist/bin/gitdot-resolve.d.ts.map +0 -1
  88. package/node_modules/@gitdot/clone-lib/dist/bin/gitdot-resolve.js +0 -154
  89. package/node_modules/@gitdot/clone-lib/dist/bin/gitdot-resolve.js.map +0 -1
  90. package/node_modules/@gitdot/clone-lib/dist/clone.d.ts +0 -21
  91. package/node_modules/@gitdot/clone-lib/dist/clone.d.ts.map +0 -1
  92. package/node_modules/@gitdot/clone-lib/dist/clone.js +0 -228
  93. package/node_modules/@gitdot/clone-lib/dist/clone.js.map +0 -1
  94. package/node_modules/@gitdot/clone-lib/dist/dotpath.d.ts +0 -10
  95. package/node_modules/@gitdot/clone-lib/dist/dotpath.d.ts.map +0 -1
  96. package/node_modules/@gitdot/clone-lib/dist/dotpath.js +0 -27
  97. package/node_modules/@gitdot/clone-lib/dist/dotpath.js.map +0 -1
  98. package/node_modules/@gitdot/clone-lib/dist/git.d.ts +0 -23
  99. package/node_modules/@gitdot/clone-lib/dist/git.d.ts.map +0 -1
  100. package/node_modules/@gitdot/clone-lib/dist/git.js +0 -134
  101. package/node_modules/@gitdot/clone-lib/dist/git.js.map +0 -1
  102. package/node_modules/@gitdot/clone-lib/dist/index.d.ts +0 -17
  103. package/node_modules/@gitdot/clone-lib/dist/index.d.ts.map +0 -1
  104. package/node_modules/@gitdot/clone-lib/dist/index.js +0 -35
  105. package/node_modules/@gitdot/clone-lib/dist/index.js.map +0 -1
  106. package/node_modules/@gitdot/clone-lib/dist/lock.d.ts +0 -24
  107. package/node_modules/@gitdot/clone-lib/dist/lock.d.ts.map +0 -1
  108. package/node_modules/@gitdot/clone-lib/dist/lock.js +0 -97
  109. package/node_modules/@gitdot/clone-lib/dist/lock.js.map +0 -1
  110. package/node_modules/@gitdot/clone-lib/dist/resolve/gitlab.d.ts +0 -15
  111. package/node_modules/@gitdot/clone-lib/dist/resolve/gitlab.d.ts.map +0 -1
  112. package/node_modules/@gitdot/clone-lib/dist/resolve/gitlab.js +0 -124
  113. package/node_modules/@gitdot/clone-lib/dist/resolve/gitlab.js.map +0 -1
  114. package/node_modules/@gitdot/clone-lib/dist/resolve/index.d.ts +0 -12
  115. package/node_modules/@gitdot/clone-lib/dist/resolve/index.d.ts.map +0 -1
  116. package/node_modules/@gitdot/clone-lib/dist/resolve/index.js +0 -31
  117. package/node_modules/@gitdot/clone-lib/dist/resolve/index.js.map +0 -1
  118. package/node_modules/@gitdot/clone-lib/dist/resolve/manifest.d.ts +0 -32
  119. package/node_modules/@gitdot/clone-lib/dist/resolve/manifest.d.ts.map +0 -1
  120. package/node_modules/@gitdot/clone-lib/dist/resolve/manifest.js +0 -159
  121. package/node_modules/@gitdot/clone-lib/dist/resolve/manifest.js.map +0 -1
  122. package/node_modules/@gitdot/clone-lib/dist/resolve/member.d.ts +0 -44
  123. package/node_modules/@gitdot/clone-lib/dist/resolve/member.d.ts.map +0 -1
  124. package/node_modules/@gitdot/clone-lib/dist/resolve/member.js +0 -163
  125. package/node_modules/@gitdot/clone-lib/dist/resolve/member.js.map +0 -1
  126. package/node_modules/@gitdot/clone-lib/dist/resolve/resolve.d.ts +0 -18
  127. package/node_modules/@gitdot/clone-lib/dist/resolve/resolve.d.ts.map +0 -1
  128. package/node_modules/@gitdot/clone-lib/dist/resolve/resolve.js +0 -155
  129. package/node_modules/@gitdot/clone-lib/dist/resolve/resolve.js.map +0 -1
  130. package/node_modules/@gitdot/clone-lib/dist/types.d.ts +0 -225
  131. package/node_modules/@gitdot/clone-lib/dist/types.d.ts.map +0 -1
  132. package/node_modules/@gitdot/clone-lib/dist/types.js +0 -9
  133. package/node_modules/@gitdot/clone-lib/dist/types.js.map +0 -1
  134. package/node_modules/@gitdot/clone-lib/package.json +0 -53
  135. package/node_modules/@microwiseai/install-preflight/dist/index.d.ts +0 -27
  136. package/node_modules/@microwiseai/install-preflight/dist/index.d.ts.map +0 -1
  137. package/node_modules/@microwiseai/install-preflight/dist/index.js +0 -26
  138. package/node_modules/@microwiseai/install-preflight/dist/index.js.map +0 -1
  139. package/node_modules/@microwiseai/install-preflight/dist/policy.d.ts +0 -42
  140. package/node_modules/@microwiseai/install-preflight/dist/policy.d.ts.map +0 -1
  141. package/node_modules/@microwiseai/install-preflight/dist/policy.js +0 -79
  142. package/node_modules/@microwiseai/install-preflight/dist/policy.js.map +0 -1
  143. package/node_modules/@microwiseai/install-preflight/dist/preflight.d.ts +0 -18
  144. package/node_modules/@microwiseai/install-preflight/dist/preflight.d.ts.map +0 -1
  145. package/node_modules/@microwiseai/install-preflight/dist/preflight.js +0 -0
  146. package/node_modules/@microwiseai/install-preflight/dist/preflight.js.map +0 -1
  147. package/node_modules/@microwiseai/install-preflight/dist/resolvers.d.ts +0 -26
  148. package/node_modules/@microwiseai/install-preflight/dist/resolvers.d.ts.map +0 -1
  149. package/node_modules/@microwiseai/install-preflight/dist/resolvers.js +0 -63
  150. package/node_modules/@microwiseai/install-preflight/dist/resolvers.js.map +0 -1
  151. package/node_modules/@microwiseai/install-preflight/dist/semver.d.ts +0 -45
  152. package/node_modules/@microwiseai/install-preflight/dist/semver.d.ts.map +0 -1
  153. package/node_modules/@microwiseai/install-preflight/dist/semver.js +0 -195
  154. package/node_modules/@microwiseai/install-preflight/dist/semver.js.map +0 -1
  155. package/node_modules/@microwiseai/install-preflight/dist/types.d.ts +0 -136
  156. package/node_modules/@microwiseai/install-preflight/dist/types.d.ts.map +0 -1
  157. package/node_modules/@microwiseai/install-preflight/dist/types.js +0 -11
  158. package/node_modules/@microwiseai/install-preflight/dist/types.js.map +0 -1
  159. package/node_modules/@microwiseai/install-preflight/package.json +0 -49
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
- import { execSync, spawn } from 'child_process';
3
- import { writeFileSync, existsSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
2
+ import { execSync } from 'child_process';
3
+ import { writeFileSync, existsSync, readFileSync, unlinkSync } from 'fs';
4
4
  import { homedir, platform, arch } from 'os';
5
5
  import { join } from 'path';
6
6
  import { fetchSnapshotFromRepo, snapshotNameToRepoName } from '../lib/gitlab.js';
@@ -14,12 +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 { createInstallTimeline } from '../lib/install-timeline.js';
18
- // Install-timeline instrumentation (investigation only, env opt-in). A disabled
19
- // no-op is the default so module-level callers (runCommand, installSkitPackages)
20
- // always have a valid handle; installCommand() reassigns a live instance when
21
- // SNAPSHOT_INSTALL_TIMELINE is set. Adds zero behavior — only measurement.
22
- let activeTimeline = createInstallTimeline({ enabled: false });
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';
23
21
  // CLI version — dynamically read from package.json (SSOT)
24
22
  import { createRequire } from 'module';
25
23
  const _require = createRequire(import.meta.url);
@@ -200,74 +198,27 @@ function commandExists(cmd) {
200
198
  // Captures the failure reason of the most recent runCommand() call so callers
201
199
  // can surface it (debugging visibility). Undefined when the last call succeeded.
202
200
  let lastCommandError;
203
- // Bound the captured-stderr buffer so a chatty command can't grow it without
204
- // limit; on overflow we keep the tail (where the actual failure usually is).
205
- const MAX_STDERR_CAPTURE = 64 * 1024;
206
- // Runs a shell command, streaming its output live while still capturing stderr
207
- // for failure diagnostics.
208
- //
209
- // Why spawn (async) instead of execSync: long installs (e.g. `npm install -g`
210
- // of the whole pre-install batch) push their live progress to STDERR. execSync
211
- // with a piped stderr hid that progress entirely — the terminal sat silent for
212
- // minutes, then dumped a summary. Here stderr is piped so we can BOTH tee it to
213
- // our own stderr in real time (visible progress) AND accumulate it, so the raw
214
- // reason is still available via lastCommandError when the command fails.
215
- //
216
- // stdout stays inherited (live, unbuffered). shell:true preserves the previous
217
- // execSync parsing of the full command string.
218
201
  function runCommand(command) {
219
202
  lastCommandError = undefined;
220
- // Instrumentation only: the timeline sanitizes the command label internally,
221
- // so tokens are never recorded. No effect when the timeline is disabled.
222
- const span = activeTimeline.start(command, { category: 'command' });
223
- return new Promise((resolve) => {
224
- const child = spawn(command, {
225
- shell: true,
226
- stdio: ['inherit', 'inherit', 'pipe'],
227
- });
228
- let stderrBuf = '';
229
- child.stderr?.on('data', (chunk) => {
230
- // Live streaming: echo the command's progress as it happens.
231
- process.stderr.write(chunk);
232
- // Capture (bounded) for failure diagnostics.
233
- stderrBuf += chunk.toString();
234
- if (stderrBuf.length > MAX_STDERR_CAPTURE) {
235
- stderrBuf = stderrBuf.slice(-MAX_STDERR_CAPTURE);
236
- }
237
- });
238
- // 'error' (spawn failed) and 'close' (process exited) are mutually exclusive
239
- // in practice, but guard against a double-resolve just in case.
240
- let settled = false;
241
- const finish = (ok) => {
242
- if (settled)
243
- return;
244
- settled = true;
245
- resolve(ok);
246
- };
247
- child.on('error', (err) => {
248
- // Spawn-level failure (e.g. shell missing) — no exit code available.
249
- lastCommandError = `spawn error — ${err.message}`;
250
- span.end({ success: false, exitReason: 'spawn error' });
251
- console.log(chalk.red(` Command failed to start: ${command}`));
252
- console.log(chalk.red(err.message));
253
- finish(false);
254
- });
255
- child.on('close', (code) => {
256
- if (code === 0) {
257
- span.end({ success: true });
258
- finish(true);
259
- return;
260
- }
261
- const detail = stderrBuf.trim() || 'unknown error';
262
- lastCommandError = `exit code ${code ?? 'unknown'} — ${detail}`;
263
- span.end({ success: false, exitReason: `exit code ${code ?? 'unknown'}` });
264
- // stderr was already streamed live above, so we don't re-print the full
265
- // detail here (that would duplicate it). Callers surface lastCommandError
266
- // where they want a compact "Reason:" line.
267
- console.log(chalk.red(` Command failed (exit code ${code ?? 'unknown'}): ${command}`));
268
- finish(false);
269
- });
270
- });
203
+ try {
204
+ // Keep stdout visible live, but capture stderr so we can surface the raw
205
+ // error reason instead of swallowing it on failure.
206
+ execSync(command, { stdio: ['inherit', 'inherit', 'pipe'] });
207
+ return true;
208
+ }
209
+ catch (err) {
210
+ const exitCode = err?.status;
211
+ const stderr = err?.stderr?.toString().trim();
212
+ const message = err?.message ? String(err.message).trim() : undefined;
213
+ const detail = stderr || message || 'unknown error';
214
+ lastCommandError = `exit code ${exitCode ?? 'unknown'} — ${detail}`;
215
+ // Surface the raw failure (stderr is no longer shown live since it is piped).
216
+ console.log(chalk.red(` Command failed (exit code ${exitCode ?? 'unknown'}): ${command}`));
217
+ if (detail) {
218
+ console.log(chalk.red(detail));
219
+ }
220
+ return false;
221
+ }
271
222
  }
272
223
  /**
273
224
  * Check if a skit package is already installed with the specified version
@@ -300,85 +251,16 @@ function isNpmGlobalInstalled(name, version) {
300
251
  return false;
301
252
  }
302
253
  }
303
- // Preflight is a diagnostic: it runs once per process and warns loudly, but
304
- // does NOT abort — skit still tries, so any real error path stays intact.
305
- let istWritabilityChecked = false;
306
- /**
307
- * Verify the current user can actually write into ~/.ist before skit installs.
308
- *
309
- * Motivation: when ~/.ist is mounted as a root:root Docker named volume and the
310
- * process runs as a non-root user, skit's writes fail with EACCES. skit swallows
311
- * that into an opaque "Error: undefined" and the install silently under-counts
312
- * (e.g. 7/12). This surfaces the real cause up front with a concrete fix.
313
- *
314
- * Detection is a real write probe (create+delete a temp file), which is more
315
- * reliable than fs.accessSync(W_OK) — the latter can misreport under root/ACLs.
316
- * Missing directories are created first (that itself exercises writability of
317
- * the parent). Never throws; a probe failure just flags the directory.
318
- */
319
- function checkIstWritable() {
320
- if (istWritabilityChecked)
321
- return;
322
- istWritabilityChecked = true;
323
- const istRoot = join(homedir(), '.ist');
324
- // Directories skit writes into: the root itself (installed-kits.json lives
325
- // here), global prompts, and profiles.
326
- const targets = [
327
- istRoot,
328
- join(istRoot, 'prompts', 'global'),
329
- join(istRoot, 'profiles'),
330
- ];
331
- const notWritable = [];
332
- for (const dir of targets) {
333
- try {
334
- if (!existsSync(dir)) {
335
- // Creating the dir also proves the parent is writable. If this throws
336
- // (EACCES on a root-owned volume), the dir is not usable → flag it.
337
- mkdirSync(dir, { recursive: true });
338
- }
339
- const probe = join(dir, `.snapshot-write-probe-${process.pid}`);
340
- writeFileSync(probe, '');
341
- unlinkSync(probe);
342
- }
343
- catch {
344
- notWritable.push(dir);
345
- }
346
- }
347
- // installed-kits.json may already exist with restrictive ownership even when
348
- // its directory is writable — probe the file directly if present.
349
- const kitsFile = join(istRoot, 'installed-kits.json');
350
- if (existsSync(kitsFile)) {
351
- try {
352
- writeFileSync(kitsFile, readFileSync(kitsFile));
353
- }
354
- catch {
355
- notWritable.push(kitsFile);
356
- }
357
- }
358
- if (notWritable.length > 0) {
359
- console.log('');
360
- console.log(chalk.yellow('⚠ ~/.ist is not writable by current user (owned by root?) — skit installs will fail.'));
361
- for (const p of notWritable) {
362
- console.log(chalk.gray(` not writable: ${p}`));
363
- }
364
- console.log(chalk.gray(' Fix directory ownership: chown -R $(whoami) ~/.ist'));
365
- console.log('');
366
- }
367
- }
368
254
  /**
369
255
  * Skit 패키지 설치 (lib 우선, CLI fallback)
370
256
  * @ist/skit lib가 있으면 직접 호출, 없으면 CLI 사용
371
257
  */
372
- async function installSkitPackages(packages, snapshotName, indent, sessionId) {
258
+ async function installSkitPackages(packages, snapshotName, indent, sessionId, recorder) {
373
259
  const result = { installed: [], failed: [] };
374
260
  const packageEntries = Object.entries(packages);
375
261
  if (packageEntries.length === 0) {
376
262
  return result;
377
263
  }
378
- // Diagnostic preflight (once/process): if ~/.ist isn't writable by the current
379
- // user, skit installs fail opaquely ("Error: undefined", under-counted total).
380
- // Warn with the concrete fix before we start the skit loop.
381
- checkIstWritable();
382
264
  const total = packageEntries.length;
383
265
  console.log('');
384
266
  console.log(chalk.yellow(`${indent}=== ${snapshotName}: ${total} skits ===`));
@@ -391,14 +273,7 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId) {
391
273
  if (isSkitInstalled(entry.name, entry.version)) {
392
274
  return { success: true, skipped: true };
393
275
  }
394
- // Per-package timing (investigation only) — surfaces individual skit
395
- // hotspots (e.g. infra-kit/mwd-kit) under the install-snapshot phase.
396
- const pkgSpan = activeTimeline.start(`${entry.name}@${entry.version}`, {
397
- category: 'package',
398
- meta: { installer: 'skit' },
399
- });
400
276
  const installResult = await installSkitPackage(entry.name, entry.version, skitOptions);
401
- pkgSpan.end({ success: installResult.success });
402
277
  return {
403
278
  success: installResult.success,
404
279
  error: installResult.error,
@@ -421,12 +296,20 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId) {
421
296
  });
422
297
  result.installed.push(...parallelResult.installed);
423
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
+ });
424
307
  return result;
425
308
  }
426
309
  /**
427
310
  * 타입별 패키지 설치 (non-skit)
428
311
  */
429
- async function installPackagesByType(packages, installerName, installers, snapshotName, indent, typeLabel, sessionId) {
312
+ async function installPackagesByType(packages, installerName, installers, snapshotName, indent, typeLabel, sessionId, recorder) {
430
313
  const result = { installed: [], failed: [] };
431
314
  const packageEntries = Object.entries(packages);
432
315
  if (packageEntries.length === 0) {
@@ -434,7 +317,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
434
317
  }
435
318
  // Skit packages use dedicated function
436
319
  if (installerName === 'skit') {
437
- return installSkitPackages(packages, snapshotName, indent, sessionId);
320
+ return installSkitPackages(packages, snapshotName, indent, sessionId, recorder);
438
321
  }
439
322
  const installerConfig = installers[installerName] || DEFAULT_INSTALLERS[installerName];
440
323
  if (!installerConfig) {
@@ -450,7 +333,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
450
333
  if (!commandExists(cmdName)) {
451
334
  console.log(chalk.yellow(`${indent}${installerName} not found. Attempting auto-install...`));
452
335
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
453
- if (await runCommand(installerConfig.hint)) {
336
+ if (runCommand(installerConfig.hint)) {
454
337
  console.log(chalk.green(`${indent} ✓ ${installerName} installed successfully`));
455
338
  }
456
339
  else {
@@ -483,7 +366,15 @@ async function installPackagesByType(packages, installerName, installers, snapsh
483
366
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
484
367
  const batchCommand = installerConfig.command.replace('{pkg}', specs.join(' '));
485
368
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
486
- const success = await runCommand(batchCommand);
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
+ });
487
378
  if (success) {
488
379
  for (const spec of specs) {
489
380
  console.log(`${indent}${chalk.blue('batch')} ${spec}...${chalk.green(' ✓')}`);
@@ -498,7 +389,14 @@ async function installPackagesByType(packages, installerName, installers, snapsh
498
389
  for (const entry of toInstall) {
499
390
  const pkgSpec = `${entry.name}@${entry.version}`;
500
391
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
501
- const ok = await runCommand(command);
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
+ });
502
400
  if (ok) {
503
401
  console.log(`${indent}${chalk.blue('fallback')} ${pkgSpec}...${chalk.green(' ✓')}`);
504
402
  result.installed.push(pkgSpec);
@@ -516,7 +414,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
516
414
  const parallelResult = await parallelInstall(entries, async (entry) => {
517
415
  const pkgSpec = `${entry.name}@${entry.version}`;
518
416
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
519
- const success = await runCommand(command);
417
+ const success = runCommand(command);
520
418
  return { success, error: success ? undefined : `Command failed: ${command}` };
521
419
  }, {
522
420
  concurrency: 4,
@@ -533,15 +431,65 @@ async function installPackagesByType(packages, installerName, installers, snapsh
533
431
  });
534
432
  result.installed.push(...parallelResult.installed);
535
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
+ });
536
442
  }
537
443
  return result;
538
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
+ }
539
481
  export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}, options = {}) {
540
482
  const indent = ' '.repeat(depth);
541
483
  const result = {
542
484
  installed: [],
543
485
  failed: [],
544
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
+ }
545
493
  // Merge installers: snapshot + parent (parent takes precedence for overrides like npm-proxy)
546
494
  let installers = { ...DEFAULT_INSTALLERS };
547
495
  if (snapshot.installers) {
@@ -641,21 +589,21 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
641
589
  const resolved = await resolveSnapshotDependencies(snapshot, { skipRegistry: true });
642
590
  // skit 패키지 설치 (skit-adapter 사용: lib 우선, CLI fallback)
643
591
  if (Object.keys(resolved.skit).length > 0) {
644
- 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);
645
593
  result.installed.push(...skitResult.installed);
646
594
  result.failed.push(...skitResult.failed);
647
595
  }
648
596
  // npm 패키지 설치 (proxy 사용 시 npm-proxy, 아니면 glpkg)
649
597
  if (Object.keys(resolved.npm).length > 0) {
650
598
  const npmInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
651
- 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);
652
600
  result.installed.push(...npmResult.installed);
653
601
  result.failed.push(...npmResult.failed);
654
602
  }
655
603
  // generic 패키지 설치 (glpkg)
656
604
  if (Object.keys(resolved.generic).length > 0) {
657
605
  const genericInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
658
- 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);
659
607
  result.installed.push(...genericResult.installed);
660
608
  result.failed.push(...genericResult.failed);
661
609
  }
@@ -672,7 +620,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
672
620
  continue;
673
621
  // Skit packages: use skit-adapter (lib 우선, CLI fallback)
674
622
  if (installerName === 'skit') {
675
- const skitResult = await installSkitPackages(packages, snapshot.name, indent, options.sessionId);
623
+ const skitResult = await installSkitPackages(packages, snapshot.name, indent, options.sessionId, options.recorder);
676
624
  result.installed.push(...skitResult.installed);
677
625
  result.failed.push(...skitResult.failed);
678
626
  continue;
@@ -691,7 +639,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
691
639
  if (!commandExists(cmdName)) {
692
640
  console.log(chalk.yellow(`${indent}${installerName} not found. Attempting auto-install...`));
693
641
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
694
- if (await runCommand(installerConfig.hint)) {
642
+ if (runCommand(installerConfig.hint)) {
695
643
  console.log(chalk.green(`${indent} ✓ ${installerName} installed successfully`));
696
644
  }
697
645
  else {
@@ -725,7 +673,15 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
725
673
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
726
674
  const batchCommand = installCfg.command.replace('{pkg}', specs.join(' '));
727
675
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
728
- const success = await runCommand(batchCommand);
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
+ });
729
685
  if (success) {
730
686
  for (const spec of specs) {
731
687
  console.log(`${indent}${chalk.blue('batch')} ${spec}...${chalk.green(' ✓')}`);
@@ -740,7 +696,14 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
740
696
  for (const entry of toInstall) {
741
697
  const pkgSpec = `${entry.name}@${entry.version}`;
742
698
  const command = installCfg.command.replace('{pkg}', pkgSpec);
743
- const ok = await runCommand(command);
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
+ });
744
707
  if (ok) {
745
708
  console.log(`${indent}${chalk.blue('fallback')} ${pkgSpec}...${chalk.green(' ✓')}`);
746
709
  result.installed.push(pkgSpec);
@@ -758,7 +721,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
758
721
  const parallelResult = await parallelInstall(installEntries, async (entry) => {
759
722
  const pkgSpec = `${entry.name}@${entry.version}`;
760
723
  const command = installCfg.command.replace('{pkg}', pkgSpec);
761
- const success = await runCommand(command);
724
+ const success = runCommand(command);
762
725
  return { success, error: success ? undefined : `Command failed: ${command}` };
763
726
  }, {
764
727
  concurrency: 4,
@@ -775,6 +738,14 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
775
738
  });
776
739
  result.installed.push(...parallelResult.installed);
777
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
+ });
778
749
  }
779
750
  }
780
751
  }
@@ -788,7 +759,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
788
759
  if (!commandExists(cmdName)) {
789
760
  console.log(chalk.yellow(`${indent}glpkg not found. Attempting auto-install...`));
790
761
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
791
- if (await runCommand(installerConfig.hint)) {
762
+ if (runCommand(installerConfig.hint)) {
792
763
  console.log(chalk.green(`${indent} ✓ glpkg installed successfully`));
793
764
  }
794
765
  else {
@@ -806,7 +777,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
806
777
  const parallelResult = await parallelInstall(legacyEntries, async (entry) => {
807
778
  const pkgSpec = `${entry.name}@${entry.version}`;
808
779
  const command = legacyCfg.command.replace('{pkg}', pkgSpec);
809
- const success = await runCommand(command);
780
+ const success = runCommand(command);
810
781
  return { success, error: success ? undefined : `Command failed: ${command}` };
811
782
  }, {
812
783
  concurrency: 4,
@@ -823,11 +794,19 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
823
794
  });
824
795
  result.installed.push(...parallelResult.installed);
825
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
+ });
826
805
  }
827
806
  }
828
807
  // Legacy: skits (use skit-adapter: lib 우선, CLI fallback)
829
808
  if (snapshot.skits && Object.keys(snapshot.skits).length > 0) {
830
- 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);
831
810
  result.installed.push(...skitResult.installed);
832
811
  result.failed.push(...skitResult.failed);
833
812
  }
@@ -887,7 +866,7 @@ async function collectAllNpmPackages(snapshot, useProxy = true) {
887
866
  * This ensures .npmrc is configured with ALL scopes before installation begins,
888
867
  * including scopes from base snapshots (e.g., @microwiseai from @ist/minimal).
889
868
  */
890
- async function collectAllPackages(snapshot) {
869
+ export async function collectAllPackages(snapshot) {
891
870
  const allPackages = {};
892
871
  // Collect from current snapshot
893
872
  if (snapshot.packages) {
@@ -1017,7 +996,7 @@ async function runReplaceMode(snapshotName, newSet, options, installers) {
1017
996
  continue;
1018
997
  }
1019
998
  console.log(chalk.gray(` Removing ${item.name}...`));
1020
- if (await runCommand(cmd)) {
999
+ if (runCommand(cmd)) {
1021
1000
  console.log(` ${item.name}${chalk.green(' ✓ removed')}`);
1022
1001
  removed.push(item);
1023
1002
  }
@@ -1078,10 +1057,6 @@ async function previewInstall(snapshot, options) {
1078
1057
  console.log(chalk.gray('Dry-run complete: no installs, no uninstalls, no .npmrc or history changes.'));
1079
1058
  }
1080
1059
  export async function installCommand(name, options = {}) {
1081
- // Install-timeline instrumentation: live instance when SNAPSHOT_INSTALL_TIMELINE
1082
- // is set, otherwise a zero-overhead no-op. Purely additive — never alters the
1083
- // install path or its output when disabled.
1084
- activeTimeline = createInstallTimeline();
1085
1060
  console.log(chalk.blue.bold(`
1086
1061
  ╔══════════════════════════════════════════════════════════════╗
1087
1062
  ║ Snapshot Installer ║
@@ -1115,7 +1090,6 @@ export async function installCommand(name, options = {}) {
1115
1090
  //
1116
1091
  // License key CANNOT be used as Bearer token for registry-proxy — they are
1117
1092
  // distinct: AuthN (uid via IST JWT) vs AuthZ (entitlement via license key).
1118
- const authSpan = activeTimeline.start('auth-token', { category: 'phase' });
1119
1093
  const licenseKey = getLicenseKey();
1120
1094
  let authToken = null;
1121
1095
  let authType = 'anonymous';
@@ -1132,16 +1106,13 @@ export async function installCommand(name, options = {}) {
1132
1106
  console.log(chalk.yellow('⚠') + ' No auth token — anonymous access');
1133
1107
  }
1134
1108
  const useProxy = true;
1135
- authSpan.end({ authType });
1136
1109
  // Fetch snapshot first — access check depends on snapshot.json content
1137
1110
  console.log(chalk.gray('Fetching snapshot...'));
1138
- const fetchSpan = activeTimeline.start('fetch-snapshot', { category: 'phase' });
1139
1111
  let snapshot;
1140
1112
  try {
1141
1113
  snapshot = await fetchSnapshotFromRepo(name, authToken);
1142
1114
  }
1143
1115
  catch (error) {
1144
- fetchSpan.end({ success: false });
1145
1116
  const errMsg = error instanceof Error ? error.message : String(error);
1146
1117
  if (options.dryRun) {
1147
1118
  // Pure preview: a fetch failure (offline / no auth) is not fatal — we just
@@ -1156,7 +1127,6 @@ export async function installCommand(name, options = {}) {
1156
1127
  console.log(chalk.yellow('Check the snapshot name and version, e.g. snapshot install @scope/name@version'));
1157
1128
  process.exit(1);
1158
1129
  }
1159
- fetchSpan.end({ success: true });
1160
1130
  console.log(chalk.green('✓') + ` Snapshot: ${snapshot.name} v${snapshot.version}`);
1161
1131
  if (snapshot.description) {
1162
1132
  console.log(chalk.gray(` ${snapshot.description}`));
@@ -1174,30 +1144,62 @@ export async function installCommand(name, options = {}) {
1174
1144
  await previewInstall(snapshot, options);
1175
1145
  return;
1176
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
+ }
1177
1172
  // ========== Access Check (based on snapshot.json access field) ==========
1178
- // snapshot is a general installer. Access is simplified to two tiers:
1179
- // - access: "public" → no license required
1173
+ // Determine access type from snapshot.json:
1174
+ // - access: "public" → no login/license required
1175
+ // - access: "authenticated" → IST login required, no license needed
1180
1176
  // - access: { type: "licensed", productId } → license required
1181
- // - anything else / absent → access decided SERVER-side
1182
- // (registry-proxy / license API are authoritative; a 401/403 fails loud).
1183
- // No client-side login gate: access control is backend-authoritative.
1177
+ // - access field absent → treated as licensed (requires license)
1184
1178
  const accessField = snapshot.access;
1185
1179
  const isPublicAccess = accessField === 'public';
1180
+ const isAuthenticatedAccess = accessField === 'authenticated';
1186
1181
  const isLicensedAccess = !!accessField && typeof accessField === 'object' && accessField.type === 'licensed';
1187
- // Optional identity for telemetry only (anonymous when absent). NOT a gate.
1188
- // A wrapper (e.g. ist-setup) may layer a login system on top of snapshot via
1189
- // the authToken injection point (IST_SESSION_TOKEN env / getProxyAuthToken).
1182
+ // --- IST Login Check ---
1190
1183
  const istCredentials = getIstCredentials();
1184
+ if (istCredentials?.userId) {
1185
+ console.log(chalk.green('✓') + ' IST login verified');
1186
+ }
1187
+ else if (isPublicAccess) {
1188
+ console.log(chalk.yellow('⚠') + ' No IST login - installing public package');
1189
+ }
1190
+ else {
1191
+ console.log(chalk.red('⛔ IST login required for non-public packages'));
1192
+ console.log(chalk.gray(' Run: ist auth login'));
1193
+ console.log(chalk.gray(' Or install a public snapshot first (no login required)'));
1194
+ process.exit(1);
1195
+ }
1191
1196
  // --- License Check ---
1192
- const accessSpan = activeTimeline.start('access-license', {
1193
- category: 'phase',
1194
- meta: { access: isPublicAccess ? 'public' : isLicensedAccess ? 'licensed' : 'server-decided' },
1195
- });
1196
1197
  const storedLicenseKey = getLicenseKey();
1197
1198
  let licenseValid = false;
1198
- if (isPublicAccess) {
1199
- // Public snapshots — skip license check entirely
1200
- console.log(chalk.green('✓') + ' Public snapshot - no license required');
1199
+ if (isPublicAccess || isAuthenticatedAccess) {
1200
+ // Public and authenticated snapshots — skip license check entirely
1201
+ const label = isAuthenticatedAccess ? 'Authenticated' : 'Public';
1202
+ console.log(chalk.green('✓') + ` ${label} snapshot - no license required`);
1201
1203
  }
1202
1204
  else if (storedLicenseKey) {
1203
1205
  try {
@@ -1239,10 +1241,8 @@ export async function installCommand(name, options = {}) {
1239
1241
  }
1240
1242
  console.log(chalk.green('✓') + ` License verified for ${productId}`);
1241
1243
  }
1242
- accessSpan.end({ licenseValid });
1243
1244
  // =======================================================================
1244
1245
  // ========== Authorize Session with Registry Proxy ==========
1245
- const authorizeSpan = activeTimeline.start('authorize-session', { category: 'phase' });
1246
1246
  let sessionId;
1247
1247
  if (authToken) {
1248
1248
  // Collect all packages for session authorization
@@ -1278,10 +1278,8 @@ export async function installCommand(name, options = {}) {
1278
1278
  }
1279
1279
  }
1280
1280
  }
1281
- authorizeSpan.end({ sessionAuthorized: sessionId !== undefined });
1282
1281
  // =============================================================
1283
1282
  // Collect all packages from entire extends chain to determine scopes
1284
- const configureSpan = activeTimeline.start('configure-npmrc', { category: 'phase' });
1285
1283
  const allPackages = await collectAllPackages(snapshot);
1286
1284
  // Configure .npmrc for proxy (with or without auth token)
1287
1285
  let npmrcBackup = null;
@@ -1301,7 +1299,6 @@ export async function installCommand(name, options = {}) {
1301
1299
  }
1302
1300
  }
1303
1301
  }
1304
- configureSpan.end({ packageCount: Object.keys(allPackages).length });
1305
1302
  // Guarantee .npmrc restoration even on ^C. A try/finally only covers thrown
1306
1303
  // errors and normal returns — it does NOT run on SIGINT/SIGTERM, so an
1307
1304
  // interrupt during the pre-install batch or the main install would leave a
@@ -1336,13 +1333,24 @@ export async function installCommand(name, options = {}) {
1336
1333
  effectiveInstallers['glpkg'] = DEFAULT_INSTALLERS['npm-proxy'];
1337
1334
  effectiveInstallers['gitlab-install'] = DEFAULT_INSTALLERS['npm-proxy'];
1338
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();
1339
1340
  // ========== Pre-install: batch all npm packages from extends chain ==========
1340
1341
  // Collect all npm-type packages across the entire extends chain and install
1341
1342
  // them in a single `npm install -g` call, instead of one per snapshot layer.
1342
- const batchSpan = activeTimeline.start('npm-batch-preinstall', { category: 'phase' });
1343
- let batchSpecs = [];
1344
1343
  const allNpmPackages = await collectAllNpmPackages(snapshot, useProxy);
1345
- 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);
1346
1354
  if (npmEntries.length > 0) {
1347
1355
  console.log('');
1348
1356
  console.log(chalk.cyan(`► Pre-installing ${npmEntries.length} npm packages from entire extends chain...`));
@@ -1358,10 +1366,18 @@ export async function installCommand(name, options = {}) {
1358
1366
  }
1359
1367
  if (toInstall.length > 0) {
1360
1368
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
1361
- batchSpecs = specs;
1362
1369
  const npmCommand = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', specs.join(' '));
1363
1370
  console.log(chalk.gray(` Running single batch: npm install -g (${specs.length} packages)`));
1364
- const batchSuccess = await runCommand(npmCommand);
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
+ });
1365
1381
  if (batchSuccess) {
1366
1382
  for (const spec of specs) {
1367
1383
  console.log(` ${chalk.blue('batch')} ${spec}...${chalk.green(' ✓')}`);
@@ -1377,7 +1393,14 @@ export async function installCommand(name, options = {}) {
1377
1393
  for (const entry of toInstall) {
1378
1394
  const pkgSpec = `${entry.name}@${entry.version}`;
1379
1395
  const cmd = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', pkgSpec);
1380
- const ok = await runCommand(cmd);
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
+ });
1381
1404
  if (ok) {
1382
1405
  console.log(` ${chalk.blue('fallback')} ${pkgSpec}...${chalk.green(' ✓')}`);
1383
1406
  }
@@ -1391,13 +1414,19 @@ export async function installCommand(name, options = {}) {
1391
1414
  console.log(chalk.green('✓ All npm packages already installed'));
1392
1415
  }
1393
1416
  }
1394
- batchSpan.end({ specs: batchSpecs, npmDeclared: npmEntries.length, npmToInstall: batchSpecs.length });
1395
1417
  // =============================================================================
1396
1418
  // Install (handles extends recursively)
1397
1419
  // installSnapshot's internal isNpmGlobalInstalled checks will skip already-installed packages
1398
1420
  let installError = null;
1399
1421
  let result = { installed: [], failed: [] };
1400
- const installSpan = activeTimeline.start('install-snapshot', { category: 'phase' });
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;
1401
1430
  try {
1402
1431
  // Auto-detect 모드: snapshot.install이 없으면 자동 판별
1403
1432
  const autoDetect = !snapshot.install;
@@ -1405,18 +1434,56 @@ export async function installCommand(name, options = {}) {
1405
1434
  useProxy,
1406
1435
  autoDetect,
1407
1436
  sessionId,
1437
+ recorder,
1438
+ overrides,
1408
1439
  });
1409
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
+ }
1410
1482
  }
1411
1483
  catch (err) {
1412
1484
  installError = err instanceof Error ? err : new Error(String(err));
1413
1485
  }
1414
1486
  finally {
1415
- installSpan.end({
1416
- installed: result.installed.length,
1417
- failed: result.failed.length,
1418
- success: installError === null,
1419
- });
1420
1487
  // Restore .npmrc + clear the session-token env (idempotent: a SIGINT/SIGTERM
1421
1488
  // handler may have already run this). Then drop the signal handlers we
1422
1489
  // registered, so they don't leak across multiple installs in one process.
@@ -1425,7 +1492,6 @@ export async function installCommand(name, options = {}) {
1425
1492
  process.removeListener('SIGTERM', onSignal);
1426
1493
  }
1427
1494
  if (installError) {
1428
- activeTimeline.finalize();
1429
1495
  console.log(chalk.red(`Error during installation: ${installError.message}`));
1430
1496
  process.exit(1);
1431
1497
  }
@@ -1436,6 +1502,16 @@ export async function installCommand(name, options = {}) {
1436
1502
  console.log(chalk.yellow('═══════════════════════════════════'));
1437
1503
  const total = result.installed.length + result.failed.length;
1438
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
+ }
1439
1515
  if (result.failed.length > 0) {
1440
1516
  console.log('');
1441
1517
  console.log(chalk.red('Failed:'));
@@ -1456,6 +1532,101 @@ export async function installCommand(name, options = {}) {
1456
1532
  console.log('');
1457
1533
  console.log(chalk.green('✓') + ' Snapshot info saved');
1458
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
+ }
1459
1630
  // Record references for the ENTIRE declared set (installed + dedup-skipped),
1460
1631
  // so replace mode has accurate referencedBy data (ADR 001 §c). This runs on
1461
1632
  // every install, not just --replace, to build up the reference graph.
@@ -1472,9 +1643,18 @@ export async function installCommand(name, options = {}) {
1472
1643
  await runReplaceMode(snapshot.name, allPackages, options, effectiveInstallers);
1473
1644
  }
1474
1645
  }
1475
- // Emit the install timeline (investigation only; no-op unless opted-in) before
1476
- // the fire-and-forget telemetry so the summary/JSONL reflect the whole run.
1477
- activeTimeline.finalize();
1646
+ // Post-install: run doctor to check prerequisites
1647
+ console.log('');
1648
+ const { checkPrerequisites, MINIMAL_PREREQUISITES } = await import('../lib/prerequisites.js');
1649
+ const prereqResults = checkPrerequisites(MINIMAL_PREREQUISITES);
1650
+ const missingPrereqs = prereqResults.filter(r => !r.installed);
1651
+ if (missingPrereqs.length > 0) {
1652
+ console.log(chalk.yellow(`\n⚠ ${missingPrereqs.length} prerequisite(s) not satisfied:`));
1653
+ for (const r of missingPrereqs) {
1654
+ console.log(` ${chalk.red('❌')} ${r.name} — required by ${r.requiredBy.join(', ')}`);
1655
+ }
1656
+ console.log(chalk.gray(`\nRun 'snapshot setup' to install, or 'snapshot doctor' for details.`));
1657
+ }
1478
1658
  // Send telemetry (fire and forget)
1479
1659
  sendInstallTelemetry({
1480
1660
  package: snapshot.name,