fraim 2.0.360 → 2.0.361-next.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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.resolveMachinePathCached = exports.expandWindowsEnvRefs = exports.parseRegQueryPathValue = void 0;
7
7
  exports.getManagedNodeRoot = getManagedNodeRoot;
8
+ exports.isOwnedManagedAgentTarget = isOwnedManagedAgentTarget;
8
9
  exports.getPortableNodeBinPath = getPortableNodeBinPath;
9
10
  exports.getManagedAgentBinDirs = getManagedAgentBinDirs;
10
11
  exports.stripProjectLocalNodeBinDirs = stripProjectLocalNodeBinDirs;
@@ -18,10 +19,13 @@ exports.__setLoginShellPathResolverForTests = __setLoginShellPathResolverForTest
18
19
  exports.resolveLoginShellPathCached = resolveLoginShellPathCached;
19
20
  exports.__clearLoginShellPathCacheForTests = __clearLoginShellPathCacheForTests;
20
21
  exports.invalidateMachinePathCache = invalidateMachinePathCache;
22
+ exports.invalidateAgentCommandRecovery = invalidateAgentCommandRecovery;
21
23
  exports.__setMachinePathResolverForTests = __setMachinePathResolverForTests;
22
24
  exports.buildRecoveredSystemPath = buildRecoveredSystemPath;
25
+ exports.buildRecoveredAgentPathAsync = buildRecoveredAgentPathAsync;
23
26
  exports.buildRecoveredAgentPath = buildRecoveredAgentPath;
24
27
  exports.versionFromProbeOutput = versionFromProbeOutput;
28
+ const operation_scope_1 = require("./operation-scope");
25
29
  const fs_1 = __importDefault(require("fs"));
26
30
  const path_1 = __importDefault(require("path"));
27
31
  const child_process_1 = require("child_process");
@@ -36,6 +40,18 @@ Object.defineProperty(exports, "resolveMachinePathCached", { enumerable: true, g
36
40
  function getManagedNodeRoot() {
37
41
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
38
42
  }
43
+ /** Repair authority requires the real launcher to remain under the real FRAIM Node root. */
44
+ function isOwnedManagedAgentTarget(target) {
45
+ try {
46
+ const root = fs_1.default.realpathSync(getManagedNodeRoot());
47
+ const real = fs_1.default.realpathSync(target);
48
+ const relative = path_1.default.relative(root, real);
49
+ return Boolean(relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative));
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
39
55
  function getPortableNodeBinPath(platform = process.platform, arch = process.arch) {
40
56
  const nodeRoot = getManagedNodeRoot();
41
57
  if (platform === 'win32') {
@@ -228,9 +244,7 @@ function buildPathWithManagedAgentBins(basePath) {
228
244
  // win32 rather than attempting a login-shell equivalent that doesn't exist.
229
245
  const LOGIN_SHELL_PATH_TIMEOUT_MS = 3000;
230
246
  const LOGIN_SHELL_PATH_CACHE_TTL_MS = 60_000;
231
- function defaultResolveLoginShellPath() {
232
- if (process.platform === 'win32')
233
- return null;
247
+ function loginShellInvocation() {
234
248
  const shell = process.env.SHELL || '/bin/bash';
235
249
  const marker = `__fraim_1256_shell_path_${process.pid}__`;
236
250
  // `-lc`, not `-ilc`: login + command, deliberately NOT interactive (SOC 2 CC6.1 / ISO 27001
@@ -248,6 +262,20 @@ function defaultResolveLoginShellPath() {
248
262
  'echo',
249
263
  `echo "${marker}"`,
250
264
  ].join('; ');
265
+ return { shell, marker, script };
266
+ }
267
+ function parseLoginShellOutput(output, marker) {
268
+ const first = output.indexOf(marker);
269
+ if (first < 0)
270
+ return null;
271
+ const rest = output.slice(first + marker.length);
272
+ const end = rest.indexOf(marker);
273
+ return (end < 0 ? rest : rest.slice(0, end)).trim() || null;
274
+ }
275
+ function defaultResolveLoginShellPath() {
276
+ if (process.platform === 'win32')
277
+ return null;
278
+ const { shell, marker, script } = loginShellInvocation();
251
279
  try {
252
280
  const result = (0, child_process_1.spawnSync)(shell, ['-lc', script], {
253
281
  encoding: 'utf8',
@@ -259,13 +287,7 @@ function defaultResolveLoginShellPath() {
259
287
  if (result.status !== 0 || result.error)
260
288
  return null;
261
289
  const output = result.stdout || '';
262
- const firstIdx = output.indexOf(marker);
263
- if (firstIdx === -1)
264
- return null;
265
- const afterFirst = output.slice(firstIdx + marker.length);
266
- const secondIdx = afterFirst.indexOf(marker);
267
- const section = (secondIdx === -1 ? afterFirst : afterFirst.slice(0, secondIdx)).trim();
268
- return section || null;
290
+ return parseLoginShellOutput(output, marker);
269
291
  }
270
292
  catch {
271
293
  return null;
@@ -312,6 +334,7 @@ function resolveLoginShellPathCached() {
312
334
  }
313
335
  /** Test seam only. Clears the memoized login-shell PATH so the next call recomputes. */
314
336
  function __clearLoginShellPathCacheForTests() {
337
+ recoveryGeneration++;
315
338
  cachedLoginShellPath = undefined;
316
339
  cachedLoginShellPathAtMs = 0;
317
340
  cachedNpmGlobalBinDirsForRecovery.clear();
@@ -392,47 +415,78 @@ function resolveNpmGlobalBinDirsCached(basePath) {
392
415
  function invalidateMachinePathCache() {
393
416
  (0, machine_path_1.clearMachinePathCache)();
394
417
  cachedNpmGlobalBinDirsForRecovery.clear();
418
+ recoveryGeneration++;
419
+ }
420
+ /** Refresh all discovery inputs after an explicit installation or setup retry. */
421
+ function invalidateAgentCommandRecovery() {
422
+ invalidateMachinePathCache();
423
+ cachedLoginShellPath = undefined;
424
+ cachedLoginShellPathAtMs = 0;
425
+ recoveryGeneration++;
395
426
  }
396
427
  function __setMachinePathResolverForTests(resolver) {
397
428
  (0, machine_path_1.setMachinePathResolver)(resolver);
398
429
  cachedNpmGlobalBinDirsForRecovery.clear();
399
430
  }
431
+ function composeRecoveredSystemInputs(basePath, machinePath, loginPath) {
432
+ const inherited = stripProjectLocalNodeBinDirs(stripManagedAgentBinDirsFromPath(basePath));
433
+ const machine = machinePath ? appendBinDirsToPath(machinePath, inherited.split(path_1.default.delimiter).filter(Boolean)) : inherited;
434
+ const login = loginPath ? appendBinDirsToPath(machine, loginPath.split(path_1.default.delimiter).filter(Boolean)) : machine;
435
+ return stripManagedAgentBinDirsFromPath(stripProjectLocalNodeBinDirs(login));
436
+ }
400
437
  function buildRecoveredSystemPath(basePath) {
401
- const withoutManaged = stripManagedAgentBinDirsFromPath(basePath);
402
- const inherited = stripProjectLocalNodeBinDirs(withoutManaged);
403
- // Issue #1748 (win32 only): the PATH the machine reports right now. This process may
404
- // predate the user's install, so its own inherited PATH is a stale copy of the same
405
- // registry. Machine entries go FIRST, because entries present in both would otherwise
406
- // keep their stale relative position and an entry existing only on the machine PATH
407
- // would land last, which is how resolution picked a different install than the one a
408
- // fresh terminal resolves.
409
- //
410
- // Deliberately NOT applied to the POSIX login-shell tier below, which keeps its
411
- // original append semantics. The staleness this reorders around is a Windows
412
- // registry-snapshot problem; on POSIX a GUI-launched process has a minimal inherited
413
- // PATH that carries no competing entry to out-order, so appending already resolves it.
414
- // Promoting login-shell entries above inherited ones there would let the login shell
415
- // silently override a directory a wrapper deliberately prepended for that launch,
416
- // which is the same class of "wrong binary launched" bug this change exists to fix.
417
- const machinePath = process.platform === 'win32' ? (0, machine_path_1.resolveMachinePathCached)() : null;
418
- const machineEntries = machinePath ? machinePath.split(path_1.default.delimiter).filter(Boolean) : [];
419
- const withMachine = machineEntries.length > 0
420
- ? appendBinDirsToPath(machineEntries.join(path_1.default.delimiter), inherited.split(path_1.default.delimiter).filter(Boolean))
421
- : inherited;
422
- const loginShellPath = resolveLoginShellPathCached();
423
- const loginShellEntries = loginShellPath ? loginShellPath.split(path_1.default.delimiter).filter(Boolean) : [];
424
- const withLoginShell = loginShellEntries.length > 0
425
- ? appendBinDirsToPath(withMachine, loginShellEntries)
426
- : withMachine;
427
- const cleaned = stripProjectLocalNodeBinDirs(withLoginShell);
428
- // Strip managed dirs a second time. FRAIM registers its own directory on the machine
429
- // PATH at install (persistShellPath), so the recovered PATH legitimately contains it,
430
- // but this helper's contract is "what the real machine provides, excluding FRAIM's
431
- // bundled copy" - which is what lets callers tell a user install apart from FRAIM's
432
- // fallback. Without this the machine read would smuggle the managed dir back in.
433
- const systemOnly = stripManagedAgentBinDirsFromPath(cleaned);
434
- const npmGlobalBinDirs = resolveNpmGlobalBinDirsCached(systemOnly);
435
- return appendBinDirsToPath(systemOnly, npmGlobalBinDirs);
438
+ const system = composeRecoveredSystemInputs(basePath, process.platform === 'win32' ? (0, machine_path_1.resolveMachinePathCached)() : null, resolveLoginShellPathCached());
439
+ return appendBinDirsToPath(system, resolveNpmGlobalBinDirsCached(system));
440
+ }
441
+ let recoveryGeneration = 0;
442
+ async function resolveLoginShellPathCachedAsync(signal) {
443
+ signal.throwIfAborted();
444
+ if (cachedLoginShellPath !== undefined && Date.now() - cachedLoginShellPathAtMs < LOGIN_SHELL_PATH_CACHE_TTL_MS)
445
+ return cachedLoginShellPath;
446
+ if (loginShellPathResolverOverride || process.platform === 'win32')
447
+ return resolveLoginShellPathCached();
448
+ const generation = recoveryGeneration;
449
+ const { captureAgentCommand } = require('./agent-command-probe');
450
+ const invocation = loginShellInvocation();
451
+ const output = await captureAgentCommand(invocation.shell, ['-lc', invocation.script], { env: { ...process.env }, signal, timeoutMs: LOGIN_SHELL_PATH_TIMEOUT_MS });
452
+ signal.throwIfAborted();
453
+ const value = output === null ? null : parseLoginShellOutput(output, invocation.marker);
454
+ if (generation === recoveryGeneration) {
455
+ cachedLoginShellPath = value;
456
+ cachedLoginShellPathAtMs = Date.now();
457
+ cachedNpmGlobalBinDirsForRecovery.clear();
458
+ }
459
+ return value;
460
+ }
461
+ const recoveredPaths = new operation_scope_1.SharedOperations();
462
+ async function buildRecoveredAgentPathAsync(basePath, options = {}) {
463
+ const generation = recoveryGeneration;
464
+ const key = JSON.stringify([basePath ?? process.env.PATH, (0, script_sync_utils_1.getUserFraimDir)(), generation]);
465
+ return (0, operation_scope_1.withOperation)(options, 30000, signal => recoveredPaths.run(key, async (owner) => {
466
+ // All children settle before cancellation releases the recovery owner.
467
+ const results = await Promise.allSettled([(0, machine_path_1.resolveMachinePathCachedAsync)(owner), resolveLoginShellPathCachedAsync(owner)]);
468
+ owner.throwIfAborted();
469
+ for (const result of results)
470
+ if (result.status === 'rejected')
471
+ throw result.reason;
472
+ const [machine, login] = results.map(result => result.value);
473
+ const system = composeRecoveredSystemInputs(basePath, machine, login);
474
+ let dirs = cachedNpmGlobalBinDirsForRecovery.get(system);
475
+ if (!dirs) {
476
+ const { getSystemCommandPath } = require('../mcp/command-resolution');
477
+ const { captureAgentCommand } = require('./agent-command-probe');
478
+ const npm = getSystemCommandPath('npm', system);
479
+ const output = npm ? await captureAgentCommand(npm, ['prefix', '-g'], { env: { ...process.env, PATH: system, Path: system }, signal: owner, timeoutMs: 5000 }) : null;
480
+ owner.throwIfAborted();
481
+ dirs = output && path_1.default.isAbsolute(output.trim()) ? getNpmGlobalBinDirsFromPrefix(output.trim()) : [];
482
+ if (generation === recoveryGeneration) {
483
+ if (cachedNpmGlobalBinDirsForRecovery.size >= NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES)
484
+ cachedNpmGlobalBinDirsForRecovery.clear();
485
+ cachedNpmGlobalBinDirsForRecovery.set(system, dirs);
486
+ }
487
+ }
488
+ return appendBinDirsToPath(appendBinDirsToPath(system, dirs), getManagedAgentBinDirs());
489
+ }, signal));
436
490
  }
437
491
  function buildRecoveredAgentPath(basePath) {
438
492
  return appendBinDirsToPath(buildRecoveredSystemPath(basePath), getManagedAgentBinDirs());
@@ -14,8 +14,12 @@ exports.ensureManagedNpm = ensureManagedNpm;
14
14
  const fs_1 = __importDefault(require("fs"));
15
15
  const os_1 = __importDefault(require("os"));
16
16
  const path_1 = __importDefault(require("path"));
17
- const https_1 = __importDefault(require("https"));
17
+ const promises_1 = require("node:stream/promises");
18
+ const node_stream_1 = require("node:stream");
19
+ const node_worker_threads_1 = require("node:worker_threads");
20
+ const operation_scope_1 = require("./operation-scope");
18
21
  const crypto_1 = __importDefault(require("crypto"));
22
+ const agent_command_probe_1 = require("./agent-command-probe");
19
23
  const managed_agent_paths_1 = require("./managed-agent-paths");
20
24
  // Desktop-only users may have no working system npm/npx. Installer setup, Hub
21
25
  // materialization, first-run setup, and agent installation share this managed
@@ -58,7 +62,12 @@ function findExecutableOnPath(name, pathValue = process.env.PATH) {
58
62
  }
59
63
  /** Whether `dir` already has a real, runnable node+npm — any version; this function's job is "a working npm", not "this exact one". */
60
64
  function hasWorkingNodeAndNpm(dir, platform = process.platform) {
61
- return fs_1.default.existsSync(path_1.default.join(dir, npmExecutableName(platform))) && fs_1.default.existsSync(path_1.default.join(dir, nodeExecutableName(platform)));
65
+ if (platform !== process.platform)
66
+ return false;
67
+ const runtimePath = [dir, process.env.SystemRoot ? path_1.default.join(process.env.SystemRoot, 'System32') : '/usr/bin'].join(path_1.default.delimiter);
68
+ const env = { ...process.env, PATH: runtimePath, Path: runtimePath };
69
+ return Boolean((0, agent_command_probe_1.selectAgentCommandSync)(path_1.default.join(dir, nodeExecutableName(platform)), { env })
70
+ && (0, agent_command_probe_1.selectAgentCommandSync)(path_1.default.join(dir, npmExecutableName(platform)), { env }));
62
71
  }
63
72
  function nodeBinDir(root, platform) {
64
73
  return platform === 'win32' ? root : path_1.default.join(root, 'bin');
@@ -108,55 +117,38 @@ function nodeArchiveFileName(request) {
108
117
  function nodeDistBaseUrl(version) {
109
118
  return `https://nodejs.org/dist/v${version}`;
110
119
  }
111
- function httpsGetBuffer(url) {
112
- return new Promise((resolve, reject) => {
113
- https_1.default.get(url, { headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } }, (res) => {
114
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
115
- httpsGetBuffer(res.headers.location).then(resolve, reject);
116
- return;
117
- }
118
- if (res.statusCode !== 200) {
119
- res.resume();
120
- reject(new Error(`GET ${url} failed: HTTP ${res.statusCode}`));
121
- return;
122
- }
123
- const chunks = [];
124
- res.on('data', (chunk) => chunks.push(chunk));
125
- res.on('end', () => resolve(Buffer.concat(chunks)));
126
- res.on('error', reject);
127
- }).on('error', reject);
128
- });
120
+ async function httpsGetBuffer(url, signal) {
121
+ const response = await fetch(url, { signal, headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } });
122
+ if (!response.ok) {
123
+ await response.body?.cancel();
124
+ throw new Error('Node release checksum download failed');
125
+ }
126
+ const chunks = [];
127
+ let size = 0;
128
+ for await (const chunk of node_stream_1.Readable.fromWeb(response.body)) {
129
+ size += chunk.length;
130
+ if (size > 1024 * 1024)
131
+ throw new Error('Node checksum response exceeded its limit');
132
+ chunks.push(chunk);
133
+ }
134
+ signal?.throwIfAborted();
135
+ return Buffer.concat(chunks);
129
136
  }
130
- function httpsDownloadToFile(url, destPath) {
131
- return new Promise((resolve, reject) => {
132
- const file = fs_1.default.createWriteStream(destPath);
133
- https_1.default.get(url, { headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } }, (res) => {
134
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
135
- file.close();
136
- httpsDownloadToFile(res.headers.location, destPath).then(resolve, reject);
137
- return;
138
- }
139
- if (res.statusCode !== 200) {
140
- res.resume();
141
- file.close();
142
- reject(new Error(`GET ${url} failed: HTTP ${res.statusCode}`));
143
- return;
144
- }
145
- res.pipe(file);
146
- file.on('finish', () => file.close(() => resolve()));
147
- }).on('error', (err) => {
148
- file.close();
149
- reject(err);
150
- });
151
- });
137
+ async function httpsDownloadToFile(url, destPath, signal) {
138
+ const response = await fetch(url, { signal, headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } });
139
+ if (!response.ok || !response.body) {
140
+ await response.body?.cancel();
141
+ throw new Error('Node runtime download failed');
142
+ }
143
+ await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(response.body), fs_1.default.createWriteStream(destPath), { signal });
152
144
  }
153
145
  /**
154
146
  * Verify `filePath` against the sha256 line naming `fileName` in the release's own
155
147
  * `SHASUMS256.txt` — the same integrity check `@electron/get` performs for Electron zips
156
148
  * (`electron-dist.ts`), applied to Node's own equivalent publishing convention.
157
149
  */
158
- async function verifyNodeArchiveChecksum(filePath, fileName, version) {
159
- const shasums = (await httpsGetBuffer(`${nodeDistBaseUrl(version)}/SHASUMS256.txt`)).toString('utf8');
150
+ async function verifyNodeArchiveChecksum(filePath, fileName, version, signal) {
151
+ const shasums = (await httpsGetBuffer(`${nodeDistBaseUrl(version)}/SHASUMS256.txt`, signal)).toString('utf8');
160
152
  const line = shasums.split('\n').find((entry) => entry.trim().endsWith(fileName));
161
153
  if (!line) {
162
154
  throw new Error(`SHASUMS256.txt for Node v${version} has no entry for ${fileName}; refusing to trust an unverified download`);
@@ -171,9 +163,9 @@ const defaultDownloadNodeArchive = async (request) => {
171
163
  const fileName = nodeArchiveFileName(request);
172
164
  const url = `${nodeDistBaseUrl(request.version)}/${fileName}`;
173
165
  const tempPath = path_1.default.join(os_1.default.tmpdir(), `fraim-managed-node-${process.pid}-${Date.now()}-${fileName}`);
174
- await httpsDownloadToFile(url, tempPath);
175
166
  try {
176
- await verifyNodeArchiveChecksum(tempPath, fileName, request.version);
167
+ await httpsDownloadToFile(url, tempPath, request.signal);
168
+ await verifyNodeArchiveChecksum(tempPath, fileName, request.version, request.signal);
177
169
  }
178
170
  catch (error) {
179
171
  fs_1.default.rmSync(tempPath, { force: true });
@@ -182,16 +174,34 @@ const defaultDownloadNodeArchive = async (request) => {
182
174
  return tempPath;
183
175
  };
184
176
  const defaultExtractNodeArchive = async (archivePath, destDir, request) => {
185
- if (request.platform === 'win32') {
186
- // eslint-disable-next-line @typescript-eslint/no-var-requires
187
- const extract = require('extract-zip');
188
- await extract(archivePath, { dir: destDir });
189
- }
190
- else {
191
- // eslint-disable-next-line @typescript-eslint/no-var-requires
192
- const tar = require('tar');
193
- await tar.x({ file: archivePath, cwd: destDir });
194
- }
177
+ request.signal?.throwIfAborted();
178
+ // Extraction owns file handles. A worker lets cancellation wait for their closure
179
+ // before removing the staging directory; a timer racing an extraction cannot do that.
180
+ await new Promise((resolve, reject) => {
181
+ const zip = request.platform === 'win32';
182
+ const worker = new node_worker_threads_1.Worker(`
183
+ const { workerData } = require('node:worker_threads');
184
+ const extractor = require(workerData.modulePath);
185
+ Promise.resolve(workerData.zip ? extractor(workerData.archivePath, { dir: workerData.destDir })
186
+ : extractor.x({ file: workerData.archivePath, cwd: workerData.destDir }))
187
+ .catch(error => { throw error; });
188
+ `, { eval: true, workerData: { zip, archivePath, destDir, modulePath: require.resolve(zip ? 'extract-zip' : 'tar') } });
189
+ let failure;
190
+ const abort = () => { void worker.terminate(); };
191
+ request.signal?.addEventListener('abort', abort, { once: true });
192
+ worker.once('error', error => { failure = error; });
193
+ worker.once('exit', code => {
194
+ request.signal?.removeEventListener('abort', abort);
195
+ if (request.signal?.aborted)
196
+ reject(request.signal.reason);
197
+ else if (failure || code !== 0)
198
+ reject(failure || new Error('Node extraction failed'));
199
+ else
200
+ resolve();
201
+ });
202
+ if (request.signal?.aborted)
203
+ abort();
204
+ });
195
205
  };
196
206
  /**
197
207
  * Download and extract Node v`request.version` into `nodeRoot/<node-vX.Y.Z-platform-arch>/`,
@@ -211,24 +221,27 @@ async function downloadAndPromoteVersionedNodeDir(args) {
211
221
  // tar requires an existing cwd. Each invocation owns a separate directory,
212
222
  // including simultaneous requests within the same process.
213
223
  stagingDir = fs_1.default.mkdtempSync(`${destDir}.staging-${process.pid}-`);
224
+ request.signal?.throwIfAborted();
214
225
  await extractArchive(archivePath, stagingDir, request);
226
+ request.signal?.throwIfAborted();
215
227
  // Official tar and ZIP archives preserve their top-level distribution folder.
216
228
  // Also accept a flattened root from a supplied extractor.
217
229
  const nestedCandidate = path_1.default.join(stagingDir, nodeVersionedDirName(request));
218
230
  const extractedRoot = fs_1.default.existsSync(nestedCandidate) ? nestedCandidate : stagingDir;
219
231
  fs_1.default.writeFileSync(path_1.default.join(extractedRoot, 'fraim-managed-node-version'), request.version, 'utf8');
220
- if (!isManagedNodeVersionComplete(extractedRoot, request.version, request.platform)) {
232
+ if (!(await hasWorkingNodeAndNpmAsync(nodeBinDir(extractedRoot, request.platform), request.platform, request.signal))) {
221
233
  throw new Error(`extracted Node v${request.version} is missing ${npmExecutableName(request.platform)}/${nodeExecutableName(request.platform)}; refusing to promote an incomplete install`);
222
234
  }
223
235
  // A concurrent launch may have finished downloading the same version already; the winner
224
236
  // keeps its copy rather than being clobbered by this one.
225
- if (!isManagedNodeVersionComplete(destDir, request.version, request.platform)) {
237
+ if (!(await hasWorkingNodeAndNpmAsync(nodeBinDir(destDir, request.platform), request.platform, request.signal))) {
238
+ request.signal?.throwIfAborted();
226
239
  fs_1.default.rmSync(destDir, { recursive: true, force: true });
227
240
  try {
228
241
  fs_1.default.renameSync(extractedRoot, destDir);
229
242
  }
230
243
  catch (error) {
231
- if (!isManagedNodeVersionComplete(destDir, request.version, request.platform))
244
+ if (!(await hasWorkingNodeAndNpmAsync(nodeBinDir(destDir, request.platform), request.platform, request.signal)))
232
245
  throw error;
233
246
  }
234
247
  }
@@ -248,18 +261,24 @@ async function downloadAndPromoteVersionedNodeDir(args) {
248
261
  * this launch," not a hard failure — never for "nothing found," which falls through to download.
249
262
  */
250
263
  async function ensureManagedNpm(options = {}) {
264
+ return (0, operation_scope_1.withOperation)(options, 90000, signal => ensureManagedNpmCore({ ...options, signal }));
265
+ }
266
+ async function ensureManagedNpmCore(options) {
251
267
  const version = options.version ?? exports.MANAGED_NODE_VERSION;
252
268
  const platform = options.platform ?? process.platform;
253
269
  const arch = options.arch ?? process.arch;
254
- const systemNpm = findExecutableOnPath(npmExecutableName(platform), options.systemPath ?? process.env.PATH);
255
- if (systemNpm) {
256
- return { npmPath: systemNpm, binDir: path_1.default.dirname(systemNpm), source: 'system' };
270
+ const systemPath = options.systemPath ?? process.env.PATH ?? '';
271
+ const env = { ...process.env, PATH: systemPath, Path: systemPath };
272
+ const systemNpm = platform === process.platform ? await (0, agent_command_probe_1.selectAgentCommand)('npm', { env, signal: options.signal }) : null;
273
+ if (systemNpm && await (0, agent_command_probe_1.selectAgentCommand)('node', { env, signal: options.signal })) {
274
+ return { npmPath: systemNpm.target, binDir: path_1.default.dirname(systemNpm.target), source: 'system' };
257
275
  }
258
276
  const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)(platform, arch);
259
- if (hasWorkingNodeAndNpm(existingBinDir, platform)) {
277
+ options.signal?.throwIfAborted();
278
+ if (await hasWorkingNodeAndNpmAsync(existingBinDir, platform, options.signal)) {
260
279
  return { npmPath: path_1.default.join(existingBinDir, npmExecutableName(platform)), binDir: existingBinDir, source: 'managed-existing' };
261
280
  }
262
- const request = { version, platform, arch };
281
+ const request = { version, platform, arch, signal: options.signal };
263
282
  options.onProgress?.('Preparing FRAIM\'s bundled Node.js runtime (one-time, ~30MB)...');
264
283
  const binDir = await downloadAndPromoteVersionedNodeDir({
265
284
  nodeRoot: (0, managed_agent_paths_1.getManagedNodeRoot)(),
@@ -270,3 +289,14 @@ async function ensureManagedNpm(options = {}) {
270
289
  options.onProgress?.('FRAIM Node.js runtime ready.');
271
290
  return { npmPath: path_1.default.join(binDir, npmExecutableName(platform)), binDir, source: 'managed-downloaded' };
272
291
  }
292
+ async function hasWorkingNodeAndNpmAsync(dir, platform, signal) {
293
+ signal?.throwIfAborted();
294
+ if (platform !== process.platform)
295
+ return false;
296
+ const runtimePath = [dir, process.env.SystemRoot ? path_1.default.join(process.env.SystemRoot, 'System32') : '/usr/bin'].join(path_1.default.delimiter);
297
+ const env = { ...process.env, PATH: runtimePath, Path: runtimePath };
298
+ const working = Boolean(await (0, agent_command_probe_1.selectAgentCommand)(path_1.default.join(dir, nodeExecutableName(platform)), { env, signal })
299
+ && await (0, agent_command_probe_1.selectAgentCommand)(path_1.default.join(dir, npmExecutableName(platform)), { env, signal }));
300
+ signal?.throwIfAborted();
301
+ return working;
302
+ }
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SharedOperations = void 0;
4
+ exports.cancellationError = cancellationError;
5
+ exports.withOperation = withOperation;
6
+ function cancellationError(signal) {
7
+ return signal?.reason instanceof Error ? signal.reason : new Error('Operation cancelled');
8
+ }
9
+ async function withOperation(options, defaultTimeoutMs, task) {
10
+ options.signal?.throwIfAborted();
11
+ const owner = new AbortController();
12
+ const abort = () => owner.abort(cancellationError(options.signal));
13
+ options.signal?.addEventListener('abort', abort, { once: true });
14
+ const timer = setTimeout(() => owner.abort(new Error('Operation exceeded its deadline')), options.timeoutMs ?? defaultTimeoutMs);
15
+ if ((options.timeoutMs ?? defaultTimeoutMs) <= 0)
16
+ owner.abort(new Error('Operation exceeded its deadline'));
17
+ try {
18
+ owner.signal.throwIfAborted();
19
+ const value = await task(owner.signal);
20
+ owner.signal.throwIfAborted();
21
+ return value;
22
+ }
23
+ finally {
24
+ clearTimeout(timer);
25
+ options.signal?.removeEventListener('abort', abort);
26
+ }
27
+ }
28
+ /** Subscribers own a lease. Only the last cancellation terminates shared work. */
29
+ class SharedOperations {
30
+ constructor() {
31
+ this.active = new Map();
32
+ }
33
+ async run(key, task, signal) {
34
+ signal?.throwIfAborted();
35
+ let work = this.active.get(key);
36
+ if (work?.controller.signal.aborted) {
37
+ // Do not overlap a replacement with the preceding owner's cleanup.
38
+ await work.promise.catch(() => undefined);
39
+ return this.run(key, task, signal);
40
+ }
41
+ if (!work) {
42
+ const controller = new AbortController();
43
+ work = { controller, subscribers: 0, promise: undefined };
44
+ const entry = work;
45
+ // Invoke synchronously, as existing coalescer callers expect.
46
+ try {
47
+ work.promise = task(controller.signal);
48
+ }
49
+ catch (error) {
50
+ work.promise = Promise.reject(error);
51
+ }
52
+ work.promise = work.promise.finally(() => { if (this.active.get(key) === entry)
53
+ this.active.delete(key); });
54
+ this.active.set(key, work);
55
+ }
56
+ const entry = work;
57
+ entry.subscribers++;
58
+ return new Promise((resolve, reject) => {
59
+ let released = false;
60
+ const release = () => {
61
+ if (released)
62
+ return false;
63
+ released = true;
64
+ entry.subscribers--;
65
+ signal?.removeEventListener('abort', abort);
66
+ return true;
67
+ };
68
+ const abort = () => {
69
+ if (!release())
70
+ return;
71
+ const error = cancellationError(signal);
72
+ if (entry.subscribers === 0) {
73
+ entry.controller.abort(error);
74
+ entry.promise.then(() => reject(error), reject);
75
+ }
76
+ else
77
+ reject(error);
78
+ };
79
+ signal?.addEventListener('abort', abort, { once: true });
80
+ entry.promise.then(value => { if (release())
81
+ resolve(value); }, error => { if (release())
82
+ reject(error); });
83
+ if (signal?.aborted)
84
+ abort();
85
+ });
86
+ }
87
+ }
88
+ exports.SharedOperations = SharedOperations;
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolvePackArtifactPaths = resolvePackArtifactPaths;
6
7
  exports.packsDir = packsDir;
7
8
  exports.defaultCloneDir = defaultCloneDir;
8
9
  exports.resolvePackHome = resolvePackHome;
@@ -36,6 +37,7 @@ const child_process_1 = require("child_process");
36
37
  const fs_1 = __importDefault(require("fs"));
37
38
  const path_1 = __importDefault(require("path"));
38
39
  const artifact_paths_1 = require("../../../registry/scripts/artifact-paths");
40
+ const local_runtime_1 = require("../../../registry/scripts/learning-usage/local-runtime");
39
41
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
40
42
  const user_config_1 = require("./user-config");
41
43
  /**
@@ -48,6 +50,37 @@ const user_config_1 = require("./user-config");
48
50
  function absolutize(p) {
49
51
  return path_1.default.isAbsolute(p) ? p : path_1.default.resolve((0, project_fraim_paths_1.getUserFraimDirPath)(), p);
50
52
  }
53
+ /**
54
+ * Resolve artifact paths for a home, reading artifact path overrides from the
55
+ * machine-level config (`~/.fraim/config.json`) for Manager and Org homes.
56
+ *
57
+ * For Manager/Org homes the config path (overrides) and the artifact root
58
+ * (storage location) are separate concerns: overrides always live in the
59
+ * machine-level config while artifacts resolve relative to the synced homeRoot.
60
+ * `resolveArtifactPaths` handles Project homes correctly on its own; this helper
61
+ * is only needed for Manager/Org to avoid reading a non-existent synced config.
62
+ */
63
+ function resolvePackArtifactPaths(home) {
64
+ const layer = 'layer' in home ? home.layer : 'level' in home ? home.level.toLowerCase() : 'project';
65
+ if (layer === 'manager' || layer === 'org') {
66
+ const machineConfigPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'config.json');
67
+ // readJsonIfObject returns {} for ENOENT in strict mode; non-ENOENT errors propagate.
68
+ const machineConfig = (0, local_runtime_1.readJsonIfObject)(machineConfigPath, { strict: true });
69
+ const sectionKey = layer === 'manager' ? 'managerArtifactPaths' : 'orgArtifactPaths';
70
+ // Use property-presence check: an explicitly invalid value (false, null, "") must reach
71
+ // resolveArtifactPaths so it can be validated and rejected there, not silently replaced.
72
+ const artifactPaths = sectionKey in machineConfig
73
+ ? machineConfig[sectionKey]
74
+ : 'artifactPaths' in machineConfig
75
+ ? machineConfig.artifactPaths
76
+ : undefined;
77
+ return (0, artifact_paths_1.resolveArtifactPaths)(home.root, home.contentRoot, machineConfigPath, artifactPaths !== undefined ? { artifactPaths } : {});
78
+ }
79
+ // Project OwnerHome carries artifactRoot = <project> while root = <project>/fraim.
80
+ // Use artifactRoot when present so paths like docs/evidence resolve relative to the project root.
81
+ const homeRoot = ('artifactRoot' in home && home.artifactRoot !== undefined) ? home.artifactRoot : home.root;
82
+ return (0, artifact_paths_1.resolveArtifactPaths)(homeRoot, home.contentRoot);
83
+ }
51
84
  /** FRAIM-managed directory for pack copies that have no user-chosen location. */
52
85
  function packsDir() {
53
86
  return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'packs');
@@ -86,7 +119,7 @@ function migrateOrgFlatContent(orgRoot) {
86
119
  const dest = path_1.default.join(orgRoot, 'personalized-employee');
87
120
  try {
88
121
  // Root-relative output trees are authoritative, even when their names resemble legacy capabilities.
89
- const artifacts = (0, artifact_paths_1.resolveArtifactPaths)(orgRoot, dest);
122
+ const artifacts = resolvePackArtifactPaths({ level: 'Org', root: orgRoot, contentRoot: dest });
90
123
  const protectedDirectories = [artifacts.evidence, artifacts.raw, artifacts.retrospectives, path_1.default.dirname(artifacts.archiveRaw)];
91
124
  const contains = (root, target) => {
92
125
  const relative = path_1.default.relative(root, target);