fraim 2.0.362 → 2.0.363-next.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.
@@ -186,7 +186,7 @@ async function installManagedAgent(option, systemPath, deps) {
186
186
  if (!(await verifyPrefix(prefix))) {
187
187
  throw new Error(option.label + ' managed installed target failed verification. Standard install: ' + (standardInstallError || 'not requested'));
188
188
  }
189
- (0, persist_shell_path_1.persistShellPath)();
189
+ await (0, persist_shell_path_1.persistShellPath)({ signal: option.signal });
190
190
  (0, managed_agent_paths_1.invalidateAgentCommandRecovery)();
191
191
  return { outcome: 'managed' };
192
192
  }
@@ -8,9 +8,13 @@ exports.persistShellPath = persistShellPath;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const os_1 = __importDefault(require("os"));
10
10
  const path_1 = __importDefault(require("path"));
11
- const child_process_1 = require("child_process");
11
+ const background_command_1 = require("./background-command");
12
12
  const script_sync_utils_1 = require("./script-sync-utils");
13
13
  const managed_agent_paths_1 = require("./managed-agent-paths");
14
+ // Unconfirmed cleanup means the old writer may still be running. Both setup
15
+ // and managed installs must stop retrying until this process is restarted.
16
+ let cleanupFailure;
17
+ let registrationQueue = Promise.resolve();
14
18
  // Issue #1285: pure command-string builder extracted from persistShellPath()
15
19
  // so the generated PowerShell command's directory order (the current
16
20
  // versioned dir added before the legacy flat dir, per `bins`' own order —
@@ -24,14 +28,25 @@ function buildPersistShellPathWindowsCommand(bins) {
24
28
  `$ErrorActionPreference = 'Stop'`,
25
29
  ...assignments,
26
30
  `$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
31
+ `$originalPath = $cur`,
27
32
  `$parts = @($cur -split ';' | Where-Object { $_ })`,
28
33
  ...removals,
29
34
  ...additions,
30
35
  `$cur = ($parts | Select-Object -Unique) -join ';'`,
31
- `[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
36
+ `if ($cur -cne $originalPath) { [Environment]::SetEnvironmentVariable('PATH', $cur, 'User') }`,
32
37
  ].join('; ');
33
38
  }
34
- function persistShellPath() {
39
+ function persistShellPath(options = {}) {
40
+ // Configuration and separate agent installs share the same User PATH. Queue
41
+ // each caller with its own cancellation signal rather than overlapping writes.
42
+ const operation = registrationQueue.then(() => registerShellPath(options));
43
+ registrationQueue = operation.catch(() => undefined);
44
+ return operation;
45
+ }
46
+ async function registerShellPath(options) {
47
+ if (cleanupFailure)
48
+ throw cleanupFailure;
49
+ options.signal?.throwIfAborted();
35
50
  const marker = '# FRAIM managed binaries';
36
51
  const legacyExportLine = 'export PATH=”$HOME/.fraim/node/bin:$PATH”';
37
52
  // The stable CLI shim must outrank old prefix installs already on inherited PATH.
@@ -51,23 +66,34 @@ function persistShellPath() {
51
66
  const stanza = `\n${marker}\n${exportLine}\n`;
52
67
  if (process.platform === 'win32') {
53
68
  const psCmd = buildPersistShellPathWindowsCommand(bins);
54
- // Issue #1692: a sandboxed test run (row detection on load, node row
55
- // success, fraim row success all reach this function) must never write
69
+ // Issue #1692: a sandboxed test run must never write
56
70
  // its temp fake-home bin dirs into the real, registry-backed Windows
57
71
  // User PATH. FRAIM_TEST_SANDBOX is already the canonical "this process
58
72
  // is a sandboxed test run" signal set by every test runner (see
59
73
  // getUserFraimDirPath() in project-fraim-paths.ts) rather than a
60
74
  // dedicated flag for this call site.
61
75
  if (process.env.FRAIM_TEST_SANDBOX !== '1') {
62
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', timeout: 10000, windowsHide: true });
76
+ const result = await (0, background_command_1.runBackgroundCommand)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], {
77
+ signal: options.signal, timeoutMs: 10000,
78
+ });
63
79
  if (result.error || result.status !== 0) {
64
- throw new Error(`Could not register managed binaries on the User PATH: ${result.error?.message || result.stderr}`);
80
+ const details = result.error?.message || result.stderr.trim() || `PowerShell exited with status ${result.status}`;
81
+ if (result.outcome === 'cleanup-failed') {
82
+ cleanupFailure = Object.assign(new Error(`Could not stop Windows PATH registration: ${details}. Close the failed PowerShell process and restart FRAIM before trying again.`), {
83
+ code: result.outcome,
84
+ });
85
+ throw cleanupFailure;
86
+ }
87
+ throw Object.assign(new Error(`Could not register managed binaries on the User PATH: ${details}`), {
88
+ code: result.outcome,
89
+ });
65
90
  }
66
91
  }
67
92
  // Fall through to write POSIX profile files for Git Bash (VS Code
68
93
  // integrated terminal, Claude Code, etc.) which do not inherit the
69
94
  // Windows user PATH written above.
70
95
  }
96
+ options.signal?.throwIfAborted();
71
97
  // Test runners isolate FRAIM_USER_DIR but may leave os.homedir() unchanged.
72
98
  const sandboxDir = process.env.FRAIM_USER_DIR ? path_1.default.resolve(process.env.FRAIM_USER_DIR) : null;
73
99
  const profileHome = process.env.FRAIM_TEST_SANDBOX === '1'
@@ -107,7 +133,9 @@ function persistShellPath() {
107
133
  if (content.includes(marker)) {
108
134
  if (!content.includes(exportLine)) {
109
135
  // Replace stale runtime directories or an older shim ordering.
110
- fs_1.default.writeFileSync(profile, content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine), 'utf8');
136
+ const updated = content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine);
137
+ if (updated !== content)
138
+ fs_1.default.writeFileSync(profile, updated, 'utf8');
111
139
  }
112
140
  continue;
113
141
  }
@@ -139,7 +139,9 @@ function projectReviewLifecycle(state) {
139
139
  if (!review)
140
140
  return { ...view, displayState: job?.state === 'complete' ? 'complete' : 'working' };
141
141
  if (!review.decision || review.decision.status === 'changes_requested')
142
- return { ...view, displayState: 'waiting', attentionReason: review.decision ? 'Changes requested.' : 'Review the current result.' };
142
+ return review.revisionWork
143
+ ? { ...view, displayState: 'working' }
144
+ : { ...view, displayState: 'waiting', attentionReason: review.decision ? 'Changes requested.' : 'Review the current result.' };
143
145
  if (review.outcome?.status === 'failure' || review.outcome?.status === 'incomplete')
144
146
  return { ...view, displayState: 'waiting', attentionReason: review.outcome.summary };
145
147
  if (review.outcome?.status === 'success' || review.legacyCompletion)
@@ -141,6 +141,15 @@ class FirstRunServer {
141
141
  res.json({ status: 'ok', service: 'fraim-first-run' });
142
142
  });
143
143
  this.registerRoutes();
144
+ const apiErrorHandler = (error, req, res, next) => {
145
+ if (!req.path.startsWith('/api/first-run/') || res.headersSent)
146
+ return next(error);
147
+ const status = error?.type === 'entity.parse.failed' ? 400 : 500;
148
+ res.status(status).json({ error: status === 400
149
+ ? 'Invalid JSON request.'
150
+ : error instanceof Error ? error.message : 'First-run request failed.' });
151
+ };
152
+ this.app.use(apiErrorHandler);
144
153
  }
145
154
  async start(port) {
146
155
  this.sessionService.resumePendingOperations();
@@ -168,6 +168,7 @@ class FirstRunSessionService {
168
168
  this.operationAbort = new AbortController(); }
169
169
  cancelPendingOperations() { this.operationAbort.abort(); }
170
170
  constructor(options) {
171
+ this.rowOperations = new Map();
171
172
  this.operationAbort = new AbortController();
172
173
  // Issue #646: the key may be absent when first-run is launched by the macOS
173
174
  // installer (.dmg) instead of `fraim first-run --key=…`. In that case the
@@ -175,6 +176,7 @@ class FirstRunSessionService {
175
176
  this.key = options.key || '';
176
177
  this.headless = options.headless === true;
177
178
  this.embeddedDesktop = options.embeddedDesktop === true;
179
+ this.registerShellPath = options.registerShellPath ?? persist_shell_path_1.persistShellPath;
178
180
  this.fakeMode = getFakeStateMode();
179
181
  this.fakeStderr =
180
182
  process.env.FRAIM_FIRST_RUN_FAKE_STDERR ??
@@ -182,6 +184,10 @@ class FirstRunSessionService {
182
184
  this.requestToken = crypto_1.default.randomUUID();
183
185
  this.state = options.resume ? (0, install_state_1.loadFirstRunState)() || (0, install_state_1.createInitialFirstRunState)(options.key) : (0, install_state_1.createInitialFirstRunState)(options.key);
184
186
  this.state.rows = normalizeRows(this.state.rows);
187
+ const interrupted = this.getRow('fraim');
188
+ if (interrupted.status === 'in-progress') {
189
+ this.setRowError(interrupted, 'Setting up FRAIM on this machine', 'Setup was interrupted before it completed. Try again.', [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
190
+ }
185
191
  if (options.projectRoot) {
186
192
  this.state.workspacePath = path_1.default.resolve(options.projectRoot);
187
193
  (0, install_state_1.saveFirstRunState)(this.state);
@@ -261,7 +267,10 @@ class FirstRunSessionService {
261
267
  gitRow.verb = 'optional — only needed for code delivery workflows';
262
268
  }
263
269
  const fraimRow = this.getRow('fraim');
264
- if (this.embeddedDesktop || commandVersion('npx') !== null) {
270
+ if (fraimRow.status === 'error' || fraimRow.status === 'in-progress') {
271
+ // A prior config is not evidence that the current setup attempt completed.
272
+ }
273
+ else if (this.embeddedDesktop || commandVersion('npx') !== null) {
265
274
  // Only mark ok if setup has previously completed (config.json is written after autoConfigureMCP).
266
275
  // Without this, the wizard skips runFraimRow entirely and autoConfigureMCP never runs for IDEs.
267
276
  const globalConfigPath = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'config.json');
@@ -273,7 +282,6 @@ class FirstRunSessionService {
273
282
  fraimRow.status = 'pending';
274
283
  fraimRow.verb = "we'll configure your IDEs";
275
284
  }
276
- (0, persist_shell_path_1.persistShellPath)();
277
285
  }
278
286
  else {
279
287
  fraimRow.status = 'pending';
@@ -473,6 +481,19 @@ class FirstRunSessionService {
473
481
  * order, until every row is `ok` or surfaces an error.
474
482
  */
475
483
  async runRow(rowId, request = {}) {
484
+ const existing = this.rowOperations.get(rowId);
485
+ if (existing)
486
+ return existing;
487
+ const operation = this.executeRow(rowId, request);
488
+ this.rowOperations.set(rowId, operation);
489
+ try {
490
+ return await operation;
491
+ }
492
+ finally {
493
+ this.rowOperations.delete(rowId);
494
+ }
495
+ }
496
+ async executeRow(rowId, request) {
476
497
  const row = this.getRow(rowId);
477
498
  if (row.status === 'ok') {
478
499
  return this.respond(`${row.label} already ready.`, true);
@@ -523,7 +544,6 @@ class FirstRunSessionService {
523
544
  this.persist();
524
545
  return this.respond('FRAIM installer repair is required.', false);
525
546
  }
526
- (0, persist_shell_path_1.persistShellPath)();
527
547
  row.status = 'ok';
528
548
  row.verb = 'Node and FRAIM launchers ready';
529
549
  this.persist();
@@ -533,7 +553,6 @@ class FirstRunSessionService {
533
553
  // If node is missing here something is severely wrong; fall through to error.
534
554
  const ver = commandVersion('node');
535
555
  if (ver) {
536
- (0, persist_shell_path_1.persistShellPath)();
537
556
  row.status = 'ok';
538
557
  row.verb = `${ver} installed`;
539
558
  this.persist();
@@ -581,22 +600,21 @@ class FirstRunSessionService {
581
600
  this.persist();
582
601
  return this.respond('Fake-mode fraim ok.', true);
583
602
  }
584
- // FRAIM_SKIP_SYNC signals an isolated/test environment. Skip all external
585
- // writes (MCP config files, global config.json, slash commands) so tests
586
- // that auto-trigger this path cannot corrupt the real user config.
587
- if (process.env.FRAIM_SKIP_SYNC) {
588
- row.status = 'ok';
589
- row.verb = 'Ready.';
590
- this.persist();
591
- return this.respond('FRAIM configured.', true);
592
- }
593
603
  try {
594
- if (this.embeddedDesktop && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
604
+ if (!process.env.FRAIM_SKIP_SYNC && this.embeddedDesktop && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
595
605
  throw new Error('Stable FRAIM launchers are missing. Re-run the native installer in repair mode.');
596
606
  }
597
- if (!this.embeddedDesktop)
607
+ if (!process.env.FRAIM_SKIP_SYNC && !this.embeddedDesktop)
598
608
  (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
599
- (0, persist_shell_path_1.persistShellPath)();
609
+ await this.registerShellPath({ signal: this.operationAbort.signal });
610
+ // Tests isolate PATH with FRAIM_TEST_SANDBOX; this flag skips remote sync
611
+ // and IDE/global configuration while retaining the setup lifecycle.
612
+ if (process.env.FRAIM_SKIP_SYNC) {
613
+ row.status = 'ok';
614
+ row.verb = 'Ready.';
615
+ this.persist();
616
+ return this.respond('FRAIM configured.', true);
617
+ }
600
618
  const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
601
619
  if (detectedIDEs.length > 0) {
602
620
  await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, detectedIDEs.map((ide) => ide.name));
@@ -835,6 +853,9 @@ class FirstRunSessionService {
835
853
  };
836
854
  }
837
855
  async finishAgentRecruiting() {
856
+ const existing = this.rowOperations.get('fraim');
857
+ if (existing)
858
+ return existing;
838
859
  this.updateAgentSummaryRow();
839
860
  const readyCount = this.state.setupResult?.detectedSurfaceCount || 0;
840
861
  if (readyCount < 1) {
@@ -846,7 +867,7 @@ class FirstRunSessionService {
846
867
  fraimRow.verb = 'configuring FRAIM for local AI agents';
847
868
  this.persist();
848
869
  appendInstallLog(`agent-recruiting-complete ready=${readyCount}`);
849
- return this.runFraimRow();
870
+ return this.runRow('fraim');
850
871
  }
851
872
  openTerminalWithCommand(command) {
852
873
  if (process.platform === 'win32') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.362",
3
+ "version": "2.0.363-next.1",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  </header>
16
16
 
17
17
  <section class="card">
18
- <ul class="checklist" id="checklist" data-testid="setup-checklist" aria-label="FRAIM setup checklist">
18
+ <div id="checklist" data-testid="setup-checklist">
19
+ <ul class="checklist" aria-label="FRAIM setup checklist">
19
20
  <!-- Skeleton rows shown until the async session-load completes.
20
21
  Replaced wholesale by script.js once /api/first-run/session returns. -->
21
22
  <li class="row skeleton-row" data-row-id="node" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">Node.js</span><span class="verb" data-testid="row-verb">checking...</span></li>
@@ -23,6 +24,7 @@
23
24
  <li class="row skeleton-row" data-row-id="agent" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">Locally installed AI agents</span><span class="verb" data-testid="row-verb">checking...</span></li>
24
25
  <li class="row skeleton-row" data-row-id="fraim" data-row-status="pending"><span class="icon" aria-hidden="true">.</span><span class="label">FRAIM</span><span class="verb" data-testid="row-verb">checking...</span></li>
25
26
  </ul>
27
+ </div>
26
28
  <div class="actions">
27
29
  <button id="primary-button" class="primary-button" data-testid="primary-button">Set up FRAIM</button>
28
30
  </div>
@@ -8,6 +8,7 @@
8
8
 
9
9
  const state = {
10
10
  session: null,
11
+ sessionLoadInFlight: false,
11
12
  activeStep: 'prereqs',
12
13
  configureInFlight: false,
13
14
  configureError: null,
@@ -45,8 +46,19 @@
45
46
  body: body !== undefined ? JSON.stringify(body) : undefined,
46
47
  });
47
48
  if (response.status === 204) return null;
48
- const json = await response.json();
49
- if (!response.ok) throw new Error(json && json.error ? json.error : `Request failed (status ${response.status}).`);
49
+ let json;
50
+ try {
51
+ json = await response.json();
52
+ } catch {
53
+ throw new Error(response.ok
54
+ ? 'FRAIM returned an invalid response. Please try again.'
55
+ : `Request failed (status ${response.status}). Please try again.`);
56
+ }
57
+ if (!response.ok) {
58
+ throw new Error(json && typeof json.error === 'string' && json.error.trim()
59
+ ? json.error
60
+ : `Request failed (status ${response.status}). Please try again.`);
61
+ }
50
62
  return json;
51
63
  }
52
64
 
@@ -632,6 +644,46 @@
632
644
  }
633
645
  }
634
646
 
647
+ function renderSessionLoadFailure(message) {
648
+ CHECKLIST_EL.className = 'setup-shell setup-shell--single';
649
+ CHECKLIST_EL.innerHTML = '';
650
+ PRIMARY_BUTTON.style.display = 'none';
651
+ setHeader('Set up FRAIM', 'Retry to check your machine and continue setup.');
652
+ setStatus('');
653
+
654
+ const pane = document.createElement('div');
655
+ pane.className = 'setup-pane';
656
+ const heading = document.createElement('h2');
657
+ heading.textContent = 'Could not load setup';
658
+ const error = document.createElement('div');
659
+ error.className = 'ready-strip ready-strip--error';
660
+ error.setAttribute('role', 'alert');
661
+ error.textContent = message;
662
+ const retry = button('Retry', 'primary');
663
+ retry.addEventListener('click', () => startSessionLoad(retry));
664
+ pane.append(heading, error, retry);
665
+ CHECKLIST_EL.appendChild(pane);
666
+ }
667
+
668
+ async function startSessionLoad(retryButton) {
669
+ if (state.sessionLoadInFlight) return;
670
+ state.sessionLoadInFlight = true;
671
+ PRIMARY_BUTTON.disabled = true;
672
+ if (retryButton) {
673
+ retryButton.disabled = true;
674
+ retryButton.textContent = 'Retrying…';
675
+ }
676
+ try {
677
+ await loadSession();
678
+ setStatus('');
679
+ } catch (err) {
680
+ renderSessionLoadFailure(err.message || 'Could not load setup. Please try again.');
681
+ } finally {
682
+ state.sessionLoadInFlight = false;
683
+ PRIMARY_BUTTON.disabled = false;
684
+ }
685
+ }
686
+
635
687
  // Issue #646: when first-run was launched without a key (the no-terminal macOS
636
688
  // installer path), gate the whole wizard behind a paste-your-key step.
637
689
  function renderKeyEntry() {
@@ -717,11 +769,13 @@
717
769
  }
718
770
 
719
771
  PRIMARY_BUTTON.addEventListener('click', () => {
772
+ if (!state.session) {
773
+ startSessionLoad();
774
+ return;
775
+ }
720
776
  state.activeStep = chooseActiveStep();
721
777
  render();
722
778
  });
723
779
 
724
- loadSession().catch((err) => {
725
- setStatus(err.message || 'Could not load first-run.', 'error');
726
- });
780
+ startSessionLoad();
727
781
  }());
@@ -430,6 +430,9 @@ body {
430
430
  grid-template-columns: minmax(190px, 0.85fr) minmax(0, 2fr);
431
431
  gap: 20px;
432
432
  }
433
+ .setup-shell--single {
434
+ grid-template-columns: minmax(0, 1fr);
435
+ }
433
436
  .setup-steps {
434
437
  display: flex;
435
438
  flex-direction: column;
@@ -619,6 +622,7 @@ body {
619
622
  color: var(--muted);
620
623
  }
621
624
  .ready-strip--error {
625
+ overflow-wrap: anywhere;
622
626
  background: #fff1f2;
623
627
  border-color: #fb7185;
624
628
  color: #9f1239;