fraim-framework 2.0.127 → 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/init-project.js +5 -1
- package/dist/src/cli/commands/sync.js +19 -5
- package/dist/src/first-run/install-state.js +7 -5
- package/dist/src/first-run/server.js +88 -38
- package/dist/src/first-run/session-service.js +665 -194
- package/dist/src/first-run/types.js +69 -12
- package/package.json +1 -1
- package/public/first-run/error-frame.js +100 -0
- package/public/first-run/index.html +35 -221
- package/public/first-run/script.js +428 -361
- package/public/first-run/styles.css +395 -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.'));
|
|
@@ -160,11 +160,15 @@ const createGitHubLabels = (projectRoot) => {
|
|
|
160
160
|
};
|
|
161
161
|
const runInitProject = async (options = {}) => {
|
|
162
162
|
console.log(chalk_1.default.blue('Initializing FRAIM project...'));
|
|
163
|
+
const failHard = options.failHard ?? 'exit';
|
|
163
164
|
const globalSetup = checkGlobalSetup();
|
|
164
165
|
if (!globalSetup.exists) {
|
|
165
166
|
console.log(chalk_1.default.red('Global FRAIM setup not found.'));
|
|
166
167
|
console.log(chalk_1.default.yellow('Please run global setup first:'));
|
|
167
168
|
console.log(chalk_1.default.cyan(' fraim setup'));
|
|
169
|
+
if (failHard === 'throw') {
|
|
170
|
+
throw new Error('Global FRAIM setup not found.');
|
|
171
|
+
}
|
|
168
172
|
process.exit(1);
|
|
169
173
|
}
|
|
170
174
|
const projectRoot = options.projectRoot || process.cwd();
|
|
@@ -312,7 +316,7 @@ const runInitProject = async (options = {}) => {
|
|
|
312
316
|
result.repositoryDetected = true;
|
|
313
317
|
}
|
|
314
318
|
if (!process.env.FRAIM_SKIP_SYNC) {
|
|
315
|
-
await (0, sync_1.runSync)({ projectRoot });
|
|
319
|
+
await (0, sync_1.runSync)({ projectRoot, failHard });
|
|
316
320
|
result.syncPerformed = true;
|
|
317
321
|
}
|
|
318
322
|
else {
|
|
@@ -101,7 +101,14 @@ function updateVersionInConfig(fraimDir) {
|
|
|
101
101
|
console.warn(chalk_1.default.yellow('Could not update version in config.json.'));
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
function failSync(mode, message) {
|
|
105
|
+
if (mode === 'throw') {
|
|
106
|
+
throw new Error(message);
|
|
107
|
+
}
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
104
110
|
const runSync = async (options) => {
|
|
111
|
+
const failHard = options.failHard ?? 'exit';
|
|
105
112
|
// Handle --global flag: sync to user-level ~/.fraim/ instead of project
|
|
106
113
|
if (options.global) {
|
|
107
114
|
console.log(chalk_1.default.blue('Syncing FRAIM content to user-level directory (~/.fraim/)...'));
|
|
@@ -112,7 +119,7 @@ const runSync = async (options) => {
|
|
|
112
119
|
}
|
|
113
120
|
catch (error) {
|
|
114
121
|
console.error(chalk_1.default.red(`User-level sync failed: ${error.message}`));
|
|
115
|
-
|
|
122
|
+
failSync(failHard, `User-level sync failed: ${error.message}`);
|
|
116
123
|
}
|
|
117
124
|
return;
|
|
118
125
|
}
|
|
@@ -136,7 +143,14 @@ const runSync = async (options) => {
|
|
|
136
143
|
console.log(chalk_1.default.cyan('Recommended: Use "npx fraim-framework@latest sync" instead.\n'));
|
|
137
144
|
}
|
|
138
145
|
const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
|
|
139
|
-
|
|
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) {
|
|
140
154
|
console.log(chalk_1.default.blue('Syncing FRAIM jobs from local server...'));
|
|
141
155
|
const localPort = process.env.FRAIM_MCP_PORT ? parseInt(process.env.FRAIM_MCP_PORT) : (0, git_utils_1.getPort)();
|
|
142
156
|
const localUrl = resolveExplicitLocalSyncUrl() || `http://localhost:${localPort}`;
|
|
@@ -158,7 +172,7 @@ const runSync = async (options) => {
|
|
|
158
172
|
}
|
|
159
173
|
console.error(chalk_1.default.red(`Local sync failed: ${result.error}`));
|
|
160
174
|
console.error(chalk_1.default.yellow('Make sure the FRAIM MCP server is running locally (npm run dev).'));
|
|
161
|
-
|
|
175
|
+
failSync(failHard, `Local sync failed: ${result.error}`);
|
|
162
176
|
}
|
|
163
177
|
let apiKey = loadUserApiKey() || config.apiKey || process.env.FRAIM_API_KEY;
|
|
164
178
|
if (!apiKey) {
|
|
@@ -170,7 +184,7 @@ const runSync = async (options) => {
|
|
|
170
184
|
console.error(chalk_1.default.red('No API key configured. Cannot sync.'));
|
|
171
185
|
console.error(chalk_1.default.yellow(`Set FRAIM_API_KEY in your environment, or add apiKey to ~/.fraim/config.json or ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('config.json')}`));
|
|
172
186
|
console.error(chalk_1.default.yellow('Or use --local to sync from a locally running FRAIM server.'));
|
|
173
|
-
|
|
187
|
+
failSync(failHard, 'No API key configured.');
|
|
174
188
|
}
|
|
175
189
|
}
|
|
176
190
|
console.log(chalk_1.default.blue('Syncing FRAIM jobs from remote server...'));
|
|
@@ -188,7 +202,7 @@ const runSync = async (options) => {
|
|
|
188
202
|
updateVersionInConfig(fraimDir);
|
|
189
203
|
return;
|
|
190
204
|
}
|
|
191
|
-
|
|
205
|
+
failSync(failHard, `Remote sync failed: ${result.error}`);
|
|
192
206
|
}
|
|
193
207
|
console.log(chalk_1.default.green(`Successfully synced ${result.employeeJobsSynced} ai-employee jobs, ${result.managerJobsSynced} ai-manager jobs, ${result.skillsSynced} skills, ${result.rulesSynced} rules, ${result.scriptsSynced} scripts, and ${result.docsSynced} docs from remote`));
|
|
194
208
|
updateVersionInConfig(fraimDir);
|
|
@@ -27,14 +27,12 @@ function maskInstallKey(key) {
|
|
|
27
27
|
function createInitialFirstRunState(key) {
|
|
28
28
|
const now = new Date().toISOString();
|
|
29
29
|
return {
|
|
30
|
-
version:
|
|
30
|
+
version: 2,
|
|
31
31
|
installKeyRef: maskInstallKey(key),
|
|
32
32
|
platform: process.platform,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
restartDeferredAgents: [],
|
|
33
|
+
agentId: 'claude-code',
|
|
34
|
+
rows: (0, types_1.createInitialRows)(),
|
|
36
35
|
resourcesUrl: types_1.FIRST_RUN_RESOURCES_URL,
|
|
37
|
-
stepStates: (0, types_1.createDefaultStepStates)(),
|
|
38
36
|
createdAt: now,
|
|
39
37
|
updatedAt: now,
|
|
40
38
|
};
|
|
@@ -46,6 +44,10 @@ function loadFirstRunState() {
|
|
|
46
44
|
}
|
|
47
45
|
try {
|
|
48
46
|
const state = JSON.parse(fs_1.default.readFileSync(statePath, 'utf8'));
|
|
47
|
+
// Reject persisted v1 state — schema changed materially in #352 v1.
|
|
48
|
+
if (state.version !== 2) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
49
51
|
if (typeof state.installKeyRef === 'string' && !state.installKeyRef.includes('...')) {
|
|
50
52
|
state.installKeyRef = maskInstallKey(state.installKeyRef);
|
|
51
53
|
}
|
|
@@ -8,6 +8,7 @@ const express_1 = __importDefault(require("express"));
|
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const child_process_1 = require("child_process");
|
|
11
|
+
const session_service_1 = require("./session-service");
|
|
11
12
|
function resolveFirstRunPublicDir() {
|
|
12
13
|
const candidates = [
|
|
13
14
|
path_1.default.resolve(process.cwd(), 'public/first-run'),
|
|
@@ -21,31 +22,65 @@ function resolveFirstRunPublicDir() {
|
|
|
21
22
|
}
|
|
22
23
|
throw new Error('Could not locate public/first-run assets.');
|
|
23
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
|
+
*/
|
|
24
41
|
function pickProjectPath() {
|
|
25
42
|
if (process.platform === 'win32') {
|
|
26
43
|
const script = [
|
|
27
44
|
'Add-Type -AssemblyName System.Windows.Forms',
|
|
28
45
|
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
46
|
+
'$dialog.Description = "Select a FRAIM project folder"',
|
|
29
47
|
'$dialog.ShowNewFolderButton = $true',
|
|
30
|
-
|
|
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) {',
|
|
31
55
|
' Write-Output $dialog.SelectedPath',
|
|
32
56
|
'}',
|
|
57
|
+
'$owner.Dispose()',
|
|
33
58
|
].join('; ');
|
|
34
|
-
|
|
35
|
-
encoding: 'utf8',
|
|
36
|
-
});
|
|
37
|
-
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
59
|
+
return runPickerProcess('powershell', ['-NoProfile', '-STA', '-Command', script]);
|
|
38
60
|
}
|
|
39
61
|
if (process.platform === 'darwin') {
|
|
40
|
-
|
|
41
|
-
encoding: 'utf8',
|
|
42
|
-
});
|
|
43
|
-
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")']);
|
|
44
63
|
}
|
|
45
|
-
|
|
46
|
-
|
|
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));
|
|
47
80
|
});
|
|
48
|
-
|
|
81
|
+
}
|
|
82
|
+
function isCanonicalRowId(value) {
|
|
83
|
+
return typeof value === 'string' && session_service_1.FIRST_RUN_ROW_IDS.includes(value);
|
|
49
84
|
}
|
|
50
85
|
class FirstRunServer {
|
|
51
86
|
constructor(options) {
|
|
@@ -100,26 +135,30 @@ class FirstRunServer {
|
|
|
100
135
|
this.app.get('/api/first-run/session', (_req, res) => {
|
|
101
136
|
res.json(this.sessionService.getSession());
|
|
102
137
|
});
|
|
103
|
-
this.app.post('/api/first-run/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
138
|
+
this.app.post('/api/first-run/rows/:rowId/run', async (req, res) => {
|
|
139
|
+
const { rowId } = req.params;
|
|
140
|
+
if (!isCanonicalRowId(rowId)) {
|
|
141
|
+
return res.status(400).json({ error: `Unknown row id: ${rowId}` });
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const result = await this.sessionService.runRow(rowId, req.body || {});
|
|
145
|
+
return res.json(result);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not run row.' });
|
|
109
149
|
}
|
|
110
|
-
return res.json(this.sessionService.selectAgent(req.body.agentId));
|
|
111
150
|
});
|
|
112
|
-
this.app.post('/api/first-run/
|
|
151
|
+
this.app.post('/api/first-run/agent/change', (req, res) => {
|
|
113
152
|
try {
|
|
114
|
-
return res.json(
|
|
153
|
+
return res.json(this.sessionService.changeAgent(req.body || {}));
|
|
115
154
|
}
|
|
116
155
|
catch (error) {
|
|
117
|
-
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not
|
|
156
|
+
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not change agent.' });
|
|
118
157
|
}
|
|
119
158
|
});
|
|
120
|
-
this.app.post('/api/first-run/project-path/pick', (_req, res) => {
|
|
159
|
+
this.app.post('/api/first-run/project-path/pick', async (_req, res) => {
|
|
121
160
|
try {
|
|
122
|
-
const selectedPath = pickProjectPath();
|
|
161
|
+
const selectedPath = await pickProjectPath();
|
|
123
162
|
if (!selectedPath) {
|
|
124
163
|
return res.status(204).end();
|
|
125
164
|
}
|
|
@@ -129,25 +168,36 @@ class FirstRunServer {
|
|
|
129
168
|
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
|
|
130
169
|
}
|
|
131
170
|
});
|
|
132
|
-
this.app.post('/api/first-run/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
179
|
+
const result = this.sessionService.finish();
|
|
180
|
+
return res.json(result);
|
|
181
|
+
});
|
|
182
|
+
// Hub-launch helper — starts an AiHubServer for the chosen project and
|
|
183
|
+
// opens the user's browser. v2 (#355) replaces the in-process spawn with
|
|
184
|
+
// a durable launcher binary that survives independently.
|
|
185
|
+
this.app.post('/api/first-run/open-hub', async (_req, res) => {
|
|
136
186
|
try {
|
|
137
|
-
|
|
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);
|
|
138
196
|
}
|
|
139
197
|
catch (error) {
|
|
140
|
-
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not
|
|
198
|
+
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open Hub.' });
|
|
141
199
|
}
|
|
142
200
|
});
|
|
143
|
-
this.app.post('/api/first-run/launch', (_req, res) => {
|
|
144
|
-
return res.json(this.sessionService.launchAndProbe());
|
|
145
|
-
});
|
|
146
|
-
this.app.post('/api/first-run/finish', (_req, res) => {
|
|
147
|
-
const result = this.sessionService.finish();
|
|
148
|
-
this.finishResolver?.();
|
|
149
|
-
return res.json(result);
|
|
150
|
-
});
|
|
151
201
|
}
|
|
152
202
|
}
|
|
153
203
|
exports.FirstRunServer = FirstRunServer;
|