@microwiseai/snapshot 0.8.0 → 0.9.1

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 (116) 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 +494 -145
  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/proxy-userconfig.d.ts +45 -0
  59. package/dist/lib/proxy-userconfig.d.ts.map +1 -0
  60. package/dist/lib/proxy-userconfig.js +94 -0
  61. package/dist/lib/proxy-userconfig.js.map +1 -0
  62. package/dist/lib/semver-range.d.ts +32 -0
  63. package/dist/lib/semver-range.d.ts.map +1 -0
  64. package/dist/lib/semver-range.js +243 -0
  65. package/dist/lib/semver-range.js.map +1 -0
  66. package/dist/lib/session.d.ts +5 -0
  67. package/dist/lib/session.d.ts.map +1 -1
  68. package/dist/lib/session.js +9 -0
  69. package/dist/lib/session.js.map +1 -1
  70. package/dist/lib/skit-adapter.d.ts.map +1 -1
  71. package/dist/lib/skit-adapter.js +26 -3
  72. package/dist/lib/skit-adapter.js.map +1 -1
  73. package/dist/lib/timing.d.ts +7 -0
  74. package/dist/lib/timing.d.ts.map +1 -0
  75. package/dist/lib/timing.js +14 -0
  76. package/dist/lib/timing.js.map +1 -0
  77. package/dist/lib/types.d.ts +3 -0
  78. package/dist/lib/types.d.ts.map +1 -1
  79. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.d.ts +25 -0
  80. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.d.ts.map +1 -0
  81. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.js +55 -0
  82. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/command-specs.js.map +1 -0
  83. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.d.ts +52 -0
  84. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.d.ts.map +1 -0
  85. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.js +62 -0
  86. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/config.js.map +1 -0
  87. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.d.ts +30 -0
  88. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.d.ts.map +1 -0
  89. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.js +39 -0
  90. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/detect.js.map +1 -0
  91. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.d.ts +35 -0
  92. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.d.ts.map +1 -0
  93. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.js +31 -0
  94. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/index.js.map +1 -0
  95. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.d.ts +42 -0
  96. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.d.ts.map +1 -0
  97. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.js +36 -0
  98. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/policy.js.map +1 -0
  99. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.d.ts +11 -0
  100. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.d.ts.map +1 -0
  101. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.js +46 -0
  102. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/report.js.map +1 -0
  103. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.d.ts +11 -0
  104. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.d.ts.map +1 -0
  105. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.js +81 -0
  106. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/semver.js.map +1 -0
  107. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.d.ts +29 -0
  108. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.d.ts.map +1 -0
  109. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.js +6 -0
  110. package/node_modules/@microwiseai/snapshot-downgrade-guard/dist/types.js.map +1 -0
  111. package/node_modules/@microwiseai/snapshot-downgrade-guard/package.json +55 -0
  112. package/package.json +5 -2
  113. package/dist/lib/transitive-resolver.d.ts +0 -89
  114. package/dist/lib/transitive-resolver.d.ts.map +0 -1
  115. package/dist/lib/transitive-resolver.js +0 -447
  116. package/dist/lib/transitive-resolver.js.map +0 -1
@@ -1,19 +1,23 @@
1
1
  import chalk from 'chalk';
2
- import { execSync } from 'child_process';
3
- import { writeFileSync, existsSync, readFileSync, unlinkSync } from 'fs';
2
+ import { execSync, spawn, spawnSync } from 'child_process';
3
+ import { existsSync, readFileSync } 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
+ import { activateProxyUserconfig } from '../lib/proxy-userconfig.js';
11
12
  import { installSkitPackage } from '../lib/skit-adapter.js';
12
13
  import { markLicenseChecked } from '@microwiseai/license';
13
- import { authorizeSession, packagesToSpecs } from '../lib/session.js';
14
+ import { authorizeSession, packagesToSpecs, maskSessionId } from '../lib/session.js';
15
+ import { readGrantMode, readGrantTimeoutMs, requestSnapshotGrant, GrantState, GrantError, checkGrantCoverage, findRootMemberVersion, formatGrantLog, splitSnapshotSpec, resolveInstallVersion, } from '../lib/grant.js';
14
16
  import { parallelInstall, recordToEntries } from '@microwiseai/snapshot-parallel';
17
+ import { detectDowngrades, resolveDowngradePolicy, readOneShotOverride, getMachineDowngradeMode, formatDowngradeReport, extractGlobalInstallSpecs, } from '@microwiseai/snapshot-downgrade-guard';
15
18
  import { dedupWithReport, dedupInstallBlock, recordInstalledSpecs, recordReferences, computeReplacePlan, applyReplaceHistory, } from '@microwiseai/snapshot-dedup';
16
19
  import { createInterface } from 'readline';
20
+ import { timingStart } from '../lib/timing.js';
17
21
  import { StepRecorder, buildLedger, writeLedger, queryDependencyTree, } from '../lib/ledger.js';
18
22
  import { coreFloorAssert, formatCoreFloorOffenders, DEFAULT_CORE_FLOOR_PKG, DEFAULT_CORE_FLOOR_MIN_VERSION, } from '../lib/core-floor.js';
19
23
  import { verifyInstallMap, runConverge, readInstalledNpmVersion, formatVerificationFailure, } from '../lib/install-verify.js';
@@ -89,86 +93,19 @@ const DEFAULT_INSTALLERS = {
89
93
  uninstall: 'npm uninstall -g {pkg}', // verified: `npm uninstall`
90
94
  },
91
95
  };
96
+ // ============================================================
97
+ // npm userconfig for registry-proxy (temp file, ~/.npmrc is never written)
98
+ // ============================================================
92
99
  /**
93
- * Configure .npmrc for registry-proxy with session token
100
+ * Point npm (and skit) at a temp userconfig carrying the proxy scope lines and,
101
+ * when authenticated, the env-referenced auth line. See lib/proxy-userconfig.ts.
94
102
  */
95
103
  function configureNpmrcForProxy(scopes) {
96
- const npmrcPath = join(homedir(), '.npmrc');
97
- let originalContent = null;
98
- // Backup existing .npmrc
99
- if (existsSync(npmrcPath)) {
100
- originalContent = readFileSync(npmrcPath, 'utf-8');
101
- }
102
- // Build new .npmrc content
103
- const proxyHost = REGISTRY_PROXY_URL.replace('https://', '');
104
- const lines = [];
105
- // Keep existing content that's not related to our scopes
106
- if (originalContent) {
107
- const existingLines = originalContent.split('\n');
108
- for (const line of existingLines) {
109
- // Skip lines related to our scopes or proxy
110
- const isOurScope = scopes.some(s => line.includes(`${s}:registry`));
111
- const isProxyAuth = line.includes(proxyHost);
112
- if (!isOurScope && !isProxyAuth && line.trim()) {
113
- lines.push(line);
114
- }
115
- }
116
- }
117
- // Add proxy registry config for each scope
118
- for (const scope of scopes) {
119
- lines.push(`${scope}:registry=${REGISTRY_PROXY_URL}/`);
120
- }
121
- // 환경변수 참조 — 토큰 직접 안 넣음 (npm이 ${ENV_VAR} 구문을 해석)
122
- lines.push(`//${proxyHost}/:_authToken=\${IST_SESSION_TOKEN}`);
123
- writeFileSync(npmrcPath, lines.join('\n') + '\n');
124
- return { path: npmrcPath, content: originalContent };
125
- }
126
- /**
127
- * Restore original .npmrc
128
- */
129
- function restoreNpmrc(backup) {
130
- if (backup.content === null) {
131
- // Original didn't exist, remove it
132
- if (existsSync(backup.path)) {
133
- unlinkSync(backup.path);
134
- }
135
- }
136
- else {
137
- // Restore original content
138
- writeFileSync(backup.path, backup.content);
139
- }
104
+ return activateProxyUserconfig({ scopes, proxyUrl: REGISTRY_PROXY_URL, withAuth: true });
140
105
  }
141
- /**
142
- * Configure .npmrc for registry-proxy WITHOUT auth token (anonymous access)
143
- */
106
+ /** Same, WITHOUT the auth line (anonymous access). */
144
107
  function configureNpmrcForProxyAnonymous(scopes) {
145
- const npmrcPath = join(homedir(), '.npmrc');
146
- let originalContent = null;
147
- // Backup existing .npmrc
148
- if (existsSync(npmrcPath)) {
149
- originalContent = readFileSync(npmrcPath, 'utf-8');
150
- }
151
- // Build new .npmrc content
152
- const proxyHost = REGISTRY_PROXY_URL.replace('https://', '');
153
- const lines = [];
154
- // Keep existing content that's not related to our scopes
155
- if (originalContent) {
156
- const existingLines = originalContent.split('\n');
157
- for (const line of existingLines) {
158
- // Skip lines related to our scopes or proxy
159
- const isOurScope = scopes.some(s => line.includes(`${s}:registry`));
160
- const isProxyAuth = line.includes(proxyHost);
161
- if (!isOurScope && !isProxyAuth && line.trim()) {
162
- lines.push(line);
163
- }
164
- }
165
- }
166
- // Add proxy registry config for each scope (NO auth token)
167
- for (const scope of scopes) {
168
- lines.push(`${scope}:registry=${REGISTRY_PROXY_URL}/`);
169
- }
170
- writeFileSync(npmrcPath, lines.join('\n') + '\n');
171
- return { path: npmrcPath, content: originalContent };
108
+ return activateProxyUserconfig({ scopes, proxyUrl: REGISTRY_PROXY_URL, withAuth: false });
172
109
  }
173
110
  /**
174
111
  * Extract scopes from packages
@@ -198,27 +135,148 @@ function commandExists(cmd) {
198
135
  // Captures the failure reason of the most recent runCommand() call so callers
199
136
  // can surface it (debugging visibility). Undefined when the last call succeeded.
200
137
  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;
138
+ // ============================================================
139
+ // Downgrade guard — npm-exec choke point (defense in depth)
140
+ // ============================================================
141
+ // EVERY global install funnels through runCommand / runCommandSync (the spawn
142
+ // wrappers) — including installCommand's batches, a direct installSnapshot()
143
+ // library call, extends recursion, and parallelInstall's per-spec callback.
144
+ // Detecting downgrades HERE, at that single execution boundary, means NO entry
145
+ // point can bypass the guard (closes the lib.ts public-export bypass). This is a
146
+ // SECOND layer; installCommand's earlier pre-install guard stays as-is.
147
+ //
148
+ // Policy threading: installCommand resolves the mode once and publishes it via
149
+ // setActiveDowngradeMode(); this boundary reuses it (no recompute, no duplicate
150
+ // report). With no upstream context (direct library call), the boundary
151
+ // self-resolves from env (SNAPSHOT_ALLOW_LOCAL) + machine config, defaulting to
152
+ // the safe 'refuse'.
153
+ export class DowngradeRefusedError extends Error {
154
+ constructor(message) {
155
+ super(message);
156
+ this.name = 'DowngradeRefusedError';
208
157
  }
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));
158
+ }
159
+ let activeDowngradeMode;
160
+ /**
161
+ * Publish (or clear) the downgrade policy resolved upstream by installCommand so
162
+ * the exec boundary reuses it. Exported as a test seam.
163
+ */
164
+ export function setActiveDowngradeMode(mode) {
165
+ activeDowngradeMode = mode;
166
+ }
167
+ /**
168
+ * Enforce the downgrade policy for a global-install command about to execute.
169
+ * No-op unless the command is a global install carrying versioned specs AND at
170
+ * least one spec would downgrade an already-installed global package. On a
171
+ * 'refuse' verdict it throws DowngradeRefusedError BEFORE spawn (zero partial
172
+ * install); 'warn' logs and proceeds; 'allow' proceeds silently. Pure detection
173
+ * is delegated to @microwiseai/snapshot-downgrade-guard.
174
+ */
175
+ function assertNoRefusedDowngrade(command) {
176
+ const specs = extractGlobalInstallSpecs(command);
177
+ if (Object.keys(specs).length === 0)
178
+ return;
179
+ const { wouldDowngrade, offenders } = detectDowngrades(specs, {
180
+ readVersion: readInstalledNpmVersion,
181
+ });
182
+ if (!wouldDowngrade)
183
+ return;
184
+ // Reuse the upstream-resolved mode when present (installCommand path); else
185
+ // self-resolve for the context-free direct library-call path.
186
+ const threaded = activeDowngradeMode !== undefined;
187
+ const mode = threaded
188
+ ? activeDowngradeMode
189
+ : resolveDowngradePolicy({
190
+ override: readOneShotOverride({ env: process.env }),
191
+ machine: getMachineDowngradeMode(),
192
+ // snapshot.json policy is not available at this boundary; the default
193
+ // 'refuse' keeps the direct-call path on the safe side.
194
+ });
195
+ if (mode === 'refuse') {
196
+ // Only the self-resolved (direct-call) path reports here — the installCommand
197
+ // path already printed upstream and cannot reach a refuse at this boundary
198
+ // (it exits during pre-install), so this avoids duplicate output.
199
+ if (!threaded) {
200
+ console.log('');
201
+ console.log(chalk.red(formatDowngradeReport(offenders, mode)));
219
202
  }
203
+ throw new DowngradeRefusedError(formatDowngradeReport(offenders, mode));
204
+ }
205
+ if (mode === 'warn' && !threaded) {
206
+ console.log('');
207
+ console.log(chalk.yellow(formatDowngradeReport(offenders, mode)));
208
+ }
209
+ // mode === 'allow' → proceed.
210
+ }
211
+ /**
212
+ * Run a command with FULLY LIVE output (spawn, async).
213
+ *
214
+ * Why spawn+async (not execSync): npm writes its progress bar to STDERR, and the
215
+ * previous `execSync(stdio:['inherit','inherit','pipe'])` piped stderr — which
216
+ * (a) hid npm's live progress (looked "frozen" for tens of seconds) and
217
+ * (b) blocked the event loop so no JS spinner could ever turn. Inheriting all
218
+ * three streams lets npm's own progress render live to the terminal while the
219
+ * event loop stays free. See install-speed-visibility work (B).
220
+ *
221
+ * On failure we can no longer capture the stderr text (it went straight to the
222
+ * terminal, so the user already SAW it) — `lastCommandError` carries the exit
223
+ * code instead.
224
+ */
225
+ // Exported as a test seam (unit-tested in install.runcommand.test.ts).
226
+ export function runCommand(command) {
227
+ // Depth-defense: abort BEFORE spawn if this global install would downgrade and
228
+ // policy is refuse. Throws DowngradeRefusedError (no partial install).
229
+ assertNoRefusedDowngrade(command);
230
+ lastCommandError = undefined;
231
+ return new Promise((resolve) => {
232
+ // shell:true so the plain command string (e.g. "npm install -g a@1 b@2")
233
+ // is parsed exactly as it was under execSync.
234
+ const child = spawn(command, { stdio: 'inherit', shell: true });
235
+ child.on('error', (err) => {
236
+ lastCommandError = `spawn error — ${err.message}`;
237
+ console.log(chalk.red(` Command failed to start: ${command}`));
238
+ console.log(chalk.red(err.message));
239
+ resolve(false);
240
+ });
241
+ child.on('close', (code) => {
242
+ if (code === 0) {
243
+ resolve(true);
244
+ return;
245
+ }
246
+ lastCommandError = `exit code ${code ?? 'unknown'}`;
247
+ console.log(chalk.red(` Command failed (exit code ${code ?? 'unknown'}): ${command}`));
248
+ resolve(false);
249
+ });
250
+ });
251
+ }
252
+ /**
253
+ * Synchronous streaming variant — used ONLY by the converge re-pin callback,
254
+ * which is invoked from the pure/sync `runConverge` (lib/install-verify.ts).
255
+ * Keeping this sync avoids making `runConverge` async (which would ripple into
256
+ * its unit-test suite and break its pure-function contract). `spawnSync` with
257
+ * inherited stdio still streams npm's live progress to the terminal (the child
258
+ * writes directly to the inherited fd), so the converge phase no longer looks
259
+ * frozen either — it only blocks the JS event loop for its (batched, rare)
260
+ * duration.
261
+ */
262
+ // Exported as a test seam (unit-tested in install.runcommand.test.ts).
263
+ export function runCommandSync(command) {
264
+ // Depth-defense: abort BEFORE spawn if this global install would downgrade and
265
+ // policy is refuse. Throws DowngradeRefusedError (no partial install).
266
+ assertNoRefusedDowngrade(command);
267
+ lastCommandError = undefined;
268
+ const res = spawnSync(command, { stdio: 'inherit', shell: true });
269
+ if (res.error) {
270
+ lastCommandError = `spawn error — ${res.error.message}`;
271
+ console.log(chalk.red(` Command failed to start: ${command}`));
272
+ console.log(chalk.red(res.error.message));
220
273
  return false;
221
274
  }
275
+ if (res.status === 0)
276
+ return true;
277
+ lastCommandError = `exit code ${res.status ?? 'unknown'}`;
278
+ console.log(chalk.red(` Command failed (exit code ${res.status ?? 'unknown'}): ${command}`));
279
+ return false;
222
280
  }
223
281
  /**
224
282
  * Check if a skit package is already installed with the specified version
@@ -264,8 +322,23 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId, re
264
322
  const total = packageEntries.length;
265
323
  console.log('');
266
324
  console.log(chalk.yellow(`${indent}=== ${snapshotName}: ${total} skits ===`));
267
- // Prepare install options with sessionId if available
268
- const skitOptions = sessionId ? { sessionId } : {};
325
+ // Prepare install options with sessionId if available. Forward onProgress so
326
+ // the skit lib's live install messages reach the terminal (skit-adapter.ts
327
+ // already forwards options.onProgress into lib.installSkit) — otherwise each
328
+ // skit install is silent until it completes.
329
+ // grant-unify P1: a function source is read PER KIT, right before the skit
330
+ // call, so a re-issued grant token is picked up (grant mode passes the istsg_
331
+ // token as the skit session → X-Session-Id on the kit tarball download).
332
+ // A plain string (legacy authorizeSession id) behaves exactly as before.
333
+ const skitOptionsFor = () => {
334
+ const sid = typeof sessionId === 'function' ? sessionId() : sessionId;
335
+ return {
336
+ ...(sid ? { sessionId: sid } : {}),
337
+ onProgress: (message) => {
338
+ console.log(chalk.gray(`${indent} ${message}`));
339
+ },
340
+ };
341
+ };
269
342
  // Install packages in parallel using snapshot-parallel
270
343
  const entries = recordToEntries(packages);
271
344
  const parallelResult = await parallelInstall(entries, async (entry) => {
@@ -273,7 +346,11 @@ async function installSkitPackages(packages, snapshotName, indent, sessionId, re
273
346
  if (isSkitInstalled(entry.name, entry.version)) {
274
347
  return { success: true, skipped: true };
275
348
  }
276
- const installResult = await installSkitPackage(entry.name, entry.version, skitOptions);
349
+ // Per-skit start log so a slow single install doesn't look frozen.
350
+ console.log(chalk.gray(`${indent} installing ${entry.name}@${entry.version}…`));
351
+ const endKit = timingStart(`kit ${entry.name}@${entry.version}`);
352
+ const installResult = await installSkitPackage(entry.name, entry.version, skitOptionsFor());
353
+ endKit();
277
354
  return {
278
355
  success: installResult.success,
279
356
  error: installResult.error,
@@ -333,7 +410,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
333
410
  if (!commandExists(cmdName)) {
334
411
  console.log(chalk.yellow(`${indent}${installerName} not found. Attempting auto-install...`));
335
412
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
336
- if (runCommand(installerConfig.hint)) {
413
+ if (await runCommand(installerConfig.hint)) {
337
414
  console.log(chalk.green(`${indent} ✓ ${installerName} installed successfully`));
338
415
  }
339
416
  else {
@@ -353,6 +430,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
353
430
  if (isNpmInstaller) {
354
431
  // Batch install: collect all not-yet-installed packages and run a single npm install -g
355
432
  const toInstall = [];
433
+ const endProbe = timingStart(`layer already-installed probes (${entries.length})`);
356
434
  for (const entry of entries) {
357
435
  if (isNpmGlobalInstalled(entry.name, entry.version)) {
358
436
  console.log(`${indent}${chalk.gray('skip')} ${entry.name}@${entry.version}${chalk.green(' (already installed)')}`);
@@ -362,11 +440,12 @@ async function installPackagesByType(packages, installerName, installers, snapsh
362
440
  toInstall.push(entry);
363
441
  }
364
442
  }
443
+ endProbe();
365
444
  if (toInstall.length > 0) {
366
445
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
367
446
  const batchCommand = installerConfig.command.replace('{pkg}', specs.join(' '));
368
447
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
369
- const success = runCommand(batchCommand);
448
+ const success = await runCommand(batchCommand);
370
449
  // S3 — record the batch step (ok, or 'fallback' if it failed → retried below).
371
450
  recorder?.record({
372
451
  phase: 'layer-batch',
@@ -389,7 +468,7 @@ async function installPackagesByType(packages, installerName, installers, snapsh
389
468
  for (const entry of toInstall) {
390
469
  const pkgSpec = `${entry.name}@${entry.version}`;
391
470
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
392
- const ok = runCommand(command);
471
+ const ok = await runCommand(command);
393
472
  recorder?.record({
394
473
  phase: 'layer-fallback',
395
474
  installer: installerName,
@@ -410,14 +489,18 @@ async function installPackagesByType(packages, installerName, installers, snapsh
410
489
  }
411
490
  }
412
491
  else {
413
- // Non-npm installer: use parallel install as before
492
+ // Non-npm installer: run SERIALLY (concurrency 1). runCommand now spawns with
493
+ // stdio:'inherit', so concurrent children would interleave output on the same
494
+ // terminal AND race the module-global lastCommandError (mis-attributing the
495
+ // failure reason). Before the B change this path used sync execSync and was
496
+ // therefore effectively serial; concurrency:1 restores that (zero regression).
414
497
  const parallelResult = await parallelInstall(entries, async (entry) => {
415
498
  const pkgSpec = `${entry.name}@${entry.version}`;
416
499
  const command = installerConfig.command.replace('{pkg}', pkgSpec);
417
- const success = runCommand(command);
500
+ const success = await runCommand(command);
418
501
  return { success, error: success ? undefined : `Command failed: ${command}` };
419
502
  }, {
420
- concurrency: 4,
503
+ concurrency: 1,
421
504
  onProgress: (event) => {
422
505
  const { result: r, current, total: t } = event;
423
506
  const progress = `[${current}/${t}]`;
@@ -463,6 +546,32 @@ function applySnapshotOverrides(snapshot, overrides) {
463
546
  }
464
547
  return next;
465
548
  }
549
+ // ============================================================
550
+ // Grant transport token (grant-unify P1, SNAPSHOT_GRANT_MODE)
551
+ // ============================================================
552
+ // When a snapshot grant is active, EVERY registry-proxy snapshot fetch (incl.
553
+ // extends bases) carries the istsg_ grant token explicitly — it takes precedence
554
+ // over the license key / IST token resolution inside fetchSnapshotFromRepo.
555
+ // null (always, in SNAPSHOT_GRANT_MODE=off) → the legacy call shape is kept.
556
+ let activeGrantToken = null;
557
+ /** Fetch an extends base snapshot (grant token when a grant is active). */
558
+ function fetchBaseSnapshot(spec) {
559
+ return activeGrantToken ? fetchSnapshotFromRepo(spec, activeGrantToken) : fetchSnapshotFromRepo(spec);
560
+ }
561
+ /**
562
+ * Collect the extends chain of a snapshot as exact {name, version} specs (the
563
+ * ancestor generics). Grant mode only — used for the closure coverage check.
564
+ */
565
+ async function collectExtendsChain(snapshot) {
566
+ const chain = [];
567
+ let cur = snapshot;
568
+ for (let depth = 0; cur.extends && depth < 16; depth++) {
569
+ const base = await fetchBaseSnapshot(cur.extends);
570
+ chain.push({ name: base.name, version: base.version });
571
+ cur = base;
572
+ }
573
+ return chain;
574
+ }
466
575
  /**
467
576
  * FB — the subset of pin overrides that actually apply to this install (i.e. the
468
577
  * overridden package IS declared in the effective npm map). These are the
@@ -502,7 +611,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
502
611
  console.log('');
503
612
  console.log(chalk.cyan(`${indent}► Extends: ${snapshot.extends}`));
504
613
  try {
505
- const baseSnapshot = await fetchSnapshotFromRepo(snapshot.extends);
614
+ const baseSnapshot = await fetchBaseSnapshot(snapshot.extends);
506
615
  console.log(chalk.gray(`${indent} Installing base snapshot...`));
507
616
  const baseResult = await installSnapshot(baseSnapshot, depth + 1, installers, options);
508
617
  result.installed.push(...baseResult.result.installed);
@@ -589,21 +698,22 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
589
698
  const resolved = await resolveSnapshotDependencies(snapshot, { skipRegistry: true });
590
699
  // skit 패키지 설치 (skit-adapter 사용: lib 우선, CLI fallback)
591
700
  if (Object.keys(resolved.skit).length > 0) {
592
- const skitResult = await installSkitPackages(resolved.skit, snapshot.name, indent, options.sessionId, options.recorder);
701
+ await options.beforePhase?.(`kit:${snapshot.name}`);
702
+ const skitResult = await installSkitPackages(resolved.skit, snapshot.name, indent, (options.skitSessionId ?? options.sessionId), options.recorder);
593
703
  result.installed.push(...skitResult.installed);
594
704
  result.failed.push(...skitResult.failed);
595
705
  }
596
706
  // npm 패키지 설치 (proxy 사용 시 npm-proxy, 아니면 glpkg)
597
707
  if (Object.keys(resolved.npm).length > 0) {
598
708
  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);
709
+ const npmResult = await installPackagesByType(resolved.npm, npmInstaller, installers, snapshot.name, indent, 'npm packages', (options.skitSessionId ?? options.sessionId), options.recorder);
600
710
  result.installed.push(...npmResult.installed);
601
711
  result.failed.push(...npmResult.failed);
602
712
  }
603
713
  // generic 패키지 설치 (glpkg)
604
714
  if (Object.keys(resolved.generic).length > 0) {
605
715
  const genericInstaller = options.useProxy ? 'npm-proxy' : 'glpkg';
606
- const genericResult = await installPackagesByType(resolved.generic, genericInstaller, installers, snapshot.name, indent, 'packages', options.sessionId, options.recorder);
716
+ const genericResult = await installPackagesByType(resolved.generic, genericInstaller, installers, snapshot.name, indent, 'packages', (options.skitSessionId ?? options.sessionId), options.recorder);
607
717
  result.installed.push(...genericResult.installed);
608
718
  result.failed.push(...genericResult.failed);
609
719
  }
@@ -620,7 +730,8 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
620
730
  continue;
621
731
  // Skit packages: use skit-adapter (lib 우선, CLI fallback)
622
732
  if (installerName === 'skit') {
623
- const skitResult = await installSkitPackages(packages, snapshot.name, indent, options.sessionId, options.recorder);
733
+ await options.beforePhase?.(`kit:${snapshot.name}`);
734
+ const skitResult = await installSkitPackages(packages, snapshot.name, indent, (options.skitSessionId ?? options.sessionId), options.recorder);
624
735
  result.installed.push(...skitResult.installed);
625
736
  result.failed.push(...skitResult.failed);
626
737
  continue;
@@ -639,7 +750,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
639
750
  if (!commandExists(cmdName)) {
640
751
  console.log(chalk.yellow(`${indent}${installerName} not found. Attempting auto-install...`));
641
752
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
642
- if (runCommand(installerConfig.hint)) {
753
+ if (await runCommand(installerConfig.hint)) {
643
754
  console.log(chalk.green(`${indent} ✓ ${installerName} installed successfully`));
644
755
  }
645
756
  else {
@@ -660,6 +771,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
660
771
  if (isNpmType) {
661
772
  // Batch install: collect all not-yet-installed packages and run a single npm install -g
662
773
  const toInstall = [];
774
+ const endProbe = timingStart(`layer already-installed probes (${installEntries.length})`);
663
775
  for (const entry of installEntries) {
664
776
  if (isNpmGlobalInstalled(entry.name, entry.version)) {
665
777
  console.log(`${indent}${chalk.gray('skip')} ${entry.name}@${entry.version}${chalk.green(' (already installed)')}`);
@@ -669,11 +781,12 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
669
781
  toInstall.push(entry);
670
782
  }
671
783
  }
784
+ endProbe();
672
785
  if (toInstall.length > 0) {
673
786
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
674
787
  const batchCommand = installCfg.command.replace('{pkg}', specs.join(' '));
675
788
  console.log(chalk.gray(`${indent} Running batch: npm install -g (${specs.length} packages)`));
676
- const success = runCommand(batchCommand);
789
+ const success = await runCommand(batchCommand);
677
790
  // S3 — record the batch step (ok, or 'fallback' if it failed → retried below).
678
791
  options.recorder?.record({
679
792
  phase: 'layer-batch',
@@ -696,7 +809,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
696
809
  for (const entry of toInstall) {
697
810
  const pkgSpec = `${entry.name}@${entry.version}`;
698
811
  const command = installCfg.command.replace('{pkg}', pkgSpec);
699
- const ok = runCommand(command);
812
+ const ok = await runCommand(command);
700
813
  options.recorder?.record({
701
814
  phase: 'layer-fallback',
702
815
  installer: installerName,
@@ -717,14 +830,16 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
717
830
  }
718
831
  }
719
832
  else {
720
- // Non-npm installer: use parallel install as before
833
+ // Non-npm installer: run SERIALLY (concurrency 1) — see rationale above:
834
+ // spawn(stdio:'inherit') children would interleave terminal output and
835
+ // race the global lastCommandError. Restores the pre-B serial behavior.
721
836
  const parallelResult = await parallelInstall(installEntries, async (entry) => {
722
837
  const pkgSpec = `${entry.name}@${entry.version}`;
723
838
  const command = installCfg.command.replace('{pkg}', pkgSpec);
724
- const success = runCommand(command);
839
+ const success = await runCommand(command);
725
840
  return { success, error: success ? undefined : `Command failed: ${command}` };
726
841
  }, {
727
- concurrency: 4,
842
+ concurrency: 1,
728
843
  onProgress: (event) => {
729
844
  const { result: r, current, total: t } = event;
730
845
  const progress = `[${current}/${t}]`;
@@ -759,7 +874,7 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
759
874
  if (!commandExists(cmdName)) {
760
875
  console.log(chalk.yellow(`${indent}glpkg not found. Attempting auto-install...`));
761
876
  console.log(chalk.gray(`${indent} Running: ${installerConfig.hint}`));
762
- if (runCommand(installerConfig.hint)) {
877
+ if (await runCommand(installerConfig.hint)) {
763
878
  console.log(chalk.green(`${indent} ✓ glpkg installed successfully`));
764
879
  }
765
880
  else {
@@ -774,13 +889,15 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
774
889
  else {
775
890
  const legacyEntries = recordToEntries(snapshot.packages);
776
891
  const legacyCfg = installerConfig; // capture for closure
892
+ // Legacy glpkg packages: run SERIALLY (concurrency 1) — see rationale above
893
+ // (spawn:'inherit' output interleave + global lastCommandError race).
777
894
  const parallelResult = await parallelInstall(legacyEntries, async (entry) => {
778
895
  const pkgSpec = `${entry.name}@${entry.version}`;
779
896
  const command = legacyCfg.command.replace('{pkg}', pkgSpec);
780
- const success = runCommand(command);
897
+ const success = await runCommand(command);
781
898
  return { success, error: success ? undefined : `Command failed: ${command}` };
782
899
  }, {
783
- concurrency: 4,
900
+ concurrency: 1,
784
901
  onProgress: (event) => {
785
902
  const { result: r, current, total: t } = event;
786
903
  const progress = `[${current}/${t}]`;
@@ -806,7 +923,8 @@ export async function installSnapshot(snapshot, depth = 0, parentInstallers = {}
806
923
  }
807
924
  // Legacy: skits (use skit-adapter: lib 우선, CLI fallback)
808
925
  if (snapshot.skits && Object.keys(snapshot.skits).length > 0) {
809
- const skitResult = await installSkitPackages(snapshot.skits, snapshot.name, indent, options.sessionId, options.recorder);
926
+ await options.beforePhase?.(`kit:${snapshot.name}`);
927
+ const skitResult = await installSkitPackages(snapshot.skits, snapshot.name, indent, (options.skitSessionId ?? options.sessionId), options.recorder);
810
928
  result.installed.push(...skitResult.installed);
811
929
  result.failed.push(...skitResult.failed);
812
930
  }
@@ -846,7 +964,7 @@ async function collectAllNpmPackages(snapshot, useProxy = true) {
846
964
  // Recurse into extends chain
847
965
  if (snapshot.extends) {
848
966
  try {
849
- const baseSnapshot = await fetchSnapshotFromRepo(snapshot.extends);
967
+ const baseSnapshot = await fetchBaseSnapshot(snapshot.extends);
850
968
  const baseNpmPackages = await collectAllNpmPackages(baseSnapshot, useProxy);
851
969
  // Base packages: don't override current snapshot's versions
852
970
  for (const [name, version] of Object.entries(baseNpmPackages)) {
@@ -883,7 +1001,7 @@ export async function collectAllPackages(snapshot) {
883
1001
  // Recurse into extends chain
884
1002
  if (snapshot.extends) {
885
1003
  try {
886
- const baseSnapshot = await fetchSnapshotFromRepo(snapshot.extends);
1004
+ const baseSnapshot = await fetchBaseSnapshot(snapshot.extends);
887
1005
  const basePackages = await collectAllPackages(baseSnapshot);
888
1006
  Object.assign(allPackages, basePackages);
889
1007
  }
@@ -996,7 +1114,7 @@ async function runReplaceMode(snapshotName, newSet, options, installers) {
996
1114
  continue;
997
1115
  }
998
1116
  console.log(chalk.gray(` Removing ${item.name}...`));
999
- if (runCommand(cmd)) {
1117
+ if (await runCommand(cmd)) {
1000
1118
  console.log(` ${item.name}${chalk.green(' ✓ removed')}`);
1001
1119
  removed.push(item);
1002
1120
  }
@@ -1057,6 +1175,9 @@ async function previewInstall(snapshot, options) {
1057
1175
  console.log(chalk.gray('Dry-run complete: no installs, no uninstalls, no .npmrc or history changes.'));
1058
1176
  }
1059
1177
  export async function installCommand(name, options = {}) {
1178
+ // Start from a clean downgrade-policy context; the pre-install guard publishes
1179
+ // the resolved mode below, and cleanup() clears it on every exit path.
1180
+ setActiveDowngradeMode(undefined);
1060
1181
  console.log(chalk.blue.bold(`
1061
1182
  ╔══════════════════════════════════════════════════════════════╗
1062
1183
  ║ Snapshot Installer ║
@@ -1106,11 +1227,90 @@ export async function installCommand(name, options = {}) {
1106
1227
  console.log(chalk.yellow('⚠') + ' No auth token — anonymous access');
1107
1228
  }
1108
1229
  const useProxy = true;
1230
+ // ========== Snapshot grant (grant-unify P1, SNAPSHOT_GRANT_MODE) ==========
1231
+ // off (default): nothing below runs (grant stays null) — legacy path.
1232
+ // prefer/require: request ONE istsg_ grant BEFORE fetching snapshot.json, since
1233
+ // the root generic itself is a closure member the proxy may gate. The grant is
1234
+ // always for an EXACT version: none/'latest' is resolved first with the proxy
1235
+ // rule (highest stable, prereleases ignored — same as the legacy path's
1236
+ // 'latest'); license-api's own 'latest' (= newest upload) is never used. prefer drops
1237
+ // the grant on any grant failure and runs the whole install on the legacy
1238
+ // path; require is fail-loud. dry-run + prefer skips the grant (zero side
1239
+ // effects); dry-run + require still needs it to fetch the snapshot.
1240
+ activeGrantToken = null;
1241
+ const grantMode = readGrantMode(process.env, (m) => console.log(chalk.yellow('⚠ ' + m)));
1242
+ const { name: snapshotBaseName, version: requestedVersion } = splitSnapshotSpec(name);
1243
+ let grant = null;
1244
+ let grantEnvActive = false;
1245
+ /** Grant failure policy: require → exit 1; prefer → warn, drop grant, legacy path. */
1246
+ const onGrantFailure = (err) => {
1247
+ const msg = err instanceof Error ? err.message : String(err);
1248
+ if (grantMode === 'require') {
1249
+ console.log(chalk.red(`⛔ Snapshot grant failed (SNAPSHOT_GRANT_MODE=require): ${msg}`));
1250
+ process.exit(1);
1251
+ }
1252
+ console.log(chalk.yellow(`⚠ Snapshot grant unavailable — continuing on the legacy path: ${msg}`));
1253
+ grant = null;
1254
+ activeGrantToken = null;
1255
+ };
1256
+ if (grantMode !== 'off' && !(options.dryRun && grantMode === 'prefer')) {
1257
+ const timeoutMs = readGrantTimeoutMs();
1258
+ let grantVersion = null;
1259
+ try {
1260
+ grantVersion = await resolveInstallVersion(snapshotBaseName, requestedVersion, {
1261
+ registryUrl: REGISTRY_PROXY_URL,
1262
+ timeoutMs,
1263
+ });
1264
+ }
1265
+ catch (err) {
1266
+ onGrantFailure(err);
1267
+ }
1268
+ if (grantVersion) {
1269
+ grant = new GrantState({ snapshot: snapshotBaseName, version: grantVersion, authToken }, {
1270
+ request: (req) => requestSnapshotGrant(req, { licenseApiUrl: LICENSE_API_URL, timeoutMs }),
1271
+ onToken: (token) => {
1272
+ activeGrantToken = token;
1273
+ if (grantEnvActive)
1274
+ process.env.IST_SESSION_TOKEN = token;
1275
+ },
1276
+ });
1277
+ console.log(chalk.gray(`Requesting snapshot grant (${grantMode}) for ${snapshotBaseName}@${grantVersion}...`));
1278
+ try {
1279
+ const g = await grant.issue();
1280
+ // The closure root (when present) must be exactly the version we asked for.
1281
+ const rootVersion = findRootMemberVersion(g.members, snapshotBaseName);
1282
+ if (rootVersion && rootVersion !== grantVersion) {
1283
+ throw new GrantError('version-mismatch', `grant closure root ${snapshotBaseName}@${rootVersion} differs from the requested ${grantVersion}`);
1284
+ }
1285
+ console.log(chalk.green('✓') + ' ' + formatGrantLog(grantMode, g));
1286
+ }
1287
+ catch (err) {
1288
+ onGrantFailure(err);
1289
+ }
1290
+ }
1291
+ }
1292
+ // ==========================================================================
1109
1293
  // Fetch snapshot first — access check depends on snapshot.json content
1110
1294
  console.log(chalk.gray('Fetching snapshot...'));
1111
1295
  let snapshot;
1112
1296
  try {
1113
- snapshot = await fetchSnapshotFromRepo(name, authToken);
1297
+ if (grant) {
1298
+ // Grant mode: fetch exactly the granted version with the grant token.
1299
+ const grantVersion = grant.version;
1300
+ try {
1301
+ snapshot = await fetchSnapshotFromRepo(`${snapshotBaseName}@${grantVersion}`, activeGrantToken);
1302
+ if (snapshot.version !== grantVersion) {
1303
+ throw new GrantError('version-mismatch', `snapshot ${snapshotBaseName}@${snapshot.version} does not match the granted version ${grantVersion}`);
1304
+ }
1305
+ }
1306
+ catch (err) {
1307
+ onGrantFailure(err);
1308
+ snapshot = await fetchSnapshotFromRepo(name, authToken);
1309
+ }
1310
+ }
1311
+ else {
1312
+ snapshot = await fetchSnapshotFromRepo(name, authToken);
1313
+ }
1114
1314
  }
1115
1315
  catch (error) {
1116
1316
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -1180,8 +1380,13 @@ export async function installCommand(name, options = {}) {
1180
1380
  const isAuthenticatedAccess = accessField === 'authenticated';
1181
1381
  const isLicensedAccess = !!accessField && typeof accessField === 'object' && accessField.type === 'licensed';
1182
1382
  // --- IST Login Check ---
1183
- const istCredentials = getIstCredentials();
1184
- if (istCredentials?.userId) {
1383
+ // Single auth verdict: isLoggedIn() = the same credential SSOT that resolved
1384
+ // authToken above (env IST_SESSION_TOKEN or ~/.ist/credentials.json .token,
1385
+ // locally unexpired) and that the 'IST Credentials' prerequisite uses. The old
1386
+ // check read credentials.json .userId, so a token-authenticated install could
1387
+ // print "✓ Auth: IST token" AND "⚠ No IST login" in the same run.
1388
+ const istCredentials = getIstCredentials(); // telemetry identity only (userId/email)
1389
+ if (isLoggedIn()) {
1185
1390
  console.log(chalk.green('✓') + ' IST login verified');
1186
1391
  }
1187
1392
  else if (isPublicAccess) {
@@ -1242,9 +1447,39 @@ export async function installCommand(name, options = {}) {
1242
1447
  console.log(chalk.green('✓') + ` License verified for ${productId}`);
1243
1448
  }
1244
1449
  // =======================================================================
1450
+ // ========== Grant closure coverage (grant mode only) ==========
1451
+ // members[] is the ALLOWED set: every exact pin we will install (flattened
1452
+ // across the extends chain, --pin applied) plus the extends ancestors must be
1453
+ // a closure member, or the proxy would 401 mid-install. A --pin outside the
1454
+ // closure is refused up front (both modes, logged in or not). A manifest pin
1455
+ // outside it is a grant/index inconsistency: prefer → legacy path, require → exit.
1456
+ if (grant) {
1457
+ const g = grant.current;
1458
+ const expectedPins = applyPinOverrides(await collectAllPackages(snapshot), overrides);
1459
+ let ancestors = [];
1460
+ try {
1461
+ ancestors = await collectExtendsChain(snapshot);
1462
+ }
1463
+ catch (err) {
1464
+ onGrantFailure(err);
1465
+ }
1466
+ if (grant) {
1467
+ const coverage = checkGrantCoverage([...Object.entries(expectedPins).map(([n, v]) => ({ name: n, version: v })), ...ancestors], g.members);
1468
+ if (!coverage.ok) {
1469
+ const pinViolations = coverage.missing.filter((spec) => Object.entries(overrides).some(([n, v]) => spec === `${n}@${v}`));
1470
+ if (pinViolations.length > 0) {
1471
+ console.log(chalk.red(`⛔ --pin outside the snapshot grant closure: ${pinViolations.join(', ')}`));
1472
+ console.log(chalk.gray(' A pinned version must be part of this snapshot version\'s frozen closure.'));
1473
+ process.exit(1);
1474
+ }
1475
+ onGrantFailure(new GrantError('invalid-response', `grant closure does not cover ${coverage.missing.length} manifest pin(s): ${coverage.missing.join(', ')}`));
1476
+ }
1477
+ }
1478
+ }
1245
1479
  // ========== Authorize Session with Registry Proxy ==========
1246
1480
  let sessionId;
1247
- if (authToken) {
1481
+ // Grant mode: the istsg_ grant replaces authorizeSession (one transport token).
1482
+ if (authToken && !grant) {
1248
1483
  // Collect all packages for session authorization
1249
1484
  const sessionPackages = [];
1250
1485
  // From snapshot.install
@@ -1268,7 +1503,7 @@ export async function installCommand(name, options = {}) {
1268
1503
  console.log(chalk.gray(` Auth type: ${authType}`));
1269
1504
  try {
1270
1505
  sessionId = await authorizeSession(authToken, name, sessionPackages);
1271
- console.log(chalk.green('✓') + ' Session authorized: ' + sessionId);
1506
+ console.log(chalk.green('✓') + ' Session authorized: ' + maskSessionId(sessionId));
1272
1507
  }
1273
1508
  catch (error) {
1274
1509
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -1281,12 +1516,20 @@ export async function installCommand(name, options = {}) {
1281
1516
  // =============================================================
1282
1517
  // Collect all packages from entire extends chain to determine scopes
1283
1518
  const allPackages = await collectAllPackages(snapshot);
1284
- // Configure .npmrc for proxy (with or without auth token)
1519
+ // Temp npm userconfig for the proxy (with or without auth); ~/.npmrc untouched
1285
1520
  let npmrcBackup = null;
1286
1521
  if (useProxy) {
1287
1522
  const scopes = extractScopes(allPackages);
1288
1523
  if (scopes.length > 0) {
1289
- if (authToken) {
1524
+ if (grant) {
1525
+ // Grant mode: the istsg_ token is the proxy transport token for npm and
1526
+ // skit (kit npm deps read ${IST_SESSION_TOKEN}). Re-issues update it.
1527
+ process.env.IST_SESSION_TOKEN = grant.current.token;
1528
+ grantEnvActive = true;
1529
+ console.log(chalk.gray(`Configuring registry-proxy (snapshot grant) for scopes: ${scopes.join(', ')}`));
1530
+ npmrcBackup = configureNpmrcForProxy(scopes);
1531
+ }
1532
+ else if (authToken) {
1290
1533
  // 환경변수에 설정 (npm이 .npmrc의 ${IST_SESSION_TOKEN}을 해석)
1291
1534
  process.env.IST_SESSION_TOKEN = authToken;
1292
1535
  console.log(chalk.gray(`Configuring registry-proxy for scopes: ${scopes.join(', ')}`));
@@ -1312,13 +1555,18 @@ export async function installCommand(name, options = {}) {
1312
1555
  return;
1313
1556
  cleanupDone = true;
1314
1557
  if (npmrcBackup) {
1315
- restoreNpmrc(npmrcBackup);
1316
- console.log(chalk.gray('Restored .npmrc'));
1558
+ npmrcBackup.cleanup();
1559
+ console.log(chalk.gray('Removed temp npm userconfig'));
1317
1560
  }
1318
1561
  delete process.env.IST_SESSION_TOKEN;
1562
+ activeGrantToken = null;
1563
+ grantEnvActive = false;
1564
+ // Clear the threaded downgrade mode so it never leaks to a later install
1565
+ // (the exec boundary self-resolves again when no context is published).
1566
+ setActiveDowngradeMode(undefined);
1319
1567
  };
1320
1568
  const onSignal = (signal) => {
1321
- console.log(chalk.yellow(`\nReceived ${signal} — restoring .npmrc before exit...`));
1569
+ console.log(chalk.yellow(`\nReceived ${signal} — restoring npm config before exit...`));
1322
1570
  cleanup();
1323
1571
  // Conventional exit code for signal termination: 128 + signal number
1324
1572
  // (SIGINT=2 → 130, SIGTERM=15 → 143).
@@ -1326,6 +1574,28 @@ export async function installCommand(name, options = {}) {
1326
1574
  };
1327
1575
  process.once('SIGINT', onSignal);
1328
1576
  process.once('SIGTERM', onSignal);
1577
+ // grant-unify P1 — re-issue the snapshot grant before a long phase when its
1578
+ // remaining TTL is low (GrantState.ensureFresh). The new token reaches npm and
1579
+ // skit through IST_SESSION_TOKEN (onToken). A re-issue failure: require →
1580
+ // throw (fail-loud; callers inside the install try/finally clean up), prefer →
1581
+ // warn and keep the current token. No-op without an active grant (off mode).
1582
+ const refreshGrant = async (phase) => {
1583
+ if (!grant)
1584
+ return;
1585
+ const st = grant;
1586
+ try {
1587
+ if (await st.ensureFresh()) {
1588
+ console.log(chalk.gray(` ↻ Grant re-issued before ${phase}: ${formatGrantLog(grantMode, st.current)}`));
1589
+ }
1590
+ }
1591
+ catch (err) {
1592
+ const msg = err instanceof Error ? err.message : String(err);
1593
+ if (grantMode === 'require') {
1594
+ throw new Error(`Snapshot grant re-issue failed before ${phase}: ${msg}`);
1595
+ }
1596
+ console.log(chalk.yellow(` ⚠ Snapshot grant re-issue failed before ${phase} — keeping the current grant: ${msg}`));
1597
+ }
1598
+ };
1329
1599
  // Determine which installers to use
1330
1600
  const effectiveInstallers = { ...DEFAULT_INSTALLERS };
1331
1601
  if (useProxy) {
@@ -1351,11 +1621,56 @@ export async function installCommand(name, options = {}) {
1351
1621
  console.log(chalk.yellow(`⚠ --pin ignored for package(s) not in this snapshot: ${unmatched.join(', ')}`));
1352
1622
  }
1353
1623
  const npmEntries = Object.entries(effectiveNpmMap);
1624
+ // ========== Downgrade guard (dev-isolation) ==========
1625
+ // A snapshot bundles OLDER stable versions. On a dev machine whose global
1626
+ // tooling is already NEWER, installing would `npm i -g {pkg}@{pinned}` and
1627
+ // DOWNGRADE it, breaking the environment. Detect at THIS boundary — the map is
1628
+ // now flattened across the whole extends chain (effectiveNpmMap), and NO npm
1629
+ // exec has happened yet (the pre-install batch is below). A clean Docker
1630
+ // sandbox has nothing installed globally → zero offenders → auto-pass, no
1631
+ // config needed. Policy precedence (highest wins): CLI --allow-downgrade / env
1632
+ // SNAPSHOT_ALLOW_LOCAL=1 → machine config → snapshot.policy.onDowngrade →
1633
+ // default 'refuse'. Side effects live in @microwiseai/snapshot-downgrade-guard
1634
+ // via injection (readVersion), keeping that package pure.
1635
+ {
1636
+ const { wouldDowngrade, offenders } = detectDowngrades(effectiveNpmMap, {
1637
+ readVersion: readInstalledNpmVersion,
1638
+ });
1639
+ // Resolve the policy ONCE with full context (incl. snapshot.json policy) and
1640
+ // publish it so the downstream npm-exec boundary (runCommand/runCommandSync)
1641
+ // REUSES it instead of recomputing — one decision for the whole install, and
1642
+ // the boundary won't re-report the same downgrade.
1643
+ const mode = resolveDowngradePolicy({
1644
+ override: readOneShotOverride({
1645
+ env: process.env,
1646
+ allowDowngradeFlag: options.allowDowngrade === true,
1647
+ }),
1648
+ machine: getMachineDowngradeMode(),
1649
+ snapshot: snapshot.policy?.onDowngrade,
1650
+ });
1651
+ setActiveDowngradeMode(mode);
1652
+ if (wouldDowngrade) {
1653
+ if (mode === 'refuse') {
1654
+ // Nothing installed yet — restore .npmrc / unset token, then fail-loud
1655
+ // with a non-zero exit and ZERO partial install.
1656
+ cleanup();
1657
+ console.log('');
1658
+ console.log(chalk.red(formatDowngradeReport(offenders, mode)));
1659
+ process.exit(1);
1660
+ }
1661
+ else if (mode === 'warn') {
1662
+ console.log('');
1663
+ console.log(chalk.yellow(formatDowngradeReport(offenders, mode)));
1664
+ }
1665
+ // mode === 'allow' → proceed silently.
1666
+ }
1667
+ }
1354
1668
  if (npmEntries.length > 0) {
1355
1669
  console.log('');
1356
1670
  console.log(chalk.cyan(`► Pre-installing ${npmEntries.length} npm packages from entire extends chain...`));
1357
1671
  // Filter out already-installed packages
1358
1672
  const toInstall = [];
1673
+ const endProbe = timingStart(`pre-install already-installed probes (${npmEntries.length})`);
1359
1674
  for (const [name, version] of npmEntries) {
1360
1675
  if (isNpmGlobalInstalled(name, version)) {
1361
1676
  console.log(` ${chalk.gray('skip')} ${name}@${version}${chalk.green(' (already installed)')}`);
@@ -1364,11 +1679,23 @@ export async function installCommand(name, options = {}) {
1364
1679
  toInstall.push({ name, version });
1365
1680
  }
1366
1681
  }
1682
+ endProbe();
1367
1683
  if (toInstall.length > 0) {
1368
1684
  const specs = toInstall.map(e => `${e.name}@${e.version}`);
1369
1685
  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);
1686
+ try {
1687
+ await refreshGrant('pre-install batch');
1688
+ }
1689
+ catch (err) {
1690
+ // Outside the install try/finally: restore .npmrc / token, then fail loud.
1691
+ cleanup();
1692
+ console.log(chalk.red(`⛔ ${err instanceof Error ? err.message : String(err)}`));
1693
+ process.exit(1);
1694
+ }
1695
+ const endBatch = timingStart(`pre-install batch (${specs.length})`);
1696
+ console.log(chalk.cyan(` ► Installing ${specs.length} npm package(s) in one batch… (수십초 걸릴 수 있음, npm 진행이 아래에 표시됩니다)`));
1697
+ const batchSuccess = await runCommand(npmCommand);
1698
+ endBatch();
1372
1699
  // S3 — record the up-front batch (the single step where the beta npm tree
1373
1700
  // is actually resolved). 'fallback' means it failed and we retried below.
1374
1701
  recorder.record({
@@ -1393,7 +1720,7 @@ export async function installCommand(name, options = {}) {
1393
1720
  for (const entry of toInstall) {
1394
1721
  const pkgSpec = `${entry.name}@${entry.version}`;
1395
1722
  const cmd = DEFAULT_INSTALLERS['npm-proxy'].command.replace('{pkg}', pkgSpec);
1396
- const ok = runCommand(cmd);
1723
+ const ok = await runCommand(cmd);
1397
1724
  recorder.record({
1398
1725
  phase: 'pre-install-fallback',
1399
1726
  installer: 'npm-proxy',
@@ -1430,33 +1757,44 @@ export async function installCommand(name, options = {}) {
1430
1757
  try {
1431
1758
  // Auto-detect 모드: snapshot.install이 없으면 자동 판별
1432
1759
  const autoDetect = !snapshot.install;
1760
+ const endLayers = timingStart('installSnapshot (layers + kits)');
1433
1761
  const installResult = await installSnapshot(snapshot, 0, effectiveInstallers, {
1434
1762
  useProxy,
1435
1763
  autoDetect,
1436
1764
  sessionId,
1437
1765
  recorder,
1438
1766
  overrides,
1767
+ ...(grant ? { beforePhase: refreshGrant, skitSessionId: () => activeGrantToken ?? undefined } : {}),
1439
1768
  });
1440
1769
  result = installResult.result;
1770
+ endLayers();
1441
1771
  // ===== S1/FB — verify (and optionally converge) BEFORE cleanup =====
1442
1772
  // SSOT enforcement: the manifest pins (with FB overrides applied) are the
1443
1773
  // truth. Check the disk actually matches. This MUST run before the finally
1444
1774
  // restores .npmrc, because re-pinning via `npm install -g` needs the
1445
1775
  // registry-proxy config + token still in place.
1446
1776
  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' });
1777
+ // Re-pin ALL diverged packages in a round with a SINGLE `npm install -g`
1778
+ // (batched) and record it as one ledger step carrying every spec. This
1779
+ // collapses N sequential global installs per round into one npm spawn.
1780
+ const repinMany = (phase) => (pkgs) => {
1781
+ if (pkgs.length === 0)
1782
+ return;
1783
+ const specs = pkgs.map((p) => `${p.name}@${p.version}`);
1784
+ const cmd = `npm install -g ${specs.join(' ')}`;
1785
+ console.log(chalk.gray(` ► Re-pinning ${specs.length} package(s) in one batch (npm install -g … — 수십초 걸릴 수 있음)`));
1786
+ // Sync streaming: invoked from the pure/sync runConverge callback.
1787
+ const ok = runCommandSync(cmd);
1788
+ recorder.record({ phase, installer: 'npm-proxy', command: cmd, specs, result: ok ? 'ok' : 'failed' });
1453
1789
  };
1790
+ await refreshGrant('verify/converge');
1791
+ const endVerify = timingStart('verify/converge');
1454
1792
  if (converge) {
1455
1793
  // Default: re-pin EVERY package that diverged from its pin (self-healing).
1456
1794
  console.log('');
1457
1795
  console.log(chalk.cyan('► Verifying & converging install to manifest pins...'));
1458
1796
  verification = runConverge(allNpmPackages, overrides, {
1459
- install: repin('converge'),
1797
+ installMany: repinMany('converge'),
1460
1798
  readVersion: readInstalledNpmVersion,
1461
1799
  maxRounds: 3,
1462
1800
  });
@@ -1471,13 +1809,14 @@ export async function installCommand(name, options = {}) {
1471
1809
  console.log('');
1472
1810
  console.log(chalk.cyan(`► Holding ${Object.keys(pinTargets).length} pinned package(s) to the override version...`));
1473
1811
  runConverge(pinTargets, {}, {
1474
- install: repin('pin-converge'),
1812
+ installMany: repinMany('pin-converge'),
1475
1813
  readVersion: readInstalledNpmVersion,
1476
1814
  maxRounds: 3,
1477
1815
  });
1478
1816
  }
1479
1817
  verification = verifyInstallMap(effectiveNpmMap, { readVersion: readInstalledNpmVersion });
1480
1818
  }
1819
+ endVerify();
1481
1820
  }
1482
1821
  }
1483
1822
  catch (err) {
@@ -1569,6 +1908,16 @@ export async function installCommand(name, options = {}) {
1569
1908
  // verification shape { ok, verifiedCount, total, mismatches[{name,expected,got}] }.
1570
1909
  if (verification)
1571
1910
  ledger.verification = verification;
1911
+ // grant-unify P1 — grant provenance (no token). Absent in off mode.
1912
+ if (grant) {
1913
+ const st = grant;
1914
+ ledger.grant = {
1915
+ mode: grantMode,
1916
+ closureHash: st.current.closureHash.slice(0, 8),
1917
+ memberCount: st.current.members.length,
1918
+ reissues: st.reissues,
1919
+ };
1920
+ }
1572
1921
  // AC1.4 — fold the core-floor verdict too.
1573
1922
  ledger.coreFloor = {
1574
1923
  ok: coreFloor.ok,