fraim-framework 2.0.128 → 2.0.129

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.
@@ -13,7 +13,13 @@ const server_2 = require("../../ai-hub/server");
13
13
  function openBrowser(url) {
14
14
  try {
15
15
  if (process.platform === 'win32') {
16
- (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`], { detached: true, stdio: 'ignore' }).unref();
16
+ // `cmd.exe /c start "" "<url>"` works in most cases but mis-parses on
17
+ // some Windows configurations (cmd treats the URL's `//` as a UNC
18
+ // path and the user sees a "Windows cannot find" dialog). The
19
+ // `rundll32 url.dll,FileProtocolHandler <url>` pattern is the
20
+ // documented protocol-handler entry point and bypasses cmd.exe
21
+ // entirely.
22
+ (0, child_process_1.spawn)('rundll32', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore' }).unref();
17
23
  return;
18
24
  }
19
25
  if (process.platform === 'darwin') {
@@ -47,6 +53,13 @@ const runFirstRun = async (options) => {
47
53
  if (!options.headless) {
48
54
  openBrowser(url);
49
55
  }
56
+ // Block forever — the wizard's /open-hub endpoint starts the Hub
57
+ // server in-process. Stopping the server here would kill the Hub.
58
+ // The user closes the terminal (Ctrl+C) when they're done. v2 (#355)
59
+ // replaces this in-process model with a detached launcher binary so
60
+ // the wizard CLI can exit cleanly while the Hub keeps running.
61
+ console.log(chalk_1.default.gray('When you finish the wizard, the Hub will be served from this terminal.'));
62
+ console.log(chalk_1.default.gray('Press Ctrl+C to stop everything when you are done.'));
50
63
  await server.waitForFinish();
51
64
  await server.stop();
52
65
  console.log(chalk_1.default.green('FRAIM first-run completed.'));
@@ -143,7 +143,14 @@ const runSync = async (options) => {
143
143
  console.log(chalk_1.default.cyan('Recommended: Use "npx fraim-framework@latest sync" instead.\n'));
144
144
  }
145
145
  const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
146
- if (options.local) {
146
+ // Allow `FRAIM_LOCAL_SYNC=1` to flip into local-mode without needing
147
+ // the --local CLI flag. The FRE's runProjectRow path doesn't surface
148
+ // a --local flag, but devs validating the FRE locally need a way to
149
+ // point sync at their localhost MCP server. With this env var set,
150
+ // any caller (including the FRE) routes through the local sync path
151
+ // exactly as if the user had passed --local.
152
+ const useLocal = options.local || process.env.FRAIM_LOCAL_SYNC === '1';
153
+ if (useLocal) {
147
154
  console.log(chalk_1.default.blue('Syncing FRAIM jobs from local server...'));
148
155
  const localPort = process.env.FRAIM_MCP_PORT ? parseInt(process.env.FRAIM_MCP_PORT) : (0, git_utils_1.getPort)();
149
156
  const localUrl = resolveExplicitLocalSyncUrl() || `http://localhost:${localPort}`;
@@ -22,31 +22,62 @@ function resolveFirstRunPublicDir() {
22
22
  }
23
23
  throw new Error('Could not locate public/first-run assets.');
24
24
  }
25
+ /**
26
+ * Open the platform's native folder picker and return the chosen path
27
+ * (or `null` if the user cancelled).
28
+ *
29
+ * Implementation notes:
30
+ * - Async (`spawn`, not `spawnSync`). The folder dialog blocks until the
31
+ * user dismisses it, which can be many seconds. With `spawnSync` the
32
+ * entire Node event loop freezes during that time — every other HTTP
33
+ * request to the FRE server gets `ERR_ABORTED`, and the FRE looks dead.
34
+ * - Windows: PowerShell needs `-STA` (Single-Threaded Apartment) for
35
+ * `System.Windows.Forms.FolderBrowserDialog` to work. We also create a
36
+ * hidden `$owner` form with `TopMost = $true` and pass it as the
37
+ * dialog's owner so the picker comes to the foreground instead of
38
+ * appearing behind the browser (which made the Browse button look
39
+ * broken).
40
+ */
25
41
  function pickProjectPath() {
26
42
  if (process.platform === 'win32') {
27
43
  const script = [
28
44
  'Add-Type -AssemblyName System.Windows.Forms',
29
45
  '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
46
+ '$dialog.Description = "Select a FRAIM project folder"',
30
47
  '$dialog.ShowNewFolderButton = $true',
31
- 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
48
+ // Hidden owner form forces the dialog above the user's browser. Without
49
+ // this the dialog often appears behind the browser tab and the user
50
+ // sees nothing happen when they click Browse.
51
+ '$owner = New-Object System.Windows.Forms.Form',
52
+ '$owner.TopMost = $true',
53
+ '$owner.ShowInTaskbar = $false',
54
+ 'if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {',
32
55
  ' Write-Output $dialog.SelectedPath',
33
56
  '}',
57
+ '$owner.Dispose()',
34
58
  ].join('; ');
35
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-Command', script], {
36
- encoding: 'utf8',
37
- });
38
- return result.status === 0 ? result.stdout.trim() || null : null;
59
+ return runPickerProcess('powershell', ['-NoProfile', '-STA', '-Command', script]);
39
60
  }
40
61
  if (process.platform === 'darwin') {
41
- const result = (0, child_process_1.spawnSync)('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'], {
42
- encoding: 'utf8',
43
- });
44
- return result.status === 0 ? result.stdout.trim() || null : null;
62
+ return runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")']);
45
63
  }
46
- const result = (0, child_process_1.spawnSync)('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'], {
47
- encoding: 'utf8',
64
+ return runPickerProcess('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null']);
65
+ }
66
+ function runPickerProcess(command, args) {
67
+ return new Promise((resolve) => {
68
+ const proc = (0, child_process_1.spawn)(command, args, {
69
+ stdio: ['ignore', 'pipe', 'pipe'],
70
+ windowsHide: true,
71
+ });
72
+ let stdout = '';
73
+ proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
74
+ proc.stderr?.on('data', () => { });
75
+ proc.on('close', () => {
76
+ const trimmed = stdout.trim();
77
+ resolve(trimmed || null);
78
+ });
79
+ proc.on('error', () => resolve(null));
48
80
  });
49
- return result.status === 0 ? result.stdout.trim() || null : null;
50
81
  }
51
82
  function isCanonicalRowId(value) {
52
83
  return typeof value === 'string' && session_service_1.FIRST_RUN_ROW_IDS.includes(value);
@@ -125,9 +156,9 @@ class FirstRunServer {
125
156
  return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not change agent.' });
126
157
  }
127
158
  });
128
- this.app.post('/api/first-run/project-path/pick', (_req, res) => {
159
+ this.app.post('/api/first-run/project-path/pick', async (_req, res) => {
129
160
  try {
130
- const selectedPath = pickProjectPath();
161
+ const selectedPath = await pickProjectPath();
131
162
  if (!selectedPath) {
132
163
  return res.status(204).end();
133
164
  }
@@ -138,16 +169,30 @@ class FirstRunServer {
138
169
  }
139
170
  });
140
171
  this.app.post('/api/first-run/finish', (_req, res) => {
172
+ // Note: /finish writes the next-prompt artifact but does NOT trigger
173
+ // process shutdown. In v1 the Hub server is started in-process by
174
+ // /open-hub — if we resolved the finishPromise here, runFirstRun
175
+ // would call server.stop() and the parent process would exit,
176
+ // taking the just-started Hub down with it. The Hub handoff has
177
+ // to keep the parent alive. The CLI exits when the user Ctrl+Cs
178
+ // the terminal. v2 (#355) replaces this with a detached launcher.
141
179
  const result = this.sessionService.finish();
142
- this.finishResolver?.();
143
180
  return res.json(result);
144
181
  });
145
182
  // Hub-launch helper — starts an AiHubServer for the chosen project and
146
183
  // opens the user's browser. v2 (#355) replaces the in-process spawn with
147
- // a durable launcher binary.
184
+ // a durable launcher binary that survives independently.
148
185
  this.app.post('/api/first-run/open-hub', async (_req, res) => {
149
186
  try {
150
- return res.json(await this.sessionService.openHub());
187
+ const result = await this.sessionService.openHub();
188
+ // Write the next-prompt artifact as a side effect of opening the
189
+ // Hub so the client doesn't need a separate /finish call. We
190
+ // intentionally do NOT resolve the finishPromise — see /finish
191
+ // handler comment above.
192
+ if (result.ok) {
193
+ this.sessionService.finish();
194
+ }
195
+ return res.json(result);
151
196
  }
152
197
  catch (error) {
153
198
  return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open Hub.' });
@@ -139,10 +139,33 @@ class FirstRunSessionService {
139
139
  delete row.errorFrame;
140
140
  delete row.streamOutput;
141
141
  }
142
- setRowError(row, whatTried, whatHappened, actions) {
142
+ setRowError(row, whatTried, whatHappened, actions, hint) {
143
143
  row.status = 'error';
144
144
  row.verb = 'failed — see below';
145
- row.errorFrame = { whatTried, whatHappened, actions };
145
+ row.errorFrame = { whatTried, whatHappened, actions, ...(hint ? { hint } : {}) };
146
+ }
147
+ /**
148
+ * Classify a thrown init/sync error into a non-tech-friendly hint.
149
+ * The verbatim stderr is preserved separately in `whatHappened` per
150
+ * R6.3 — the hint is additive context, not a replacement.
151
+ */
152
+ classifyInitError(detail) {
153
+ if (/status code 401|Unauthorized|401\b/i.test(detail)) {
154
+ return "Your FRAIM install key was rejected by the registry. If your key expired, get a new one from your account page and re-run setup with the new key. If you're testing locally, set `FRAIM_LOCAL_SYNC=1` so the project row syncs from your local FRAIM server instead of the production registry.";
155
+ }
156
+ if (/status code 403|Forbidden/i.test(detail)) {
157
+ return "The FRAIM registry refused this key for this project. Check that your install key has access to the registry you're syncing from.";
158
+ }
159
+ if (/status code 5\d\d/i.test(detail) || /5\d\d\b/.test(detail)) {
160
+ return 'The FRAIM registry returned a server error. This is usually transient — wait a minute and click Retry. If it keeps failing, the registry may be down.';
161
+ }
162
+ if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|getaddrinfo|network/i.test(detail)) {
163
+ return "Couldn't reach the FRAIM registry. Check your internet connection (or if you're testing locally, that the local FRAIM server is running) and click Retry.";
164
+ }
165
+ if (/Global FRAIM setup not found/i.test(detail)) {
166
+ return 'FRAIM\'s global config is missing. This usually means the agent step didn\'t complete cleanly — go back and re-run the AI agent row.';
167
+ }
168
+ return undefined;
146
169
  }
147
170
  detectRowsOnLoad() {
148
171
  if (this.fakeMode) {
@@ -586,11 +609,11 @@ class FirstRunSessionService {
586
609
  const detail = initError instanceof Error ? initError.message : 'Unknown init error';
587
610
  // Skip is intentionally omitted on the project row — without a
588
611
  // project there is no Hub to open, so deferring this row would
589
- // leave the user wedged. Retry is always offered; if the failure
590
- // is a 401 from remote sync, the verbatim message tells the user
591
- // their API key is the problem and they need to re-run with a
592
- // valid key.
593
- this.setRowError(row, `Initializing FRAIM in ${resolvedPath}`, detail, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
612
+ // leave the user wedged. Retry is always offered; the hint
613
+ // translates the verbatim error (e.g. "Request failed with
614
+ // status code 401") into something a non-tech user can act on.
615
+ const hint = this.classifyInitError(detail);
616
+ this.setRowError(row, `Initializing FRAIM in ${resolvedPath}`, detail, [{ id: 'retry', label: 'Retry', variant: 'primary' }], hint);
594
617
  appendInstallLog(`project-init-failed ${resolvedPath}: ${detail}`);
595
618
  this.persist();
596
619
  return this.respond(`Initialization failed: ${detail}`, false);
@@ -728,7 +751,11 @@ class FirstRunSessionService {
728
751
  openBrowser(url) {
729
752
  try {
730
753
  if (process.platform === 'win32') {
731
- (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`], { detached: true, stdio: 'ignore' }).unref();
754
+ // `cmd.exe /c start "" "<url>"` mis-parses on some Windows hosts
755
+ // (cmd interprets the URL's `//` as a UNC path -> "Windows cannot
756
+ // find" popup). rundll32 + url.dll,FileProtocolHandler is the
757
+ // documented protocol-handler entry point and bypasses cmd entirely.
758
+ (0, child_process_1.spawn)('rundll32', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore' }).unref();
732
759
  return;
733
760
  }
734
761
  if (process.platform === 'darwin') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.128",
3
+ "version": "2.0.129",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -43,6 +43,17 @@
43
43
  whatTried.textContent = escapeText(frame.whatTried || '');
44
44
  root.appendChild(whatTried);
45
45
 
46
+ // Optional plain-language hint rendered ABOVE the verbatim stderr so
47
+ // a non-tech user reads "your install key was rejected — get a new
48
+ // one" before they parse `Request failed with status code 401`.
49
+ if (frame.hint) {
50
+ const hint = document.createElement('div');
51
+ hint.className = 'error-hint';
52
+ hint.setAttribute('data-testid', 'error-hint');
53
+ hint.textContent = escapeText(frame.hint);
54
+ root.appendChild(hint);
55
+ }
56
+
46
57
  const { visible, hidden } = truncateToLastLines(frame.whatHappened || '', 12);
47
58
  const whatHappened = document.createElement('pre');
48
59
  whatHappened.className = 'what-happened';
@@ -350,11 +350,22 @@
350
350
 
351
351
  if (allOk || skipPathDone) {
352
352
  try {
353
- const finishResp = await api('/api/first-run/finish', 'POST');
354
- setStatus(finishResp.message);
353
+ // /open-hub starts the Hub server in-process AND writes the
354
+ // next-prompt artifact (the work /finish used to do). We do NOT
355
+ // call /finish separately — that would race against the Hub
356
+ // start (server.stop() would kill the just-spawned Hub).
357
+ PRIMARY_BUTTON.disabled = true;
358
+ setStatus('Opening Hub…');
355
359
  const openResp = await api('/api/first-run/open-hub', 'POST');
356
360
  if (openResp && openResp.message) setStatus(openResp.message);
361
+ if (openResp && openResp.hubUrl) {
362
+ // Redirect this tab to the Hub. The Hub server is in-process
363
+ // with the wizard server, so navigating away keeps the same
364
+ // Node process serving — no broken handoff.
365
+ window.location.replace(openResp.hubUrl);
366
+ }
357
367
  } catch (err) {
368
+ PRIMARY_BUTTON.disabled = false;
358
369
  setStatus(err.message, 'error');
359
370
  }
360
371
  return;
@@ -316,6 +316,15 @@ body {
316
316
  color: var(--danger);
317
317
  font-weight: 600;
318
318
  }
319
+ .error-frame .error-hint {
320
+ color: var(--text);
321
+ font-size: 14px;
322
+ line-height: 1.45;
323
+ background: var(--warn-soft);
324
+ border: 1px solid #f0d8b3;
325
+ border-radius: 8px;
326
+ padding: 10px 12px;
327
+ }
319
328
  .error-frame .what-happened {
320
329
  background: #0d1410;
321
330
  color: #f1c0c0;