fraim-hub 2.0.304 → 2.0.306

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.
package/README.md CHANGED
@@ -313,9 +313,8 @@ Use these defaults:
313
313
  - `technical-design` after the spec is approved and you need the implementation plan, file touchpoints, and risk handling.
314
314
  - `feature-implementation` for code changes, bug fixes, and documentation updates that should be executed and validated.
315
315
  - `test-authoring` when you need reproduction coverage, missing tests, or stronger regression protection before implementation.
316
- - `browser-application-validation` or `ui-polish-validation` after user-facing UI changes or when the ask is explicitly browser validation.
317
- - `implementation-feature-review` when you need to verify the delivered behavior matches the feature spec.
318
- - `implementation-design-review` when you need to verify the code matches the approved technical design.
316
+ - `iterative-quality-improvement` or `ui-polish-validation` after user-facing UI changes or when the ask is explicitly browser validation.
317
+ - `independent-implementation-review` when you need an independent sign-off that the delivered behavior matches both the approved technical design and the feature spec.
319
318
  - `issue-retrospective` after the work is complete and you want durable learnings captured.
320
319
 
321
320
  Typical path for a larger feature:
@@ -3,12 +3,17 @@ 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._internal = void 0;
6
7
  exports.certPaths = certPaths;
7
8
  exports.loadOrCreateCert = loadOrCreateCert;
8
9
  exports.trustCert = trustCert;
10
+ exports.untrustCert = untrustCert;
11
+ exports.computeCertFingerprint = computeCertFingerprint;
12
+ exports.isCertTrusted = isCertTrusted;
9
13
  const fs_1 = __importDefault(require("fs"));
10
14
  const path_1 = __importDefault(require("path"));
11
15
  const os_1 = __importDefault(require("os"));
16
+ const crypto_1 = __importDefault(require("crypto"));
12
17
  const child_process_1 = require("child_process");
13
18
  function certDir() {
14
19
  const base = process.env.APPDATA ||
@@ -45,6 +50,15 @@ async function loadOrCreateCert() {
45
50
  // HTTPS loopback certificate to be trusted before it can render the task pane.
46
51
  return { key: pem.private, cert: pem.cert };
47
52
  }
53
+ /**
54
+ * Test seam (issue #1589): the real OS command invocation goes through this mutable,
55
+ * module-owned indirection point instead of a bare `spawnSync` call, so a test can swap in
56
+ * a fake implementation and assert on the exact command/arguments `trustCert()`/`untrustCert()`
57
+ * would invoke, without spawning a process, touching PATH, or mutating the real trust store.
58
+ * Swapping the export on Node's built-in `child_process` module itself is not reliable for
59
+ * this (confirmed empirically while fixing this issue) - this repo-owned seam is.
60
+ */
61
+ exports._internal = { spawnSync: child_process_1.spawnSync };
48
62
  /**
49
63
  * Installs the cert as a trusted root CA in the user's OS certificate store.
50
64
  * ONLY call this for explicit Word-Online support — it prompts a Windows security
@@ -55,7 +69,7 @@ async function loadOrCreateCert() {
55
69
  */
56
70
  function trustCert(certPath) {
57
71
  if (process.platform === 'win32') {
58
- const result = (0, child_process_1.spawnSync)('certutil', ['-addstore', '-user', 'Root', certPath], { encoding: 'utf8' });
72
+ const result = exports._internal.spawnSync('certutil', ['-addstore', '-user', 'Root', certPath], { encoding: 'utf8' });
59
73
  if (result.status !== 0) {
60
74
  console.warn('[fraim] could not trust localhost certificate:', result.stderr || result.stdout);
61
75
  }
@@ -63,6 +77,56 @@ function trustCert(certPath) {
63
77
  }
64
78
  if (process.platform === 'darwin') {
65
79
  const keychain = path_1.default.join(os_1.default.homedir(), 'Library', 'Keychains', 'login.keychain-db');
66
- (0, child_process_1.spawnSync)('security', ['add-trusted-cert', '-r', 'trustRoot', '-k', keychain, certPath], { stdio: 'ignore' });
80
+ exports._internal.spawnSync('security', ['add-trusted-cert', '-r', 'trustRoot', '-k', keychain, certPath], { stdio: 'ignore' });
81
+ }
82
+ }
83
+ /** Removes a specific cert from the trust store, by fingerprint. Test-only helper. */
84
+ function untrustCert(fingerprint) {
85
+ if (process.platform === 'win32') {
86
+ exports._internal.spawnSync('certutil', ['-delstore', '-user', 'Root', fingerprint], { encoding: 'utf8' });
87
+ return;
88
+ }
89
+ if (process.platform === 'darwin') {
90
+ exports._internal.spawnSync('security', ['delete-certificate', '-Z', fingerprint], { stdio: 'ignore' });
91
+ }
92
+ }
93
+ /**
94
+ * SHA-1 fingerprint of an X.509 cert's DER bytes, in the lowercase hex form
95
+ * both `certutil -store` (Windows) and `security find-certificate -Z` (macOS)
96
+ * print, so it can be searched for directly in their output.
97
+ */
98
+ function computeCertFingerprint(pemCert) {
99
+ const der = Buffer.from(pemCert
100
+ .replace('-----BEGIN CERTIFICATE-----', '')
101
+ .replace('-----END CERTIFICATE-----', '')
102
+ .replace(/\s+/g, ''), 'base64');
103
+ return crypto_1.default.createHash('sha1').update(der).digest('hex');
104
+ }
105
+ /**
106
+ * Live check: is this exact cert (by fingerprint, not just "a localhost cert")
107
+ * already present and trusted in the OS root store? Issue #1571: replaces a
108
+ * flag-file-based "did we already do this" record, which can silently drift
109
+ * from reality (a user or policy tool removes the cert from the trust store,
110
+ * a profile restore, etc.) with no way to recover once it does. This mirrors
111
+ * office-sideload.ts's isSideloaded(), which already queries the real
112
+ * registry/filesystem live rather than trusting a flag alone.
113
+ */
114
+ function isCertTrusted(certPath) {
115
+ if (!fs_1.default.existsSync(certPath))
116
+ return false;
117
+ const fingerprint = computeCertFingerprint(fs_1.default.readFileSync(certPath, 'utf8'));
118
+ if (process.platform === 'win32') {
119
+ const result = (0, child_process_1.spawnSync)('certutil', ['-store', '-user', 'Root'], { encoding: 'utf8' });
120
+ if (result.status !== 0)
121
+ return false;
122
+ return result.stdout.toLowerCase().includes(fingerprint);
123
+ }
124
+ if (process.platform === 'darwin') {
125
+ const keychain = path_1.default.join(os_1.default.homedir(), 'Library', 'Keychains', 'login.keychain-db');
126
+ const result = (0, child_process_1.spawnSync)('security', ['find-certificate', '-a', '-c', 'localhost', '-Z', keychain], { encoding: 'utf8' });
127
+ if (result.status !== 0)
128
+ return false;
129
+ return result.stdout.toLowerCase().replace(/:/g, '').includes(fingerprint);
67
130
  }
131
+ return false;
68
132
  }
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.DESKTOP_HUB_READY_TIMEOUT_ENV = void 0;
40
40
  exports.resolveDesktopReadyTimeoutMs = resolveDesktopReadyTimeoutMs;
41
41
  exports.waitForDesktopHubReady = waitForDesktopHubReady;
42
+ exports.resolveLaunchProjectPath = resolveLaunchProjectPath;
42
43
  exports.runHub = runHub;
43
44
  // Hub-owned launcher used by the fraim-hub package. Keep this outside
44
45
  // src/cli/commands so the core fraim package has no Hub command implementation.
@@ -203,10 +204,38 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
203
204
  + `${stillRunning}`
204
205
  + ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
205
206
  }
207
+ /**
208
+ * Issue #1253: which project this launch is for, decided at the launch boundary.
209
+ *
210
+ * cwd affinity lives HERE rather than in the server, and the placement is the point.
211
+ * `resolveHubProjectPath` in server.ts has no access to a launch directory at all, so
212
+ * the #866 R2 guarantee — the server never substitutes `process.cwd()` — stays
213
+ * literally true rather than true by convention. What changes is only what the launch
214
+ * surface may hand it: an invocation directory that is already a FRAIM project is an
215
+ * intentional project and may be adopted; an uninitialized one never becomes one.
216
+ *
217
+ * An explicit `--project-path` is passed through verbatim. The server gates it (it is
218
+ * recorded only when it is a real FRAIM project), so the CLI does not second-guess a
219
+ * path the user typed.
220
+ *
221
+ * Exported for tests: this is the whole cwd-eligibility rule, and it is a pure
222
+ * function of (flag, cwd).
223
+ */
224
+ function resolveLaunchProjectPath(explicitProjectPath, cwd = process.cwd()) {
225
+ if (explicitProjectPath)
226
+ return path_1.default.resolve(explicitProjectPath);
227
+ return (0, project_fraim_paths_1.workspaceFraimExists)(cwd) ? path_1.default.resolve(cwd) : undefined;
228
+ }
206
229
  async function runHub(options) {
207
230
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('./server')));
208
231
  const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
209
- const projectPath = options.projectPath ? path_1.default.resolve(options.projectPath) : undefined;
232
+ // #1253: covers the desktop child and the in-process server alike — both already
233
+ // read this one local.
234
+ const projectPath = resolveLaunchProjectPath(options.projectPath);
235
+ if (projectPath && !options.projectPath) {
236
+ // A surprising project selection must be traceable to the launch directory.
237
+ console.log(`[ai-hub] launch project adopted from cwd: ${projectPath}`);
238
+ }
210
239
  const runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
211
240
  if (options.open) {
212
241
  const wantDesktop = !options.browser;
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
11
11
  const server_1 = require("./server");
12
12
  const cert_store_1 = require("./cert-store");
13
13
  const office_sideload_1 = require("./office-sideload");
14
+ const office_setup_policy_1 = require("./office-setup-policy");
14
15
  const remote_hub_gateway_1 = require("./remote-hub-gateway");
15
16
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
16
17
  const version_utils_1 = require("../cli/utils/version-utils");
@@ -214,6 +215,28 @@ function checkForRelaunchAttemptUpdate() {
214
215
  // ---------------------------------------------------------------------------
215
216
  // Word manifest sideload (runs once on first launch)
216
217
  // ---------------------------------------------------------------------------
218
+ // Trusting the loopback cert prompts a one-time OS security dialog (Windows
219
+ // "certutil" store-write, macOS Touch ID/password for keychain trust), so it
220
+ // must not run on every launch. Issue #1571 PR feedback: rather than record
221
+ // "already done" in a flag file that can drift from reality (the cert gets
222
+ // removed from the OS trust store by a user, a policy tool, a profile
223
+ // restore, with no way to recover once the flag lies), check the OS trust
224
+ // store live via isCertTrusted() - the same live-check pattern office-sideload.ts's
225
+ // isSideloaded() already uses instead of trusting a flag alone.
226
+ function ensureCertTrustedOnce(certPath) {
227
+ if ((0, cert_store_1.isCertTrusted)(certPath))
228
+ return;
229
+ try {
230
+ (0, cert_store_1.trustCert)(certPath);
231
+ }
232
+ catch (err) {
233
+ console.warn('[fraim] could not trust localhost certificate:', err);
234
+ }
235
+ }
236
+ // Issue: Word add-in sideload must run once on initial install and then just be
237
+ // checked (not re-run, not re-prompted) on every later Hub launch. isSideloaded()
238
+ // plus the version+port flag below make this idempotent: unchanged state returns
239
+ // immediately with no registry write and no dialog.
217
240
  function ensureWordSideload(projectPath, httpsPort) {
218
241
  // Flag version bump: bump this string when new manifests are added so all
219
242
  // users get re-sideloaded on their next launch.
@@ -238,12 +261,12 @@ function ensureWordSideload(projectPath, httpsPort) {
238
261
  fs_1.default.writeFileSync(flagPath, expectedFlag);
239
262
  }
240
263
  function shouldConfigureOfficeSideload() {
241
- // Office sideload support requires installing a local HTTPS certificate into
242
- // the OS trust store. On macOS that prompts "security wants to make changes
243
- // to your certificate trust settings", so do not run it during ordinary Hub
244
- // startup. It must be triggered by an explicit Office setup path.
245
- return process.env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1'
246
- && process.env.FRAIM_ENABLE_OFFICE_SIDELOAD === '1';
264
+ // Office setup (cert trust + manifest sideload) runs on every real Hub
265
+ // launch, but ensureCertTrustedOnce() and ensureWordSideload() are each
266
+ // internally idempotent, so in steady state neither does any work and no
267
+ // dialog reappears. Decision logic (env var + platform gating) lives in
268
+ // shouldRunOfficeSetup() (office-setup-policy.ts) see #1571.
269
+ return (0, office_setup_policy_1.shouldRunOfficeSetup)(process.env, process.platform);
247
270
  }
248
271
  // ---------------------------------------------------------------------------
249
272
  // Tray setup
@@ -492,18 +515,14 @@ async function launchDesktopShell(options) {
492
515
  catch (err) {
493
516
  console.warn('[fraim] could not write hub runtime file:', err);
494
517
  }
495
- // #1110: trusting the loopback cert in the OS store is a blocking `certutil` subprocess and
496
- // is only needed so Word *Online* will render the task pane over HTTPS. Never do this during
497
- // ordinary desktop startup; on macOS it prompts for Touch ID/password to change certificate
498
- // trust settings. Keep it behind the explicit Office sideload setup path.
499
- if (shouldConfigureOfficeSideload()) {
500
- try {
501
- (0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
502
- }
503
- catch (err) {
504
- console.warn('[fraim] could not trust localhost certificate:', err);
505
- }
506
- }
518
+ // #1110: trusting the loopback cert in the OS store is only needed so Word
519
+ // *Online* will render the task pane over HTTPS, and the underlying `certutil`/
520
+ // `security` call can prompt an OS trust dialog (Touch ID/password on macOS).
521
+ // ensureCertTrustedOnce() runs this exactly once per cert, not on every launch,
522
+ // so the one-time Office setup happens at initial install and is just checked
523
+ // (no dialog, no re-run) on every later Hub launch.
524
+ if (shouldConfigureOfficeSideload())
525
+ ensureCertTrustedOnce((0, cert_store_1.certPaths)().certPath);
507
526
  if (shouldConfigureOfficeSideload())
508
527
  ensureWordSideload(resolvedProjectPath, httpsPort);
509
528
  const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
@@ -2250,9 +2250,19 @@ function normalizeGeminiPromptForMatch(value) {
2250
2250
  return String(value || '').replace(/\s+/g, ' ').trim();
2251
2251
  }
2252
2252
  class CliHostRuntime {
2253
- constructor(spawn = spawnHostProcess, killTree = (pid, signal) => (0, tree_kill_1.default)(pid, signal)) {
2253
+ constructor(spawn = spawnHostProcess, killTree = (pid, signal) => (0, tree_kill_1.default)(pid, signal),
2254
+ // Issue #1570: a redirect's kill was previously fire-and-forget with no
2255
+ // ceiling (see `killActiveChildWithEscalation`). Configurable (not just a
2256
+ // module constant) so tests can prove the retry/escalation behavior in
2257
+ // milliseconds instead of the real 5s production window.
2258
+ killEscalationTimeoutMs = 5000,
2259
+ // Issue #1583: the initial SIGTERM counts as attempt 1; retries escalate
2260
+ // to SIGKILL until this cap is exhausted.
2261
+ maxKillEscalationAttempts = 10) {
2254
2262
  this.spawn = spawn;
2255
2263
  this.killTree = killTree;
2264
+ this.killEscalationTimeoutMs = killEscalationTimeoutMs;
2265
+ this.maxKillEscalationAttempts = maxKillEscalationAttempts;
2256
2266
  // Issue #1176: active continuation runs keyed by `${hostId}::${sessionId}` (R1,
2257
2267
  // R13, R14). Lives on this long-lived singleton instance, in-memory only (R15).
2258
2268
  // `spawn`/`killTree` are constructor-injectable so tests can prove queueing,
@@ -2307,6 +2317,8 @@ class CliHostRuntime {
2307
2317
  const active = this.activeContinueRuns.get(key);
2308
2318
  if (!active)
2309
2319
  return false;
2320
+ if (active.escalationTimer != null)
2321
+ clearTimeout(active.escalationTimer);
2310
2322
  active.pending.splice(0);
2311
2323
  this.activeContinueRuns.delete(key);
2312
2324
  if (active.child.pid == null)
@@ -2337,12 +2349,7 @@ class CliHostRuntime {
2337
2349
  if (entry.deliveryIntent === 'stop') {
2338
2350
  active.pending.unshift(entry);
2339
2351
  if (active.child.pid != null) {
2340
- try {
2341
- this.killTree(active.child.pid, 'SIGTERM');
2342
- }
2343
- catch {
2344
- // Best-effort: `close` still fires on normal process exit either way.
2345
- }
2352
+ this.killActiveChildWithEscalation(key, active);
2346
2353
  }
2347
2354
  }
2348
2355
  else {
@@ -2357,11 +2364,75 @@ class CliHostRuntime {
2357
2364
  spawnAndRegister(key, hostId, sessionId, entry) {
2358
2365
  const plan = (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(entry.direct ? buildDirectContinuePlan(hostId, sessionId, entry.message) : buildContinuePlan(hostId, sessionId, entry.message), entry.launchContext?.agent, entry.launchContext?.env);
2359
2366
  const child = this.spawn(hostId, plan, entry.projectPath, entry.handlers);
2360
- const runEntry = { child, pending: [] };
2367
+ const runEntry = { child, pending: [], handlers: entry.handlers };
2361
2368
  this.activeContinueRuns.set(key, runEntry);
2362
- child.once('close', () => this.dequeueNext(key, hostId, sessionId, runEntry));
2369
+ child.once('close', () => {
2370
+ // Issue #1570: a redirect kill that eventually takes (just slower than
2371
+ // the escalation window) must not leave its retry timer dangling past
2372
+ // this point — the process it was trying to kill is already gone.
2373
+ if (runEntry.escalationTimer != null) {
2374
+ clearTimeout(runEntry.escalationTimer);
2375
+ runEntry.escalationTimer = undefined;
2376
+ }
2377
+ this.dequeueNext(key, hostId, sessionId, runEntry);
2378
+ });
2363
2379
  return child;
2364
2380
  }
2381
+ // Issue #1570 (Defect 1): the redirect kill was previously fire-and-forget
2382
+ // — `killTree` was called once, with no way to know whether it actually
2383
+ // took, and no ceiling on how long the queued correction could sit behind
2384
+ // a stale turn if it silently didn't (a resistant/reparented descendant, a
2385
+ // stuck `taskkill /T`). This bounds the wait: if the active child's `close`
2386
+ // event hasn't fired within `killEscalationTimeoutMs`, retry the kill and
2387
+ // signal the active turn's own handlers so the manager sees a visible
2388
+ // "still interrupting" state instead of the old turn's output silently
2389
+ // continuing forever. Retries are capped by `maxKillEscalationAttempts`, so
2390
+ // a permanently wedged process produces a final failure signal instead of an
2391
+ // unbounded escalation loop.
2392
+ killActiveChildWithEscalation(key, active, attempt = 1) {
2393
+ const pid = active.child.pid;
2394
+ if (pid == null)
2395
+ return;
2396
+ const maxAttempts = Math.max(1, Math.floor(this.maxKillEscalationAttempts));
2397
+ // Issue #1570: the first attempt is a normal SIGTERM; every retry escalates
2398
+ // to SIGKILL, matching the issue's own suggested "re-issue a forceful kill"
2399
+ // shape. On Windows this is a no-op (tree-kill's win32 branch always runs
2400
+ // `taskkill /T /F` regardless of signal), but on POSIX a resistant process
2401
+ // that ignored SIGTERM gets an unignorable SIGKILL from the second attempt on.
2402
+ try {
2403
+ this.killTree(pid, attempt === 1 ? 'SIGTERM' : 'SIGKILL');
2404
+ }
2405
+ catch {
2406
+ // Best-effort: `close` still fires on normal process exit either way.
2407
+ }
2408
+ if (active.escalationTimer != null)
2409
+ clearTimeout(active.escalationTimer);
2410
+ active.escalationTimer = setTimeout(() => {
2411
+ active.escalationTimer = undefined;
2412
+ // The child already closed (and this entry was replaced/removed via
2413
+ // dequeueNext) — nothing left to escalate.
2414
+ if (this.activeContinueRuns.get(key) !== active)
2415
+ return;
2416
+ if (attempt >= maxAttempts) {
2417
+ try {
2418
+ active.handlers.onInterruptFailed?.({ attempts: attempt, timeoutMs: this.killEscalationTimeoutMs });
2419
+ }
2420
+ catch (error) {
2421
+ console.warn('[ai-hub] onInterruptFailed handler threw:', error instanceof Error ? error.message : String(error));
2422
+ }
2423
+ console.warn('[ai-hub] redirect kill did not close after maximum escalation attempts:', { key, pid, attempts: attempt });
2424
+ return;
2425
+ }
2426
+ try {
2427
+ active.handlers.onInterruptStalled?.({ attempt, timeoutMs: this.killEscalationTimeoutMs });
2428
+ }
2429
+ catch (error) {
2430
+ console.warn('[ai-hub] onInterruptStalled handler threw:', error instanceof Error ? error.message : String(error));
2431
+ }
2432
+ console.warn('[ai-hub] redirect kill did not close within the escalation window; retrying:', { key, pid, attempt });
2433
+ this.killActiveChildWithEscalation(key, active, attempt + 1);
2434
+ }, this.killEscalationTimeoutMs);
2435
+ }
2365
2436
  // R4-R6, R9, R16 (batch-delivery per issue #1176 Round 2 design feedback):
2366
2437
  // when the active child closes, drain every pending continuation at once
2367
2438
  // (FIFO), join their messages into a single combined turn, and spawn exactly
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ /**
3
+ * Pure decision logic for desktop Office setup (loopback cert trust + Word/
4
+ * Excel/PowerPoint manifest sideload), extracted out of desktop-main.ts so it
5
+ * can be unit-tested without an Electron runtime (mirrors the
6
+ * window-open-decision.ts pure-function pattern - see architecture.md).
7
+ *
8
+ * All I/O (registry writes, certutil/security spawns) stays in desktop-main.ts
9
+ * and cert-store.ts; this module only decides whether to attempt it at all.
10
+ * Cert-trust idempotency itself is a live OS-state check (cert-store.ts's
11
+ * isCertTrusted()), not a flag file, so it has no decision logic to extract
12
+ * here - see #1571 PR feedback.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.shouldRunOfficeSetup = shouldRunOfficeSetup;
16
+ /**
17
+ * Issue #1571: a prior fix (commit 33764591c, "Avoid macOS trust prompt on
18
+ * normal Hub launch") gated Office setup behind FRAIM_ENABLE_OFFICE_SIDELOAD,
19
+ * an env var nothing else in the codebase ever set - so setup silently never
20
+ * ran on any platform. Office setup must run on every real launch on the
21
+ * platforms that support it; ensureCertTrustedOnce()/ensureWordSideload() in
22
+ * desktop-main.ts are each independently idempotent, so in steady state this
23
+ * returning true does no actual work and shows no dialog.
24
+ *
25
+ * Linux has no native Office desktop apps - office-sideload.ts and
26
+ * cert-store.ts both have no real implementation there - so it is excluded
27
+ * here rather than attempted and logged as "Unsupported platform" on every
28
+ * launch (FRAIM ships a Linux desktop build; see packages/fraim-hub/package.json).
29
+ */
30
+ function shouldRunOfficeSetup(env, platform) {
31
+ return env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1'
32
+ && (platform === 'win32' || platform === 'darwin');
33
+ }
@@ -22,6 +22,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
22
22
  return (mod && mod.__esModule) ? mod : { "default": mod };
23
23
  };
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports._internal = exports.WEF_DEVELOPER_KEY = void 0;
25
26
  exports.manifestXmlForPort = manifestXmlForPort;
26
27
  exports.shouldSkipSideloadForFlag = shouldSkipSideloadForFlag;
27
28
  exports.winDeveloperManifestSubkey = winDeveloperManifestSubkey;
@@ -32,7 +33,7 @@ const fs_1 = __importDefault(require("fs"));
32
33
  const path_1 = __importDefault(require("path"));
33
34
  const os_1 = __importDefault(require("os"));
34
35
  const child_process_1 = require("child_process");
35
- const WEF_DEVELOPER_KEY = 'HKCU\\SOFTWARE\\Microsoft\\Office\\16.0\\WEF\\Developer';
36
+ exports.WEF_DEVELOPER_KEY = 'HKCU\\SOFTWARE\\Microsoft\\Office\\16.0\\WEF\\Developer';
36
37
  const MANIFESTS = [
37
38
  {
38
39
  guid: 'd1090951-50cf-4cf2-9d12-b0f8541d265c',
@@ -93,7 +94,7 @@ function prepareManifestForSideload(entry, sourcePath, options) {
93
94
  // Windows registry helpers
94
95
  // ---------------------------------------------------------------------------
95
96
  function winDeveloperManifestSubkey(guid) {
96
- return `${WEF_DEVELOPER_KEY}\\{${guid}}`;
97
+ return `${exports.WEF_DEVELOPER_KEY}\\{${guid}}`;
97
98
  }
98
99
  function parseRegSzValue(stdout) {
99
100
  const line = stdout.split(/\r?\n/).find(l => l.includes('REG_SZ'));
@@ -103,7 +104,7 @@ function parseRegSzValue(stdout) {
103
104
  return line.slice(idx + 'REG_SZ'.length).trim() || null;
104
105
  }
105
106
  function winRegisteredRootValue(guid) {
106
- const r = (0, child_process_1.spawnSync)('reg', ['query', WEF_DEVELOPER_KEY, '/v', guid], { encoding: 'utf8' });
107
+ const r = (0, child_process_1.spawnSync)('reg', ['query', exports.WEF_DEVELOPER_KEY, '/v', guid], { encoding: 'utf8' });
107
108
  if (r.status !== 0 || !r.stdout.includes('REG_SZ'))
108
109
  return null;
109
110
  // Output line looks like: " <guid> REG_SZ C:\path\to\manifest.xml"
@@ -122,14 +123,21 @@ function winRegisteredValue(guid) {
122
123
  // but treat the root value as the effective discovery registration.
123
124
  return winRegisteredRootValue(guid) ?? winRegisteredDefaultValue(guid);
124
125
  }
126
+ /**
127
+ * Test seam (issue #1589): the registry-write invocations go through this mutable,
128
+ * module-owned indirection point instead of a bare `spawnSync` call, so a test can swap in a
129
+ * fake implementation and assert on the exact `reg` command/arguments `sideloadManifest()`
130
+ * would invoke, without spawning a process, touching PATH, or mutating the real registry.
131
+ */
132
+ exports._internal = { spawnSync: child_process_1.spawnSync };
125
133
  function winWriteRegisteredValue(guid, manifestPath) {
126
- const root = (0, child_process_1.spawnSync)('reg', [
127
- 'add', WEF_DEVELOPER_KEY,
134
+ const root = exports._internal.spawnSync('reg', [
135
+ 'add', exports.WEF_DEVELOPER_KEY,
128
136
  '/v', guid, '/t', 'REG_SZ', '/d', manifestPath, '/f',
129
137
  ], { encoding: 'utf8' });
130
138
  if (root.status !== 0)
131
139
  return { ok: false, reason: root.stderr || `reg add failed for ${guid}` };
132
- const r = (0, child_process_1.spawnSync)('reg', [
140
+ const r = exports._internal.spawnSync('reg', [
133
141
  'add', winDeveloperManifestSubkey(guid),
134
142
  '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f',
135
143
  ], { encoding: 'utf8' });
@@ -137,7 +145,7 @@ function winWriteRegisteredValue(guid, manifestPath) {
137
145
  return { ok: false, reason: r.stderr || `reg add failed for ${guid}` };
138
146
  // Remove an accidental unbraced subkey left by older debugger settings. This
139
147
  // is different from the root named value above, which Word needs.
140
- (0, child_process_1.spawnSync)('reg', ['delete', `${WEF_DEVELOPER_KEY}\\${guid}`, '/f'], { encoding: 'utf8' });
148
+ exports._internal.spawnSync('reg', ['delete', `${exports.WEF_DEVELOPER_KEY}\\${guid}`, '/f'], { encoding: 'utf8' });
141
149
  return { ok: true };
142
150
  }
143
151
  // ---------------------------------------------------------------------------
@@ -195,8 +203,8 @@ function sideloadManifest(projectPath, options = {}) {
195
203
  function removeSideload() {
196
204
  for (const entry of MANIFESTS) {
197
205
  if (process.platform === 'win32') {
198
- (0, child_process_1.spawnSync)('reg', ['delete', WEF_DEVELOPER_KEY, '/v', entry.guid, '/f'], { encoding: 'utf8' });
199
- (0, child_process_1.spawnSync)('reg', ['delete', `${WEF_DEVELOPER_KEY}\\${entry.guid}`, '/f'], { encoding: 'utf8' });
206
+ (0, child_process_1.spawnSync)('reg', ['delete', exports.WEF_DEVELOPER_KEY, '/v', entry.guid, '/f'], { encoding: 'utf8' });
207
+ (0, child_process_1.spawnSync)('reg', ['delete', `${exports.WEF_DEVELOPER_KEY}\\${entry.guid}`, '/f'], { encoding: 'utf8' });
200
208
  (0, child_process_1.spawnSync)('reg', ['delete', winDeveloperManifestSubkey(entry.guid), '/f'], { encoding: 'utf8' });
201
209
  }
202
210
  else if (process.platform === 'darwin') {