@microwiseai/snapshot 0.8.0 → 0.9.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 (112) hide show
  1. package/dist/build-info.json +1 -0
  2. package/dist/commands/access.d.ts +25 -9
  3. package/dist/commands/access.d.ts.map +1 -1
  4. package/dist/commands/access.js +101 -47
  5. package/dist/commands/access.js.map +1 -1
  6. package/dist/commands/config.d.ts +3 -0
  7. package/dist/commands/config.d.ts.map +1 -0
  8. package/dist/commands/config.js +37 -0
  9. package/dist/commands/config.js.map +1 -0
  10. package/dist/commands/install.d.ts +54 -0
  11. package/dist/commands/install.d.ts.map +1 -1
  12. package/dist/commands/install.js +480 -65
  13. package/dist/commands/install.js.map +1 -1
  14. package/dist/commands/publish.d.ts +10 -0
  15. package/dist/commands/publish.d.ts.map +1 -1
  16. package/dist/commands/publish.js +94 -12
  17. package/dist/commands/publish.js.map +1 -1
  18. package/dist/index.js +45 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/access-index.d.ts +81 -0
  21. package/dist/lib/access-index.d.ts.map +1 -0
  22. package/dist/lib/access-index.js +175 -0
  23. package/dist/lib/access-index.js.map +1 -0
  24. package/dist/lib/access-sync.d.ts +66 -17
  25. package/dist/lib/access-sync.d.ts.map +1 -1
  26. package/dist/lib/access-sync.js +391 -217
  27. package/dist/lib/access-sync.js.map +1 -1
  28. package/dist/lib/extends-access.d.ts +34 -0
  29. package/dist/lib/extends-access.d.ts.map +1 -0
  30. package/dist/lib/extends-access.js +82 -0
  31. package/dist/lib/extends-access.js.map +1 -0
  32. package/dist/lib/gitlab-publisher.d.ts +70 -0
  33. package/dist/lib/gitlab-publisher.d.ts.map +1 -0
  34. package/dist/lib/gitlab-publisher.js +165 -0
  35. package/dist/lib/gitlab-publisher.js.map +1 -0
  36. package/dist/lib/gitlab.d.ts +4 -0
  37. package/dist/lib/gitlab.d.ts.map +1 -1
  38. package/dist/lib/gitlab.js +1 -1
  39. package/dist/lib/gitlab.js.map +1 -1
  40. package/dist/lib/grant.d.ts +157 -0
  41. package/dist/lib/grant.d.ts.map +1 -0
  42. package/dist/lib/grant.js +352 -0
  43. package/dist/lib/grant.js.map +1 -0
  44. package/dist/lib/install-verify.d.ts +16 -2
  45. package/dist/lib/install-verify.d.ts.map +1 -1
  46. package/dist/lib/install-verify.js +24 -3
  47. package/dist/lib/install-verify.js.map +1 -1
  48. package/dist/lib/ledger.d.ts +13 -0
  49. package/dist/lib/ledger.d.ts.map +1 -1
  50. package/dist/lib/ledger.js.map +1 -1
  51. package/dist/lib/prerequisites.d.ts.map +1 -1
  52. package/dist/lib/prerequisites.js +4 -1
  53. package/dist/lib/prerequisites.js.map +1 -1
  54. package/dist/lib/proxy-auth-globalconfig.d.ts +55 -0
  55. package/dist/lib/proxy-auth-globalconfig.d.ts.map +1 -0
  56. package/dist/lib/proxy-auth-globalconfig.js +110 -0
  57. package/dist/lib/proxy-auth-globalconfig.js.map +1 -0
  58. package/dist/lib/semver-range.d.ts +32 -0
  59. package/dist/lib/semver-range.d.ts.map +1 -0
  60. package/dist/lib/semver-range.js +243 -0
  61. package/dist/lib/semver-range.js.map +1 -0
  62. package/dist/lib/session.d.ts +5 -0
  63. package/dist/lib/session.d.ts.map +1 -1
  64. package/dist/lib/session.js +9 -0
  65. package/dist/lib/session.js.map +1 -1
  66. package/dist/lib/skit-adapter.d.ts.map +1 -1
  67. package/dist/lib/skit-adapter.js +26 -3
  68. package/dist/lib/skit-adapter.js.map +1 -1
  69. package/dist/lib/timing.d.ts +7 -0
  70. package/dist/lib/timing.d.ts.map +1 -0
  71. package/dist/lib/timing.js +14 -0
  72. package/dist/lib/timing.js.map +1 -0
  73. package/dist/lib/types.d.ts +3 -0
  74. package/dist/lib/types.d.ts.map +1 -1
  75. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.d.ts +25 -0
  76. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.d.ts.map +1 -0
  77. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.js +55 -0
  78. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.js.map +1 -0
  79. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.d.ts +52 -0
  80. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.d.ts.map +1 -0
  81. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.js +62 -0
  82. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.js.map +1 -0
  83. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.d.ts +30 -0
  84. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.d.ts.map +1 -0
  85. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.js +39 -0
  86. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.js.map +1 -0
  87. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.d.ts +35 -0
  88. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.d.ts.map +1 -0
  89. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.js +31 -0
  90. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.js.map +1 -0
  91. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.d.ts +42 -0
  92. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.d.ts.map +1 -0
  93. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.js +36 -0
  94. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.js.map +1 -0
  95. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.d.ts +11 -0
  96. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.d.ts.map +1 -0
  97. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.js +46 -0
  98. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.js.map +1 -0
  99. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.d.ts +11 -0
  100. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.d.ts.map +1 -0
  101. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.js +81 -0
  102. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.js.map +1 -0
  103. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.d.ts +29 -0
  104. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.d.ts.map +1 -0
  105. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.js +6 -0
  106. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.js.map +1 -0
  107. package/node_modules/@microwiseai/snapshot-downgrade-guard/package.json +55 -0
  108. package/package.json +5 -2
  109. package/dist/lib/transitive-resolver.d.ts +0 -89
  110. package/dist/lib/transitive-resolver.d.ts.map +0 -1
  111. package/dist/lib/transitive-resolver.js +0 -447
  112. package/dist/lib/transitive-resolver.js.map +0 -1
@@ -1,19 +1,22 @@
1
1
  import chalk from 'chalk';
2
- import { execSync } from 'child_process';
2
+ import { execSync, spawn, spawnSync } from 'child_process';
3
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';
7
7
  import { saveCurrentSnapshot } from '../lib/snapshot.js';
8
- import { getLicenseKey, getProxyAuthToken, REGISTRY_PROXY_URL, VENDOR_NAME, STORE_URL, PACKAGE_NAME, getIstCredentials, LICENSE_API_URL, verifyLicenseKey } from '../lib/config.js';
8
+ import { getLicenseKey, getProxyAuthToken, REGISTRY_PROXY_URL, VENDOR_NAME, STORE_URL, PACKAGE_NAME, getIstCredentials, isLoggedIn, LICENSE_API_URL, verifyLicenseKey } from '../lib/config.js';
9
9
  import { resolveSnapshotDependencies } from '../lib/package-resolver.js';
10
- // transitive-resolver removed — npm handles dependency resolution automatically
10
+ // (transitive-resolver deleted — npm resolves dependencies; see git history)
11
11
  import { installSkitPackage } from '../lib/skit-adapter.js';
12
12
  import { markLicenseChecked } from '@microwiseai/license';
13
- import { authorizeSession, packagesToSpecs } from '../lib/session.js';
13
+ import { authorizeSession, packagesToSpecs, maskSessionId } from '../lib/session.js';
14
+ import { readGrantMode, readGrantTimeoutMs, requestSnapshotGrant, GrantState, GrantError, checkGrantCoverage, findRootMemberVersion, formatGrantLog, splitSnapshotSpec, resolveInstallVersion, } from '../lib/grant.js';
14
15
  import { parallelInstall, recordToEntries } from '@microwiseai/snapshot-parallel';
16
+ import { detectDowngrades, resolveDowngradePolicy, readOneShotOverride, getMachineDowngradeMode, formatDowngradeReport, extractGlobalInstallSpecs, } from '@microwiseai/snapshot-downgrade-guard';
15
17
  import { dedupWithReport, dedupInstallBlock, recordInstalledSpecs, recordReferences, computeReplacePlan, applyReplaceHistory, } from '@microwiseai/snapshot-dedup';
16
18
  import { createInterface } from 'readline';
19
+ import { timingStart } from '../lib/timing.js';
17
20
  import { StepRecorder, buildLedger, writeLedger, queryDependencyTree, } from '../lib/ledger.js';
18
21
  import { coreFloorAssert, formatCoreFloorOffenders, DEFAULT_CORE_FLOOR_PKG, DEFAULT_CORE_FLOOR_MIN_VERSION, } from '../lib/core-floor.js';
19
22
  import { verifyInstallMap, runConverge, readInstalledNpmVersion, formatVerificationFailure, } from '../lib/install-verify.js';
@@ -198,27 +201,148 @@ function commandExists(cmd) {
198
201
  // Captures the failure reason of the most recent runCommand() call so callers
199
202
  // can surface it (debugging visibility). Undefined when the last call succeeded.
200
203
  let lastCommandError;
201
- function runCommand(command) {
202
- lastCommandError = undefined;
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;
204
+ // ============================================================
205
+ // Downgrade guard — npm-exec choke point (defense in depth)
206
+ // ============================================================
207
+ // EVERY global install funnels through runCommand / runCommandSync (the spawn
208
+ // wrappers) — including installCommand's batches, a direct installSnapshot()
209
+ // library call, extends recursion, and parallelInstall's per-spec callback.
210
+ // Detecting downgrades HERE, at that single execution boundary, means NO entry
211
+ // point can bypass the guard (closes the lib.ts public-export bypass). This is a
212
+ // SECOND layer; installCommand's earlier pre-install guard stays as-is.
213
+ //
214
+ // Policy threading: installCommand resolves the mode once and publishes it via
215
+ // setActiveDowngradeMode(); this boundary reuses it (no recompute, no duplicate
216
+ // report). With no upstream context (direct library call), the boundary
217
+ // self-resolves from env (SNAPSHOT_ALLOW_LOCAL) + machine config, defaulting to
218
+ // the safe 'refuse'.
219
+ export class DowngradeRefusedError extends Error {
220
+ constructor(message) {
221
+ super(message);
222
+ this.name = 'DowngradeRefusedError';
208
223
  }
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));
224
+ }
225
+ let activeDowngradeMode;
226
+ /**
227
+ * Publish (or clear) the downgrade policy resolved upstream by installCommand so
228
+ * the exec boundary reuses it. Exported as a test seam.
229
+ */
230
+ export function setActiveDowngradeMode(mode) {
231
+ activeDowngradeMode = mode;
232
+ }
233
+ /**
234
+ * Enforce the downgrade policy for a global-install command about to execute.
235
+ * No-op unless the command is a global install carrying versioned specs AND at
236
+ * least one spec would downgrade an already-installed global package. On a
237
+ * 'refuse' verdict it throws DowngradeRefusedError BEFORE spawn (zero partial
238
+ * install); 'warn' logs and proceeds; 'allow' proceeds silently. Pure detection
239
+ * is delegated to @microwiseai/snapshot-downgrade-guard.
240
+ */
241
+ function assertNoRefusedDowngrade(command) {
242
+ const specs = extractGlobalInstallSpecs(command);
243
+ if (Object.keys(specs).length === 0)
244
+ return;
245
+ const { wouldDowngrade, offenders } = detectDowngrades(specs, {
246
+ readVersion: readInstalledNpmVersion,
247
+ });
248
+ if (!wouldDowngrade)
249
+ return;
250
+ // Reuse the upstream-resolved mode when present (installCommand path); else
251
+ // self-resolve for the context-free direct library-call path.
252
+ const threaded = activeDowngradeMode !== undefined;
253
+ const mode = threaded
254
+ ? activeDowngradeMode
255
+ : resolveDowngradePolicy({
256
+ override: readOneShotOverride({ env: process.env }),
257
+ machine: getMachineDowngradeMode(),
258
+ // snapshot.json policy is not available at this boundary; the default
259
+ // 'refuse' keeps the direct-call path on the safe side.
260
+ });
261
+ if (mode === 'refuse') {
262
+ // Only the self-resolved (direct-call) path reports here — the installCommand
263
+ // path already printed upstream and cannot reach a refuse at this boundary
264
+ // (it exits during pre-install), so this avoids duplicate output.
265
+ if (!threaded) {
266
+ console.log('');
267
+ console.log(chalk.red(formatDowngradeReport(offenders, mode)));
219
268
  }
269
+ throw new DowngradeRefusedError(formatDowngradeReport(offenders, mode));
270
+ }
271
+ if (mode === 'warn' && !threaded) {
272
+ console.log('');
273
+ console.log(chalk.yellow(formatDowngradeReport(offenders, mode)));
274
+ }
275
+ // mode === 'allow' → proceed.
276
+ }
277
+ /**
278
+ * Run a command with FULLY LIVE output (spawn, async).
279
+ *
280
+ * Why spawn+async (not execSync): npm writes its progress bar to STDERR, and the
281
+ * previous `execSync(stdio:['inherit','inherit','pipe'])` piped stderr — which
282
+ * (a) hid npm's live progress (looked "frozen" for tens of seconds) and
283
+ * (b) blocked the event loop so no JS spinner could ever turn. Inheriting all
284
+ * three streams lets npm's own progress render live to the terminal while the
285
+ * event loop stays free. See install-speed-visibility work (B).
286
+ *
287
+ * On failure we can no longer capture the stderr text (it went straight to the
288
+ * terminal, so the user already SAW it) — `lastCommandError` carries the exit
289
+ * code instead.
290
+ */
291
+ // Exported as a test seam (unit-tested in install.runcommand.test.ts).
292
+ export function runCommand(command) {
293
+ // Depth-defense: abort BEFORE spawn if this global install would downgrade and
294
+ // policy is refuse. Throws DowngradeRefusedError (no partial install).
295
+ assertNoRefusedDowngrade(command);
296
+ lastCommandError = undefined;
297
+ return new Promise((resolve) => {
298
+ // shell:true so the plain command string (e.g. "npm install -g a@1 b@2")
299
+ // is parsed exactly as it was under execSync.
300
+ const child = spawn(command, { stdio: 'inherit', shell: true });
301
+ child.on('error', (err) => {
302
+ lastCommandError = `spawn error — ${err.message}`;
303
+ console.log(chalk.red(` Command failed to start: ${command}`));
304
+ console.log(chalk.red(err.message));
305
+ resolve(false);
306
+ });
307
+ child.on('close', (code) => {
308
+ if (code === 0) {
309
+ resolve(true);
310
+ return;
311
+ }
312
+ lastCommandError = `exit code ${code ?? 'unknown'}`;
313
+ console.log(chalk.red(` Command failed (exit code ${code ?? 'unknown'}): ${command}`));
314
+ resolve(false);
315
+ });
316
+ });
317
+ }
318
+ /**
319
+ * Synchronous streaming variant — used ONLY by the converge re-pin callback,
320
+ * which is invoked from the pure/sync `runConverge` (lib/install-verify.ts).
321
+ * Keeping this sync avoids making `runConverge` async (which would ripple into
322
+ * its unit-test suite and break its pure-function contract). `spawnSync` with
323
+ * inherited stdio still streams npm's live progress to the terminal (the child
324
+ * writes directly to the inherited fd), so the converge phase no longer looks
325
+ * frozen either — it only blocks the JS event loop for its (batched, rare)
326
+ * duration.
327
+ */
328
+ // Exported as a test seam (unit-tested in install.runcommand.test.ts).
329
+ export function runCommandSync(command) {
330
+ // Depth-defense: abort BEFORE spawn if this global install would downgrade and
331
+ // policy is refuse. Throws DowngradeRefusedError (no partial install).
332
+ assertNoRefusedDowngrade(command);
333
+ lastCommandError = undefined;
334
+ const res = spawnSync(command, { stdio: 'inherit', shell: true });
335
+ if (res.error) {
336
+ lastCommandError = `spawn error — ${res.error.message}`;
337
+ console.log(chalk.red(` Command failed to start: ${command}`));
338
+ console.log(chalk.red(res.error.message));
220
339
  return false;
221
340
  }
341
+ if (res.status === 0)
342
+ return true;
343
+ lastCommandError = `exit code ${res.status ?? 'unknown'}`;
344
+ console.log(chalk.red(` Command failed (exit code ${res.status ?? 'unknown'}): ${command}`));
345
+ return false;
222
346
  }
223
347
  /**
224
348
  * Check if a skit package is already installed with the specified version
@@ -264,8 +388,23 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId, re
264
388
  const total = packageEntries.length;
265
389
  console.log('');
266
390
  console.log(chalk.yellow(`${indent}=== ${snapshotName}: ${total} skits ===`));
267
- // Prepare install options with sessionId if available
268
- const skitOptions = sessionId ? { sessionId } : {};
391
+ // Prepare install options with sessionId if available. Forward onProgress so
392
+ // the skit lib's live install messages reach the terminal (skit-adapter.ts
393
+ // already forwards options.onProgress into lib.installSkit) — otherwise each
394
+ // skit install is silent until it completes.
395
+ // grant-unify P1: a function source is read PER KIT, right before the skit
396
+ // call, so a re-issued grant token is picked up (grant mode passes the istsg_
397
+ // token as the skit session → X-Session-Id on the kit tarball download).
398
+ // A plain string (legacy authorizeSession id) behaves exactly as before.
399
+ const skitOptionsFor = () => {
400
+ const sid = typeof sessionId === 'function' ? sessionId() : sessionId;
401
+ return {
402
+ ...(sid ? { sessionId: sid } : {}),
403
+ onProgress: (message) => {
404
+ console.log(chalk.gray(`${indent} ${message}`));
405
+ },
406
+ };
407
+ };
269
408
  // Install packages in parallel using snapshot-parallel
270
409
  const entries = recordToEntries(packages);
271
410
  const parallelResult = await parallelInstall(entries, async (entry) => {
@@ -273,7 +412,11 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId, re
273
412
  if (isSkitInstalled(entry.name, entry.version)) {
274
413
  return { success: true, skipped: true };
275
414
  }
276
- const installResult = await installSkitPackage(entry.name, entry.version, skitOptions);
415
+ // Per-skit start log so a slow single install doesn't look frozen.
416
+ console.log(chalk.gray(`${indent} installing ${entry.name}@${entry.version}…`));
417
+ const endKit = timingStart(`kit ${entry.name}@${entry.version}`);
418
+ const installResult = await installSkitPackage(entry.name, entry.version, skitOptionsFor());
419
+ endKit();
277
420
  return {
278
421
  success: installResult.success,
279
422
  error: installResult.error,
@@ -333,7 +476,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
333
476
  if (!commandExists(cmdName)) {
334
477
  console.log(chalk.yellow(`${indent}${installerName} not found. Attempting auto-install...`));
335
478
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
336
- if (runCommand(installerConfig.hint)) {
479
+ if (await runCommand(installerConfig.hint)) {
337
480
  console.log(chalk.green(`${indent} ✓ ${installerName} installed successfully`));
338
481
  }
339
482
  else {
@@ -353,6 +496,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
353
496
  if (isNpmInstaller) {
354
497
  // Batch install: collect all not-yet-installed packages and run a single npm install -g
355
498
  const toInstall = [];
499
+ const endProbe = timingStart(`layer already-installed probes (${entries.length})`);
356
500
  for (const entry of entries) {
357
501
  if (isNpmGlobalInstalled(entry.name, entry.version)) {
358
502
  console.log(`${indent}${chalk.gray('skip')} ${entry.name}@${entry.version}${chalk.green(' (already installed)')}`);
@@ -362,11 +506,12 @@ async function installPackagesByType(packages, installerName, installers, snapsh
362
506
  toInstall.push(entry);
363
507
  }
364
508
  }
509
+ endProbe();
365
510
  if (toInstall.length > 0) {
366
511
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
367
512
  const batchCommand = installerConfig.command.replace('{pkg}', specs.join(' '));
368
513
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
369
- const success = runCommand(batchCommand);
514
+ const success = await runCommand(batchCommand);
370
515
  // S3 — record the batch step (ok, or 'fallback' if it failed → retried below).
371
516
  recorder?.record({
372
517
  phase: 'layer-batch',
@@ -389,7 +534,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
389
534
  for (const entry of toInstall) {
390
535
  const pkgSpec = `${entry.name}@${entry.version}`;
391
536
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
392
- const ok = runCommand(command);
537
+ const ok = await runCommand(command);
393
538
  recorder?.record({
394
539
  phase: 'layer-fallback',
395
540
  installer: installerName,
@@ -410,14 +555,18 @@ async function installPackagesByType(packages, installerName, installers, snapsh
410
555
  }
411
556
  }
412
557
  else {
413
- // Non-npm installer: use parallel install as before
558
+ // Non-npm installer: run SERIALLY (concurrency 1). runCommand now spawns with
559
+ // stdio:'inherit', so concurrent children would interleave output on the same
560
+ // terminal AND race the module-global lastCommandError (mis-attributing the
561
+ // failure reason). Before the B change this path used sync execSync and was
562
+ // therefore effectively serial; concurrency:1 restores that (zero regression).
414
563
  const parallelResult = await parallelInstall(entries, async (entry) => {
415
564
  const pkgSpec = `${entry.name}@${entry.version}`;
416
565
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
417
- const success = runCommand(command);
566
+ const success = await runCommand(command);
418
567
  return { success, error: success ? undefined : `Command failed: ${command}` };
419
568
  }, {
420
- concurrency: 4,
569
+ concurrency: 1,
421
570
  onProgress: (event) => {
422
571
  const { result: r, current, total: t } = event;
423
572
  const progress = `[${current}/${t}]`;
@@ -463,6 +612,32 @@ function applySnapshotOverrides(snapshot, overrides) {
463
612
  }
464
613
  return next;
465
614
  }
615
+ // ============================================================
616
+ // Grant transport token (grant-unify P1, SNAPSHOT_GRANT_MODE)
617
+ // ============================================================
618
+ // When a snapshot grant is active, EVERY registry-proxy snapshot fetch (incl.
619
+ // extends bases) carries the istsg_ grant token explicitly — it takes precedence
620
+ // over the license key / IST token resolution inside fetchSnapshotFromRepo.
621
+ // null (always, in SNAPSHOT_GRANT_MODE=off) → the legacy call shape is kept.
622
+ let activeGrantToken = null;
623
+ /** Fetch an extends base snapshot (grant token when a grant is active). */
624
+ function fetchBaseSnapshot(spec) {
625
+ return activeGrantToken ? fetchSnapshotFromRepo(spec, activeGrantToken) : fetchSnapshotFromRepo(spec);
626
+ }
627
+ /**
628
+ * Collect the extends chain of a snapshot as exact {name, version} specs (the
629
+ * ancestor generics). Grant mode only — used for the closure coverage check.
630
+ */
631
+ async function collectExtendsChain(snapshot) {
632
+ const chain = [];
633
+ let cur = snapshot;
634
+ for (let depth = 0; cur.extends && depth < 16; depth++) {
635
+ const base = await fetchBaseSnapshot(cur.extends);
636
+ chain.push({ name: base.name, version: base.version });
637
+ cur = base;
638
+ }
639
+ return chain;
640
+ }
466
641
  /**
467
642
  * FB — the subset of pin overrides that actually apply to this install (i.e. the
468
643
  * overridden package IS declared in the effective npm map). These are the
@@ -502,7 +677,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
502
677
  console.log('');
503
678
  console.log(chalk.cyan(`${indent}► Extends: ${snapshot.extends}`));
504
679
  try {
505
- const baseSnapshot = await fetchSnapshotFromRepo(snapshot.extends);
680
+ const baseSnapshot = await fetchBaseSnapshot(snapshot.extends);
506
681
  console.log(chalk.gray(`${indent} Installing base snapshot...`));
507
682
  const baseResult = await installSnapshot(baseSnapshot, depth + 1, installers, options);
508
683
  result.installed.push(...baseResult.result.installed);
@@ -589,21 +764,22 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
589
764
  const resolved = await resolveSnapshotDependencies(snapshot, { skipRegistry: true });
590
765
  // skit 패키지 설치 (skit-adapter 사용: lib 우선, CLI fallback)
591
766
  if (Object.keys(resolved.skit).length > 0) {
592
- const skitResult = await installSkitPackages(resolved.skit, snapshot.name, indent, options.sessionId, options.recorder);
767
+ await options.beforePhase?.(`kit:${snapshot.name}`);
768
+ const skitResult = await installSkitPackages(resolved.skit, snapshot.name, indent, (options.skitSessionId ?? options.sessionId), options.recorder);
593
769
  result.installed.push(...skitResult.installed);
594
770
  result.failed.push(...skitResult.failed);
595
771
  }
596
772
  // npm 패키지 설치 (proxy 사용 시 npm-proxy, 아니면 glpkg)
597
773
  if (Object.keys(resolved.npm).length > 0) {
598
774
  const npmInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
599
- const npmResult = await installPackagesByType(resolved.npm, npmInstaller, installers, snapshot.name, indent, 'npm packages', options.sessionId, options.recorder);
775
+ const npmResult = await installPackagesByType(resolved.npm, npmInstaller, installers, snapshot.name, indent, 'npm packages', (options.skitSessionId ?? options.sessionId), options.recorder);
600
776
  result.installed.push(...npmResult.installed);
601
777
  result.failed.push(...npmResult.failed);
602
778
  }
603
779
  // generic 패키지 설치 (glpkg)
604
780
  if (Object.keys(resolved.generic).length > 0) {
605
781
  const genericInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
606
- const genericResult = await installPackagesByType(resolved.generic, genericInstaller, installers, snapshot.name, indent, 'packages', options.sessionId, options.recorder);
782
+ const genericResult = await installPackagesByType(resolved.generic, genericInstaller, installers, snapshot.name, indent, 'packages', (options.skitSessionId ?? options.sessionId), options.recorder);
607
783
  result.installed.push(...genericResult.installed);
608
784
  result.failed.push(...genericResult.failed);
609
785
  }
@@ -620,7 +796,8 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
620
796
  continue;
621
797
  // Skit packages: use skit-adapter (lib 우선, CLI fallback)
622
798
  if (installerName === 'skit') {
623
- const skitResult = await installSkitPackages(packages, snapshot.name, indent, options.sessionId, options.recorder);
799
+ await options.beforePhase?.(`kit:${snapshot.name}`);
800
+ const skitResult = await installSkitPackages(packages, snapshot.name, indent, (options.skitSessionId ?? options.sessionId), options.recorder);
624
801
  result.installed.push(...skitResult.installed);
625
802
  result.failed.push(...skitResult.failed);
626
803
  continue;
@@ -639,7 +816,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
639
816
  if (!commandExists(cmdName)) {
640
817
  console.log(chalk.yellow(`${indent}${installerName} not found. Attempting auto-install...`));
641
818
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
642
- if (runCommand(installerConfig.hint)) {
819
+ if (await runCommand(installerConfig.hint)) {
643
820
  console.log(chalk.green(`${indent} ✓ ${installerName} installed successfully`));
644
821
  }
645
822
  else {
@@ -660,6 +837,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
660
837
  if (isNpmType) {
661
838
  // Batch install: collect all not-yet-installed packages and run a single npm install -g
662
839
  const toInstall = [];
840
+ const endProbe = timingStart(`layer already-installed probes (${installEntries.length})`);
663
841
  for (const entry of installEntries) {
664
842
  if (isNpmGlobalInstalled(entry.name, entry.version)) {
665
843
  console.log(`${indent}${chalk.gray('skip')} ${entry.name}@${entry.version}${chalk.green(' (already installed)')}`);
@@ -669,11 +847,12 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
669
847
  toInstall.push(entry);
670
848
  }
671
849
  }
850
+ endProbe();
672
851
  if (toInstall.length > 0) {
673
852
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
674
853
  const batchCommand = installCfg.command.replace('{pkg}', specs.join(' '));
675
854
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
676
- const success = runCommand(batchCommand);
855
+ const success = await runCommand(batchCommand);
677
856
  // S3 — record the batch step (ok, or 'fallback' if it failed → retried below).
678
857
  options.recorder?.record({
679
858
  phase: 'layer-batch',
@@ -696,7 +875,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
696
875
  for (const entry of toInstall) {
697
876
  const pkgSpec = `${entry.name}@${entry.version}`;
698
877
  const command = installCfg.command.replace('{pkg}', pkgSpec);
699
- const ok = runCommand(command);
878
+ const ok = await runCommand(command);
700
879
  options.recorder?.record({
701
880
  phase: 'layer-fallback',
702
881
  installer: installerName,
@@ -717,14 +896,16 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
717
896
  }
718
897
  }
719
898
  else {
720
- // Non-npm installer: use parallel install as before
899
+ // Non-npm installer: run SERIALLY (concurrency 1) — see rationale above:
900
+ // spawn(stdio:'inherit') children would interleave terminal output and
901
+ // race the global lastCommandError. Restores the pre-B serial behavior.
721
902
  const parallelResult = await parallelInstall(installEntries, async (entry) => {
722
903
  const pkgSpec = `${entry.name}@${entry.version}`;
723
904
  const command = installCfg.command.replace('{pkg}', pkgSpec);
724
- const success = runCommand(command);
905
+ const success = await runCommand(command);
725
906
  return { success, error: success ? undefined : `Command failed: ${command}` };
726
907
  }, {
727
- concurrency: 4,
908
+ concurrency: 1,
728
909
  onProgress: (event) => {
729
910
  const { result: r, current, total: t } = event;
730
911
  const progress = `[${current}/${t}]`;
@@ -759,7 +940,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
759
940
  if (!commandExists(cmdName)) {
760
941
  console.log(chalk.yellow(`${indent}glpkg not found. Attempting auto-install...`));
761
942
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
762
- if (runCommand(installerConfig.hint)) {
943
+ if (await runCommand(installerConfig.hint)) {
763
944
  console.log(chalk.green(`${indent} ✓ glpkg installed successfully`));
764
945
  }
765
946
  else {
@@ -774,13 +955,15 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
774
955
  else {
775
956
  const legacyEntries = recordToEntries(snapshot.packages);
776
957
  const legacyCfg = installerConfig; // capture for closure
958
+ // Legacy glpkg packages: run SERIALLY (concurrency 1) — see rationale above
959
+ // (spawn:'inherit' output interleave + global lastCommandError race).
777
960
  const parallelResult = await parallelInstall(legacyEntries, async (entry) => {
778
961
  const pkgSpec = `${entry.name}@${entry.version}`;
779
962
  const command = legacyCfg.command.replace('{pkg}', pkgSpec);
780
- const success = runCommand(command);
963
+ const success = await runCommand(command);
781
964
  return { success, error: success ? undefined : `Command failed: ${command}` };
782
965
  }, {
783
- concurrency: 4,
966
+ concurrency: 1,
784
967
  onProgress: (event) => {
785
968
  const { result: r, current, total: t } = event;
786
969
  const progress = `[${current}/${t}]`;
@@ -806,7 +989,8 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
806
989
  }
807
990
  // Legacy: skits (use skit-adapter: lib 우선, CLI fallback)
808
991
  if (snapshot.skits && Object.keys(snapshot.skits).length > 0) {
809
- const skitResult = await installSkitPackages(snapshot.skits, snapshot.name, indent, options.sessionId, options.recorder);
992
+ await options.beforePhase?.(`kit:${snapshot.name}`);
993
+ const skitResult = await installSkitPackages(snapshot.skits, snapshot.name, indent, (options.skitSessionId ?? options.sessionId), options.recorder);
810
994
  result.installed.push(...skitResult.installed);
811
995
  result.failed.push(...skitResult.failed);
812
996
  }
@@ -846,7 +1030,7 @@ async function collectAllNpmPackages(snapshot, useProxy = true) {
846
1030
  // Recurse into extends chain
847
1031
  if (snapshot.extends) {
848
1032
  try {
849
- const baseSnapshot = await fetchSnapshotFromRepo(snapshot.extends);
1033
+ const baseSnapshot = await fetchBaseSnapshot(snapshot.extends);
850
1034
  const baseNpmPackages = await collectAllNpmPackages(baseSnapshot, useProxy);
851
1035
  // Base packages: don't override current snapshot's versions
852
1036
  for (const [name, version] of Object.entries(baseNpmPackages)) {
@@ -883,7 +1067,7 @@ export async function collectAllPackages(snapshot) {
883
1067
  // Recurse into extends chain
884
1068
  if (snapshot.extends) {
885
1069
  try {
886
- const baseSnapshot = await fetchSnapshotFromRepo(snapshot.extends);
1070
+ const baseSnapshot = await fetchBaseSnapshot(snapshot.extends);
887
1071
  const basePackages = await collectAllPackages(baseSnapshot);
888
1072
  Object.assign(allPackages, basePackages);
889
1073
  }
@@ -996,7 +1180,7 @@ async function runReplaceMode(snapshotName, newSet, options, installers) {
996
1180
  continue;
997
1181
  }
998
1182
  console.log(chalk.gray(` Removing ${item.name}...`));
999
- if (runCommand(cmd)) {
1183
+ if (await runCommand(cmd)) {
1000
1184
  console.log(` ${item.name}${chalk.green(' ✓ removed')}`);
1001
1185
  removed.push(item);
1002
1186
  }
@@ -1057,6 +1241,9 @@ async function previewInstall(snapshot, options) {
1057
1241
  console.log(chalk.gray('Dry-run complete: no installs, no uninstalls, no .npmrc or history changes.'));
1058
1242
  }
1059
1243
  export async function installCommand(name, options = {}) {
1244
+ // Start from a clean downgrade-policy context; the pre-install guard publishes
1245
+ // the resolved mode below, and cleanup() clears it on every exit path.
1246
+ setActiveDowngradeMode(undefined);
1060
1247
  console.log(chalk.blue.bold(`
1061
1248
  ╔══════════════════════════════════════════════════════════════╗
1062
1249
  ║ Snapshot Installer ║
@@ -1106,11 +1293,90 @@ export async function installCommand(name, options = {}) {
1106
1293
  console.log(chalk.yellow('⚠') + ' No auth token — anonymous access');
1107
1294
  }
1108
1295
  const useProxy = true;
1296
+ // ========== Snapshot grant (grant-unify P1, SNAPSHOT_GRANT_MODE) ==========
1297
+ // off (default): nothing below runs (grant stays null) — legacy path.
1298
+ // prefer/require: request ONE istsg_ grant BEFORE fetching snapshot.json, since
1299
+ // the root generic itself is a closure member the proxy may gate. The grant is
1300
+ // always for an EXACT version: none/'latest' is resolved first with the proxy
1301
+ // rule (highest stable, prereleases ignored — same as the legacy path's
1302
+ // 'latest'); license-api's own 'latest' (= newest upload) is never used. prefer drops
1303
+ // the grant on any grant failure and runs the whole install on the legacy
1304
+ // path; require is fail-loud. dry-run + prefer skips the grant (zero side
1305
+ // effects); dry-run + require still needs it to fetch the snapshot.
1306
+ activeGrantToken = null;
1307
+ const grantMode = readGrantMode(process.env, (m) => console.log(chalk.yellow('⚠ ' + m)));
1308
+ const { name: snapshotBaseName, version: requestedVersion } = splitSnapshotSpec(name);
1309
+ let grant = null;
1310
+ let grantEnvActive = false;
1311
+ /** Grant failure policy: require → exit 1; prefer → warn, drop grant, legacy path. */
1312
+ const onGrantFailure = (err) => {
1313
+ const msg = err instanceof Error ? err.message : String(err);
1314
+ if (grantMode === 'require') {
1315
+ console.log(chalk.red(`⛔ Snapshot grant failed (SNAPSHOT_GRANT_MODE=require): ${msg}`));
1316
+ process.exit(1);
1317
+ }
1318
+ console.log(chalk.yellow(`⚠ Snapshot grant unavailable — continuing on the legacy path: ${msg}`));
1319
+ grant = null;
1320
+ activeGrantToken = null;
1321
+ };
1322
+ if (grantMode !== 'off' && !(options.dryRun && grantMode === 'prefer')) {
1323
+ const timeoutMs = readGrantTimeoutMs();
1324
+ let grantVersion = null;
1325
+ try {
1326
+ grantVersion = await resolveInstallVersion(snapshotBaseName, requestedVersion, {
1327
+ registryUrl: REGISTRY_PROXY_URL,
1328
+ timeoutMs,
1329
+ });
1330
+ }
1331
+ catch (err) {
1332
+ onGrantFailure(err);
1333
+ }
1334
+ if (grantVersion) {
1335
+ grant = new GrantState({ snapshot: snapshotBaseName, version: grantVersion, authToken }, {
1336
+ request: (req) => requestSnapshotGrant(req, { licenseApiUrl: LICENSE_API_URL, timeoutMs }),
1337
+ onToken: (token) => {
1338
+ activeGrantToken = token;
1339
+ if (grantEnvActive)
1340
+ process.env.IST_SESSION_TOKEN = token;
1341
+ },
1342
+ });
1343
+ console.log(chalk.gray(`Requesting snapshot grant (${grantMode}) for ${snapshotBaseName}@${grantVersion}...`));
1344
+ try {
1345
+ const g = await grant.issue();
1346
+ // The closure root (when present) must be exactly the version we asked for.
1347
+ const rootVersion = findRootMemberVersion(g.members, snapshotBaseName);
1348
+ if (rootVersion && rootVersion !== grantVersion) {
1349
+ throw new GrantError('version-mismatch', `grant closure root ${snapshotBaseName}@${rootVersion} differs from the requested ${grantVersion}`);
1350
+ }
1351
+ console.log(chalk.green('✓') + ' ' + formatGrantLog(grantMode, g));
1352
+ }
1353
+ catch (err) {
1354
+ onGrantFailure(err);
1355
+ }
1356
+ }
1357
+ }
1358
+ // ==========================================================================
1109
1359
  // Fetch snapshot first — access check depends on snapshot.json content
1110
1360
  console.log(chalk.gray('Fetching snapshot...'));
1111
1361
  let snapshot;
1112
1362
  try {
1113
- snapshot = await fetchSnapshotFromRepo(name, authToken);
1363
+ if (grant) {
1364
+ // Grant mode: fetch exactly the granted version with the grant token.
1365
+ const grantVersion = grant.version;
1366
+ try {
1367
+ snapshot = await fetchSnapshotFromRepo(`${snapshotBaseName}@${grantVersion}`, activeGrantToken);
1368
+ if (snapshot.version !== grantVersion) {
1369
+ throw new GrantError('version-mismatch', `snapshot ${snapshotBaseName}@${snapshot.version} does not match the granted version ${grantVersion}`);
1370
+ }
1371
+ }
1372
+ catch (err) {
1373
+ onGrantFailure(err);
1374
+ snapshot = await fetchSnapshotFromRepo(name, authToken);
1375
+ }
1376
+ }
1377
+ else {
1378
+ snapshot = await fetchSnapshotFromRepo(name, authToken);
1379
+ }
1114
1380
  }
1115
1381
  catch (error) {
1116
1382
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -1180,8 +1446,13 @@ export async function installCommand(name, options = {}) {
1180
1446
  const isAuthenticatedAccess = accessField === 'authenticated';
1181
1447
  const isLicensedAccess = !!accessField && typeof accessField === 'object' && accessField.type === 'licensed';
1182
1448
  // --- IST Login Check ---
1183
- const istCredentials = getIstCredentials();
1184
- if (istCredentials?.userId) {
1449
+ // Single auth verdict: isLoggedIn() = the same credential SSOT that resolved
1450
+ // authToken above (env IST_SESSION_TOKEN or ~/.ist/credentials.json .token,
1451
+ // locally unexpired) and that the 'IST Credentials' prerequisite uses. The old
1452
+ // check read credentials.json .userId, so a token-authenticated install could
1453
+ // print "✓ Auth: IST token" AND "⚠ No IST login" in the same run.
1454
+ const istCredentials = getIstCredentials(); // telemetry identity only (userId/email)
1455
+ if (isLoggedIn()) {
1185
1456
  console.log(chalk.green('✓') + ' IST login verified');
1186
1457
  }
1187
1458
  else if (isPublicAccess) {
@@ -1242,9 +1513,39 @@ export async function installCommand(name, options = {}) {
1242
1513
  console.log(chalk.green('✓') + ` License verified for ${productId}`);
1243
1514
  }
1244
1515
  // =======================================================================
1516
+ // ========== Grant closure coverage (grant mode only) ==========
1517
+ // members[] is the ALLOWED set: every exact pin we will install (flattened
1518
+ // across the extends chain, --pin applied) plus the extends ancestors must be
1519
+ // a closure member, or the proxy would 401 mid-install. A --pin outside the
1520
+ // closure is refused up front (both modes, logged in or not). A manifest pin
1521
+ // outside it is a grant/index inconsistency: prefer → legacy path, require → exit.
1522
+ if (grant) {
1523
+ const g = grant.current;
1524
+ const expectedPins = applyPinOverrides(await collectAllPackages(snapshot), overrides);
1525
+ let ancestors = [];
1526
+ try {
1527
+ ancestors = await collectExtendsChain(snapshot);
1528
+ }
1529
+ catch (err) {
1530
+ onGrantFailure(err);
1531
+ }
1532
+ if (grant) {
1533
+ const coverage = checkGrantCoverage([...Object.entries(expectedPins).map(([n, v]) => ({ name: n, version: v })), ...ancestors], g.members);
1534
+ if (!coverage.ok) {
1535
+ const pinViolations = coverage.missing.filter((spec) => Object.entries(overrides).some(([n, v]) => spec === `${n}@${v}`));
1536
+ if (pinViolations.length > 0) {
1537
+ console.log(chalk.red(`⛔ --pin outside the snapshot grant closure: ${pinViolations.join(', ')}`));
1538
+ console.log(chalk.gray(' A pinned version must be part of this snapshot version\'s frozen closure.'));
1539
+ process.exit(1);
1540
+ }
1541
+ onGrantFailure(new GrantError('invalid-response', `grant closure does not cover ${coverage.missing.length} manifest pin(s): ${coverage.missing.join(', ')}`));
1542
+ }
1543
+ }
1544
+ }
1245
1545
  // ========== Authorize Session with Registry Proxy ==========
1246
1546
  let sessionId;
1247
- if (authToken) {
1547
+ // Grant mode: the istsg_ grant replaces authorizeSession (one transport token).
1548
+ if (authToken && !grant) {
1248
1549
  // Collect all packages for session authorization
1249
1550
  const sessionPackages = [];
1250
1551
  // From snapshot.install
@@ -1268,7 +1569,7 @@ export async function installCommand(name, options = {}) {
1268
1569
  console.log(chalk.gray(` Auth type: ${authType}`));
1269
1570
  try {
1270
1571
  sessionId = await authorizeSession(authToken, name, sessionPackages);
1271
- console.log(chalk.green('✓') + ' Session authorized: ' + sessionId);
1572
+ console.log(chalk.green('✓') + ' Session authorized: ' + maskSessionId(sessionId));
1272
1573
  }
1273
1574
  catch (error) {
1274
1575
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -1286,7 +1587,15 @@ export async function installCommand(name, options = {}) {
1286
1587
  if (useProxy) {
1287
1588
  const scopes = extractScopes(allPackages);
1288
1589
  if (scopes.length > 0) {
1289
- if (authToken) {
1590
+ if (grant) {
1591
+ // Grant mode: the istsg_ token is the proxy transport token for npm and
1592
+ // skit (kit npm deps read ${IST_SESSION_TOKEN}). Re-issues update it.
1593
+ process.env.IST_SESSION_TOKEN = grant.current.token;
1594
+ grantEnvActive = true;
1595
+ console.log(chalk.gray(`Configuring registry-proxy (snapshot grant) for scopes: ${scopes.join(', ')}`));
1596
+ npmrcBackup = configureNpmrcForProxy(scopes);
1597
+ }
1598
+ else if (authToken) {
1290
1599
  // 환경변수에 설정 (npm이 .npmrc의 ${IST_SESSION_TOKEN}을 해석)
1291
1600
  process.env.IST_SESSION_TOKEN = authToken;
1292
1601
  console.log(chalk.gray(`Configuring registry-proxy for scopes: ${scopes.join(', ')}`));
@@ -1316,6 +1625,11 @@ export async function installCommand(name, options = {}) {
1316
1625
  console.log(chalk.gray('Restored .npmrc'));
1317
1626
  }
1318
1627
  delete process.env.IST_SESSION_TOKEN;
1628
+ activeGrantToken = null;
1629
+ grantEnvActive = false;
1630
+ // Clear the threaded downgrade mode so it never leaks to a later install
1631
+ // (the exec boundary self-resolves again when no context is published).
1632
+ setActiveDowngradeMode(undefined);
1319
1633
  };
1320
1634
  const onSignal = (signal) => {
1321
1635
  console.log(chalk.yellow(`\nReceived ${signal} — restoring .npmrc before exit...`));
@@ -1326,6 +1640,28 @@ export async function installCommand(name, options = {}) {
1326
1640
  };
1327
1641
  process.once('SIGINT', onSignal);
1328
1642
  process.once('SIGTERM', onSignal);
1643
+ // grant-unify P1 — re-issue the snapshot grant before a long phase when its
1644
+ // remaining TTL is low (GrantState.ensureFresh). The new token reaches npm and
1645
+ // skit through IST_SESSION_TOKEN (onToken). A re-issue failure: require →
1646
+ // throw (fail-loud; callers inside the install try/finally clean up), prefer →
1647
+ // warn and keep the current token. No-op without an active grant (off mode).
1648
+ const refreshGrant = async (phase) => {
1649
+ if (!grant)
1650
+ return;
1651
+ const st = grant;
1652
+ try {
1653
+ if (await st.ensureFresh()) {
1654
+ console.log(chalk.gray(` ↻ Grant re-issued before ${phase}: ${formatGrantLog(grantMode, st.current)}`));
1655
+ }
1656
+ }
1657
+ catch (err) {
1658
+ const msg = err instanceof Error ? err.message : String(err);
1659
+ if (grantMode === 'require') {
1660
+ throw new Error(`Snapshot grant re-issue failed before ${phase}: ${msg}`);
1661
+ }
1662
+ console.log(chalk.yellow(` ⚠ Snapshot grant re-issue failed before ${phase} — keeping the current grant: ${msg}`));
1663
+ }
1664
+ };
1329
1665
  // Determine which installers to use
1330
1666
  const effectiveInstallers = { ...DEFAULT_INSTALLERS };
1331
1667
  if (useProxy) {
@@ -1351,11 +1687,56 @@ export async function installCommand(name, options = {}) {
1351
1687
  console.log(chalk.yellow(`⚠ --pin ignored for package(s) not in this snapshot: ${unmatched.join(', ')}`));
1352
1688
  }
1353
1689
  const npmEntries = Object.entries(effectiveNpmMap);
1690
+ // ========== Downgrade guard (dev-isolation) ==========
1691
+ // A snapshot bundles OLDER stable versions. On a dev machine whose global
1692
+ // tooling is already NEWER, installing would `npm i -g {pkg}@{pinned}` and
1693
+ // DOWNGRADE it, breaking the environment. Detect at THIS boundary — the map is
1694
+ // now flattened across the whole extends chain (effectiveNpmMap), and NO npm
1695
+ // exec has happened yet (the pre-install batch is below). A clean Docker
1696
+ // sandbox has nothing installed globally → zero offenders → auto-pass, no
1697
+ // config needed. Policy precedence (highest wins): CLI --allow-downgrade / env
1698
+ // SNAPSHOT_ALLOW_LOCAL=1 → machine config → snapshot.policy.onDowngrade →
1699
+ // default 'refuse'. Side effects live in @microwiseai/snapshot-downgrade-guard
1700
+ // via injection (readVersion), keeping that package pure.
1701
+ {
1702
+ const { wouldDowngrade, offenders } = detectDowngrades(effectiveNpmMap, {
1703
+ readVersion: readInstalledNpmVersion,
1704
+ });
1705
+ // Resolve the policy ONCE with full context (incl. snapshot.json policy) and
1706
+ // publish it so the downstream npm-exec boundary (runCommand/runCommandSync)
1707
+ // REUSES it instead of recomputing — one decision for the whole install, and
1708
+ // the boundary won't re-report the same downgrade.
1709
+ const mode = resolveDowngradePolicy({
1710
+ override: readOneShotOverride({
1711
+ env: process.env,
1712
+ allowDowngradeFlag: options.allowDowngrade === true,
1713
+ }),
1714
+ machine: getMachineDowngradeMode(),
1715
+ snapshot: snapshot.policy?.onDowngrade,
1716
+ });
1717
+ setActiveDowngradeMode(mode);
1718
+ if (wouldDowngrade) {
1719
+ if (mode === 'refuse') {
1720
+ // Nothing installed yet — restore .npmrc / unset token, then fail-loud
1721
+ // with a non-zero exit and ZERO partial install.
1722
+ cleanup();
1723
+ console.log('');
1724
+ console.log(chalk.red(formatDowngradeReport(offenders, mode)));
1725
+ process.exit(1);
1726
+ }
1727
+ else if (mode === 'warn') {
1728
+ console.log('');
1729
+ console.log(chalk.yellow(formatDowngradeReport(offenders, mode)));
1730
+ }
1731
+ // mode === 'allow' → proceed silently.
1732
+ }
1733
+ }
1354
1734
  if (npmEntries.length > 0) {
1355
1735
  console.log('');
1356
1736
  console.log(chalk.cyan(`► Pre-installing ${npmEntries.length} npm packages from entire extends chain...`));
1357
1737
  // Filter out already-installed packages
1358
1738
  const toInstall = [];
1739
+ const endProbe = timingStart(`pre-install already-installed probes (${npmEntries.length})`);
1359
1740
  for (const [name, version] of npmEntries) {
1360
1741
  if (isNpmGlobalInstalled(name, version)) {
1361
1742
  console.log(` ${chalk.gray('skip')} ${name}@${version}${chalk.green(' (already installed)')}`);
@@ -1364,11 +1745,23 @@ export async function installCommand(name, options = {}) {
1364
1745
  toInstall.push({ name, version });
1365
1746
  }
1366
1747
  }
1748
+ endProbe();
1367
1749
  if (toInstall.length > 0) {
1368
1750
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
1369
1751
  const npmCommand = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', specs.join(' '));
1370
- console.log(chalk.gray(` Running single batch: npm install -g (${specs.length} packages)`));
1371
- const batchSuccess = runCommand(npmCommand);
1752
+ try {
1753
+ await refreshGrant('pre-install batch');
1754
+ }
1755
+ catch (err) {
1756
+ // Outside the install try/finally: restore .npmrc / token, then fail loud.
1757
+ cleanup();
1758
+ console.log(chalk.red(`⛔ ${err instanceof Error ? err.message : String(err)}`));
1759
+ process.exit(1);
1760
+ }
1761
+ const endBatch = timingStart(`pre-install batch (${specs.length})`);
1762
+ console.log(chalk.cyan(` ► Installing ${specs.length} npm package(s) in one batch… (수십초 걸릴 수 있음, npm 진행이 아래에 표시됩니다)`));
1763
+ const batchSuccess = await runCommand(npmCommand);
1764
+ endBatch();
1372
1765
  // S3 — record the up-front batch (the single step where the beta npm tree
1373
1766
  // is actually resolved). 'fallback' means it failed and we retried below.
1374
1767
  recorder.record({
@@ -1393,7 +1786,7 @@ export async function installCommand(name, options = {}) {
1393
1786
  for (const entry of toInstall) {
1394
1787
  const pkgSpec = `${entry.name}@${entry.version}`;
1395
1788
  const cmd = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', pkgSpec);
1396
- const ok = runCommand(cmd);
1789
+ const ok = await runCommand(cmd);
1397
1790
  recorder.record({
1398
1791
  phase: 'pre-install-fallback',
1399
1792
  installer: 'npm-proxy',
@@ -1430,33 +1823,44 @@ export async function installCommand(name, options = {}) {
1430
1823
  try {
1431
1824
  // Auto-detect 모드: snapshot.install이 없으면 자동 판별
1432
1825
  const autoDetect = !snapshot.install;
1826
+ const endLayers = timingStart('installSnapshot (layers + kits)');
1433
1827
  const installResult = await installSnapshot(snapshot, 0, effectiveInstallers, {
1434
1828
  useProxy,
1435
1829
  autoDetect,
1436
1830
  sessionId,
1437
1831
  recorder,
1438
1832
  overrides,
1833
+ ...(grant ? { beforePhase: refreshGrant, skitSessionId: () => activeGrantToken ?? undefined } : {}),
1439
1834
  });
1440
1835
  result = installResult.result;
1836
+ endLayers();
1441
1837
  // ===== S1/FB — verify (and optionally converge) BEFORE cleanup =====
1442
1838
  // SSOT enforcement: the manifest pins (with FB overrides applied) are the
1443
1839
  // truth. Check the disk actually matches. This MUST run before the finally
1444
1840
  // restores .npmrc, because re-pinning via `npm install -g` needs the
1445
1841
  // registry-proxy config + token still in place.
1446
1842
  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' });
1843
+ // Re-pin ALL diverged packages in a round with a SINGLE `npm install -g`
1844
+ // (batched) and record it as one ledger step carrying every spec. This
1845
+ // collapses N sequential global installs per round into one npm spawn.
1846
+ const repinMany = (phase) => (pkgs) => {
1847
+ if (pkgs.length === 0)
1848
+ return;
1849
+ const specs = pkgs.map((p) => `${p.name}@${p.version}`);
1850
+ const cmd = `npm install -g ${specs.join(' ')}`;
1851
+ console.log(chalk.gray(` ► Re-pinning ${specs.length} package(s) in one batch (npm install -g … — 수십초 걸릴 수 있음)`));
1852
+ // Sync streaming: invoked from the pure/sync runConverge callback.
1853
+ const ok = runCommandSync(cmd);
1854
+ recorder.record({ phase, installer: 'npm-proxy', command: cmd, specs, result: ok ? 'ok' : 'failed' });
1453
1855
  };
1856
+ await refreshGrant('verify/converge');
1857
+ const endVerify = timingStart('verify/converge');
1454
1858
  if (converge) {
1455
1859
  // Default: re-pin EVERY package that diverged from its pin (self-healing).
1456
1860
  console.log('');
1457
1861
  console.log(chalk.cyan('► Verifying & converging install to manifest pins...'));
1458
1862
  verification = runConverge(allNpmPackages, overrides, {
1459
- install: repin('converge'),
1863
+ installMany: repinMany('converge'),
1460
1864
  readVersion: readInstalledNpmVersion,
1461
1865
  maxRounds: 3,
1462
1866
  });
@@ -1471,13 +1875,14 @@ export async function installCommand(name, options = {}) {
1471
1875
  console.log('');
1472
1876
  console.log(chalk.cyan(`► Holding ${Object.keys(pinTargets).length} pinned package(s) to the override version...`));
1473
1877
  runConverge(pinTargets, {}, {
1474
- install: repin('pin-converge'),
1878
+ installMany: repinMany('pin-converge'),
1475
1879
  readVersion: readInstalledNpmVersion,
1476
1880
  maxRounds: 3,
1477
1881
  });
1478
1882
  }
1479
1883
  verification = verifyInstallMap(effectiveNpmMap, { readVersion: readInstalledNpmVersion });
1480
1884
  }
1885
+ endVerify();
1481
1886
  }
1482
1887
  }
1483
1888
  catch (err) {
@@ -1569,6 +1974,16 @@ export async function installCommand(name, options = {}) {
1569
1974
  // verification shape { ok, verifiedCount, total, mismatches[{name,expected,got}] }.
1570
1975
  if (verification)
1571
1976
  ledger.verification = verification;
1977
+ // grant-unify P1 — grant provenance (no token). Absent in off mode.
1978
+ if (grant) {
1979
+ const st = grant;
1980
+ ledger.grant = {
1981
+ mode: grantMode,
1982
+ closureHash: st.current.closureHash.slice(0, 8),
1983
+ memberCount: st.current.members.length,
1984
+ reissues: st.reissues,
1985
+ };
1986
+ }
1572
1987
  // AC1.4 — fold the core-floor verdict too.
1573
1988
  ledger.coreFloor = {
1574
1989
  ok: coreFloor.ok,