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.
- package/dist/src/cli/commands/first-run.js +14 -1
- package/dist/src/cli/commands/sync.js +8 -1
- package/dist/src/first-run/server.js +62 -17
- package/dist/src/first-run/session-service.js +35 -8
- package/package.json +1 -1
- package/public/first-run/error-frame.js +11 -0
- package/public/first-run/script.js +13 -2
- package/public/first-run/styles.css +9 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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;
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
354
|
-
|
|
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;
|