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.
@@ -1,9 +1,42 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.FirstRunSessionService = void 0;
39
+ exports.FIRST_RUN_ROW_IDS = exports.FirstRunSessionService = void 0;
7
40
  const fs_1 = __importDefault(require("fs"));
8
41
  const os_1 = __importDefault(require("os"));
9
42
  const path_1 = __importDefault(require("path"));
@@ -15,7 +48,17 @@ const setup_1 = require("../cli/commands/setup");
15
48
  const init_project_1 = require("../cli/commands/init-project");
16
49
  const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
17
50
  const types_1 = require("./types");
51
+ Object.defineProperty(exports, "FIRST_RUN_ROW_IDS", { enumerable: true, get: function () { return types_1.FIRST_RUN_ROW_IDS; } });
18
52
  const install_state_1 = require("./install-state");
53
+ function getFakeStateMode() {
54
+ if (process.env.FRAIM_FIRST_RUN_FAKE !== '1')
55
+ return null;
56
+ const explicit = process.env.FRAIM_FIRST_RUN_FAKE_STATE;
57
+ if (explicit === 'all-pending' || explicit === 'all-detected' || explicit === 'agent-install-fails') {
58
+ return explicit;
59
+ }
60
+ return 'default';
61
+ }
19
62
  function commandExists(command) {
20
63
  const executable = process.platform === 'win32' ? 'cmd.exe' : command;
21
64
  const args = process.platform === 'win32'
@@ -28,6 +71,20 @@ function commandExists(command) {
28
71
  });
29
72
  return result.status === 0;
30
73
  }
74
+ function commandVersion(command) {
75
+ const executable = process.platform === 'win32' ? 'cmd.exe' : command;
76
+ const args = process.platform === 'win32'
77
+ ? ['/d', '/s', '/c', `${command} --version`]
78
+ : ['--version'];
79
+ const result = (0, child_process_1.spawnSync)(executable, args, {
80
+ encoding: 'utf8',
81
+ timeout: 5000,
82
+ });
83
+ if (result.status !== 0)
84
+ return null;
85
+ const text = `${result.stdout || ''}${result.stderr || ''}`.split(/\r?\n/)[0]?.trim() || null;
86
+ return text;
87
+ }
31
88
  function ensureOutputDirs() {
32
89
  fs_1.default.mkdirSync((0, script_sync_utils_1.getUserFraimDir)(), { recursive: true });
33
90
  fs_1.default.mkdirSync(path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install'), { recursive: true });
@@ -42,261 +99,675 @@ function appendInstallLog(line) {
42
99
  ensureOutputDirs();
43
100
  fs_1.default.appendFileSync(getInstallLogPath(), `[${new Date().toISOString()}] ${line}${os_1.default.EOL}`);
44
101
  }
45
- function getLaunchInstruction(agentId, projectRoot) {
46
- const cwd = projectRoot || process.cwd();
47
- switch (agentId) {
48
- case 'claude-code':
49
- return `cd "${cwd}" && claude`;
50
- case 'codex':
51
- return `cd "${cwd}" && codex`;
52
- case 'gemini-cli':
53
- return `cd "${cwd}" && gemini`;
54
- default:
55
- return `cd "${cwd}"`;
56
- }
57
- }
58
- function maybeLaunchInteractiveShell(agentId, projectRoot) {
59
- const commandLine = getLaunchInstruction(agentId, projectRoot);
60
- try {
61
- if (process.platform === 'win32') {
62
- (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', `start "" cmd /k ${commandLine}`], {
63
- detached: true,
64
- stdio: 'ignore',
65
- }).unref();
66
- return true;
67
- }
68
- if (process.platform === 'darwin') {
69
- (0, child_process_1.spawn)('open', ['-a', 'Terminal', projectRoot], {
70
- detached: true,
71
- stdio: 'ignore',
72
- }).unref();
73
- return true;
74
- }
75
- (0, child_process_1.spawn)('x-terminal-emulator', ['-e', 'bash', '-lc', commandLine], {
76
- detached: true,
77
- stdio: 'ignore',
78
- }).unref();
79
- return true;
80
- }
81
- catch {
82
- return false;
83
- }
102
+ function findAgentOption(agentId) {
103
+ return types_1.FIRST_RUN_AGENT_OPTIONS.find((option) => option.id === agentId);
84
104
  }
85
- function probeCodex(projectRoot) {
86
- const result = (0, child_process_1.spawnSync)('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'], {
87
- cwd: projectRoot,
88
- input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
89
- encoding: 'utf8',
90
- timeout: 60000,
91
- });
92
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
93
- return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
94
- }
95
- function probeClaude(projectRoot) {
96
- const result = (0, child_process_1.spawnSync)('claude', ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'], {
97
- cwd: projectRoot,
98
- input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
99
- encoding: 'utf8',
100
- timeout: 60000,
101
- });
102
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
103
- return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
104
- }
105
- function probeGemini(projectRoot) {
106
- const result = (0, child_process_1.spawnSync)('gemini', ['--help'], {
107
- cwd: projectRoot,
108
- encoding: 'utf8',
109
- timeout: 30000,
110
- });
111
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
112
- return {
113
- ok: result.status === 0,
114
- output: output || 'Gemini CLI responded to --help; runtime FRAIM probe still requires manual confirmation.',
115
- };
105
+ function pendingVerbForAgent(agentId) {
106
+ const option = findAgentOption(agentId);
107
+ if (!option)
108
+ return "we'll set up the recommended agent";
109
+ if (agentId === 'claude-code')
110
+ return "we'll set up Claude Code (recommended)";
111
+ return `we'll set up ${option.label}`;
116
112
  }
117
113
  class FirstRunSessionService {
118
114
  constructor(options) {
119
115
  this.key = options.key;
120
116
  this.headless = options.headless === true;
121
- this.fakeMode = process.env.FRAIM_FIRST_RUN_FAKE === '1';
117
+ this.fakeMode = getFakeStateMode();
118
+ this.fakeStderr =
119
+ process.env.FRAIM_FIRST_RUN_FAKE_STDERR ??
120
+ ['npm error code EACCES', 'npm error syscall mkdir'].join('\n');
122
121
  this.requestToken = crypto_1.default.randomUUID();
123
122
  this.state = options.resume ? (0, install_state_1.loadFirstRunState)() || (0, install_state_1.createInitialFirstRunState)(options.key) : (0, install_state_1.createInitialFirstRunState)(options.key);
124
123
  if (options.projectRoot) {
125
124
  this.state.workspacePath = path_1.default.resolve(options.projectRoot);
126
- this.state.stepStates.project = 'running';
127
125
  (0, install_state_1.saveFirstRunState)(this.state);
128
126
  }
129
127
  }
130
128
  persist() {
131
129
  (0, install_state_1.saveFirstRunState)(this.state);
132
130
  }
133
- markStep(step, status) {
134
- this.state.stepStates[step] = status;
135
- this.persist();
131
+ getRow(rowId) {
132
+ const row = this.state.rows.find((r) => r.id === rowId);
133
+ if (!row) {
134
+ throw new Error(`Row ${rowId} not found in state — initial rows must include all canonical IDs`);
135
+ }
136
+ return row;
137
+ }
138
+ clearRowError(row) {
139
+ delete row.errorFrame;
140
+ delete row.streamOutput;
141
+ }
142
+ setRowError(row, whatTried, whatHappened, actions, hint) {
143
+ row.status = 'error';
144
+ row.verb = 'failed — see below';
145
+ row.errorFrame = { whatTried, whatHappened, actions, ...(hint ? { hint } : {}) };
136
146
  }
137
- detectAgents() {
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;
169
+ }
170
+ detectRowsOnLoad() {
138
171
  if (this.fakeMode) {
139
- return types_1.FIRST_RUN_AGENT_OPTIONS.map((agent) => ({
140
- id: agent.id,
141
- label: agent.label,
142
- detected: true,
143
- detail: 'Fake test-mode agent available.',
144
- }));
145
- }
146
- const detected = (0, ide_detector_1.detectInstalledIDEs)();
147
- return types_1.FIRST_RUN_AGENT_OPTIONS.map((agent) => {
148
- const ide = detected.find((entry) => entry.aliases?.some((alias) => agent.detectAliases.includes(alias)) ||
149
- agent.detectAliases.some((alias) => entry.name.toLowerCase().includes(alias)));
150
- return {
151
- id: agent.id,
152
- label: agent.label,
153
- detected: Boolean(ide) || commandExists(agent.launchCommand),
154
- detail: ide ? `Config path: ${ide.configPath}` : 'CLI/config not detected yet.',
155
- };
156
- });
172
+ this.applyFakeStateOnLoad(this.fakeMode);
173
+ return;
174
+ }
175
+ // Real detection — populate row statuses from `command -v` style probes.
176
+ const nodeRow = this.getRow('node');
177
+ const nodeVer = commandVersion('node');
178
+ if (nodeVer) {
179
+ nodeRow.status = 'ok';
180
+ nodeRow.verb = `${nodeVer} detected`;
181
+ }
182
+ else {
183
+ nodeRow.status = 'pending';
184
+ nodeRow.verb = "we'll install";
185
+ }
186
+ const gitRow = this.getRow('git');
187
+ const gitVer = commandVersion('git');
188
+ if (gitVer) {
189
+ gitRow.status = 'ok';
190
+ gitRow.verb = `${gitVer} detected`;
191
+ }
192
+ else {
193
+ gitRow.status = 'pending';
194
+ gitRow.verb = "we'll install";
195
+ }
196
+ const agentRow = this.getRow('agent');
197
+ const detected = this.detectAnyKnownAgent();
198
+ if (detected) {
199
+ this.state.agentId = detected.id;
200
+ agentRow.status = 'ok';
201
+ agentRow.verb = `${detected.label} detected`;
202
+ agentRow.detail = detected.versionLine ?? undefined;
203
+ }
204
+ else {
205
+ agentRow.status = 'pending';
206
+ agentRow.verb = pendingVerbForAgent(this.state.agentId);
207
+ }
208
+ const loginRow = this.getRow('agent-login');
209
+ if (agentRow.status === 'ok' && this.probeAgentReady(this.state.agentId).ok) {
210
+ loginRow.status = 'ok';
211
+ loginRow.verb = 'Signed in (verified)';
212
+ }
213
+ else {
214
+ loginRow.status = 'pending';
215
+ loginRow.verb = "you'll sign in after install";
216
+ }
217
+ const projectRow = this.getRow('project');
218
+ if (this.state.workspacePath && fs_1.default.existsSync(this.state.workspacePath)) {
219
+ projectRow.status = 'ok';
220
+ projectRow.verb = this.state.workspacePath;
221
+ }
222
+ else {
223
+ projectRow.status = 'manual-required';
224
+ projectRow.verb = 'pick a folder where FRAIM should work';
225
+ }
226
+ }
227
+ detectAnyKnownAgent() {
228
+ // Priority order matches FIRST_RUN_AGENT_OPTIONS ordering.
229
+ for (const option of types_1.FIRST_RUN_AGENT_OPTIONS) {
230
+ if (commandExists(option.launchCommand)) {
231
+ return { id: option.id, label: option.label, versionLine: commandVersion(option.launchCommand) };
232
+ }
233
+ }
234
+ // Also accept matches via the IDE detector for desktop variants.
235
+ const ides = (0, ide_detector_1.detectInstalledIDEs)();
236
+ for (const option of types_1.FIRST_RUN_AGENT_OPTIONS) {
237
+ const ide = ides.find((entry) => entry.aliases?.some((a) => option.detectAliases.includes(a)));
238
+ if (ide) {
239
+ return { id: option.id, label: option.label, versionLine: null };
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+ applyFakeStateOnLoad(mode) {
245
+ const setStatus = (rowId, status, verb, detail) => {
246
+ const row = this.getRow(rowId);
247
+ row.status = status;
248
+ row.verb = verb;
249
+ if (detail !== undefined)
250
+ row.detail = detail;
251
+ };
252
+ if (mode === 'all-pending') {
253
+ setStatus('node', 'pending', "we'll install");
254
+ setStatus('git', 'pending', "we'll install");
255
+ setStatus('agent', 'pending', pendingVerbForAgent(this.state.agentId));
256
+ setStatus('agent-login', 'pending', "you'll sign in after install");
257
+ setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
258
+ return;
259
+ }
260
+ if (mode === 'all-detected') {
261
+ setStatus('node', 'ok', 'v20.11.1 detected');
262
+ setStatus('git', 'ok', 'git version 2.45 detected');
263
+ setStatus('agent', 'ok', 'Claude Code v1.4.2 detected');
264
+ setStatus('agent-login', 'ok', 'Signed in (verified by claude -p probe)');
265
+ setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
266
+ return;
267
+ }
268
+ if (mode === 'agent-install-fails') {
269
+ setStatus('node', 'ok', 'v20.11.1 installed');
270
+ setStatus('git', 'ok', '2.45 installed');
271
+ setStatus('agent', 'pending', pendingVerbForAgent(this.state.agentId));
272
+ setStatus('agent-login', 'pending', "you'll sign in after install");
273
+ setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
274
+ return;
275
+ }
276
+ // 'default' fake mode — everything ok, project still requires a pick.
277
+ setStatus('node', 'ok', 'v20.11.1 detected');
278
+ setStatus('git', 'ok', 'git version 2.45 detected');
279
+ setStatus('agent', 'ok', 'fake-mode agent detected');
280
+ setStatus('agent-login', 'ok', 'fake-mode signed in');
281
+ setStatus('project', 'manual-required', 'pick a folder where FRAIM should work');
157
282
  }
158
283
  getRequestToken() {
159
284
  return this.requestToken;
160
285
  }
161
286
  getSession() {
162
- const agents = this.detectAgents();
163
- this.state.detectedAgents = agents.filter((agent) => agent.detected).map((agent) => agent.id);
287
+ this.detectRowsOnLoad();
164
288
  this.persist();
165
289
  return {
166
290
  state: this.state,
167
- agents,
291
+ rows: this.state.rows,
292
+ primaryButtonLabel: (0, types_1.derivePrimaryButtonLabel)(this.state.rows),
168
293
  prompt: types_1.FIRST_RUN_PROMPT,
169
294
  headless: this.headless,
170
295
  requestToken: this.requestToken,
296
+ agentOptions: types_1.FIRST_RUN_AGENT_OPTIONS,
297
+ currentAgentId: this.state.agentId,
171
298
  };
172
299
  }
173
- runPrereqChecks() {
174
- this.markStep('prereqs', 'running');
175
- const checks = [
176
- { label: 'node', ok: commandExists('node') },
177
- { label: 'npx', ok: commandExists('npx') },
178
- { label: 'git', ok: commandExists('git') },
179
- ];
180
- const failures = checks.filter((check) => !check.ok).map((check) => check.label);
181
- const message = failures.length === 0
182
- ? 'Node.js, npx, and git are available.'
183
- : `Missing prerequisites: ${failures.join(', ')}.`;
184
- appendInstallLog(`prereq-check ${message}`);
185
- this.markStep('prereqs', failures.length === 0 ? 'complete' : 'failed');
186
- if (failures.length === 0) {
187
- this.markStep('agent', 'running');
188
- }
189
- return { ok: failures.length === 0, message, state: this.state };
190
- }
191
- selectAgent(agentId) {
192
- this.state.selectedAgentId = agentId;
193
- this.markStep('agent', 'complete');
194
- this.markStep('configure', 'running');
195
- appendInstallLog(`agent-selected ${agentId}`);
300
+ respond(message, ok) {
196
301
  return {
197
- ok: true,
198
- message: `Selected ${types_1.FIRST_RUN_AGENT_OPTIONS.find((agent) => agent.id === agentId)?.label || agentId}.`,
302
+ ok,
303
+ message,
199
304
  state: this.state,
200
- launchCommand: types_1.FIRST_RUN_AGENT_OPTIONS.find((agent) => agent.id === agentId)?.loginCommand,
305
+ rows: this.state.rows,
306
+ primaryButtonLabel: (0, types_1.derivePrimaryButtonLabel)(this.state.rows),
201
307
  };
202
308
  }
203
- async configureFraim() {
204
- this.markStep('configure', 'running');
205
- (0, setup_1.saveGlobalConfig)(this.key, 'conversational', {}, {});
206
- if (!this.fakeMode) {
207
- const selectedAgent = this.state.selectedAgentId ? (0, ide_detector_1.findIDEByName)(this.state.selectedAgentId) : undefined;
208
- const targetNames = selectedAgent ? [selectedAgent.name] : undefined;
209
- await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, targetNames, {});
210
- this.state.configuredAgents = this.detectAgents().filter((agent) => agent.detected).map((agent) => agent.id);
309
+ /**
310
+ * Run a single row to completion (or to its next stable state). Idempotent.
311
+ * The client calls this for each row that is `pending`, in the canonical
312
+ * order, until every row is `ok` or surfaces an error.
313
+ */
314
+ async runRow(rowId, request = {}) {
315
+ const row = this.getRow(rowId);
316
+ if (row.status === 'ok') {
317
+ return this.respond(`${row.label} already ready.`, true);
211
318
  }
212
- else {
213
- this.state.configuredAgents = this.state.selectedAgentId ? [this.state.selectedAgentId] : [];
319
+ this.clearRowError(row);
320
+ row.status = 'in-progress';
321
+ this.persist();
322
+ appendInstallLog(`row-run start ${rowId} status=${row.status}`);
323
+ try {
324
+ switch (rowId) {
325
+ case 'node':
326
+ return await this.runNodeRow();
327
+ case 'git':
328
+ return await this.runGitRow();
329
+ case 'agent':
330
+ return await this.runAgentRow(request);
331
+ case 'agent-login':
332
+ return await this.runAgentLoginRow(request);
333
+ case 'project':
334
+ return await this.runProjectRow(request);
335
+ }
214
336
  }
215
- appendInstallLog(`configure configuredAgents=${this.state.configuredAgents.join(',')}`);
216
- this.markStep('configure', 'complete');
217
- this.markStep('project', 'running');
218
- return {
219
- ok: true,
220
- message: 'Saved FRAIM global configuration and wrote agent config where available.',
221
- state: this.state,
222
- };
337
+ catch (error) {
338
+ // Last-ditch safety net — every row's runner is supposed to surface
339
+ // its own R6 frame via setRowError before throwing, but if anything
340
+ // slips past (e.g. a sync layer that calls process.exit and we narrowly
341
+ // converted it to throw), we still surface a frame here rather than
342
+ // letting the request return an opaque 500. Skip is included so the
343
+ // user is never wedged on a non-recoverable failure of one row.
344
+ const message = error instanceof Error ? error.message : 'Unknown error';
345
+ row.status = 'error';
346
+ row.verb = 'failed — see below';
347
+ row.errorFrame = {
348
+ whatTried: `Running ${row.label} step`,
349
+ whatHappened: message,
350
+ actions: [
351
+ { id: 'retry', label: 'Retry', variant: 'primary' },
352
+ { id: 'skip', label: 'Skip and continue', variant: 'ghost' },
353
+ ],
354
+ };
355
+ this.persist();
356
+ return this.respond(`Row ${rowId} failed: ${message}`, false);
357
+ }
358
+ }
359
+ async runNodeRow() {
360
+ const row = this.getRow('node');
361
+ // Node is bootstrapped by the install template before the wizard starts.
362
+ // If node is missing here something is severely wrong; fall through to error.
363
+ const ver = commandVersion('node');
364
+ if (ver) {
365
+ row.status = 'ok';
366
+ row.verb = `${ver} installed`;
367
+ this.persist();
368
+ return this.respond(`Node detected (${ver}).`, true);
369
+ }
370
+ if (this.fakeMode) {
371
+ row.status = 'ok';
372
+ row.verb = 'v20.11.1 installed';
373
+ this.persist();
374
+ return this.respond('Fake-mode node ok.', true);
375
+ }
376
+ this.setRowError(row, 'Verifying Node.js installation', 'node --version returned a non-zero exit code or no output.\n\nThe installer template is expected to have bootstrapped Node before launching first-run.', [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
377
+ this.persist();
378
+ return this.respond('Node not detected.', false);
379
+ }
380
+ async runGitRow() {
381
+ const row = this.getRow('git');
382
+ const ver = commandVersion('git');
383
+ if (ver) {
384
+ row.status = 'ok';
385
+ row.verb = `${ver} installed`;
386
+ this.persist();
387
+ return this.respond(`git detected (${ver}).`, true);
388
+ }
389
+ if (this.fakeMode) {
390
+ row.status = 'ok';
391
+ row.verb = 'git version 2.45 installed';
392
+ this.persist();
393
+ return this.respond('Fake-mode git ok.', true);
394
+ }
395
+ this.setRowError(row, 'Verifying git installation', 'git --version returned a non-zero exit code or no output.\n\nOn macOS, run `xcode-select --install` to install the Command Line Developer Tools, then retry. On Windows, install Git for Windows from https://git-scm.com/download/win.', [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
396
+ this.persist();
397
+ return this.respond('git not detected.', false);
398
+ }
399
+ async runAgentRow(request) {
400
+ const row = this.getRow('agent');
401
+ // If the user picked an alternative through the error frame, switch and continue.
402
+ if (request.errorActionId === 'alternative' && request.alternativeAgentId) {
403
+ this.state.agentId = request.alternativeAgentId;
404
+ row.verb = pendingVerbForAgent(this.state.agentId);
405
+ }
406
+ if (request.errorActionId === 'skip') {
407
+ row.status = 'manual-required';
408
+ row.verb = 'you said you\'ll set up the agent yourself';
409
+ // If the user skipped agent install, sign-in is also their problem —
410
+ // there's no agent to sign into. Mark agent-login manual-required so
411
+ // the auto-progress loop doesn't try to probe a nonexistent CLI on
412
+ // the next Continue click.
413
+ const loginRow = this.getRow('agent-login');
414
+ if (loginRow.status !== 'ok') {
415
+ loginRow.status = 'manual-required';
416
+ loginRow.verb = "you'll handle sign-in when you set up the agent";
417
+ }
418
+ this.persist();
419
+ return this.respond('Agent setup deferred.', true);
420
+ }
421
+ const option = findAgentOption(this.state.agentId);
422
+ if (!option) {
423
+ this.setRowError(row, 'Looking up the AI agent install command', `Unknown agent id: ${this.state.agentId}`, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
424
+ this.persist();
425
+ return this.respond('Unknown agent.', false);
426
+ }
427
+ // Fake mode short-circuit — never call real OS commands. The whole point
428
+ // of fake mode is hermetic, deterministic test rendering.
429
+ if (this.fakeMode === 'agent-install-fails') {
430
+ this.setRowError(row, `Installing ${option.label} via npm install -g ${option.installPackage}`, this.fakeStderr, this.buildAgentInstallActions(this.state.agentId));
431
+ this.persist();
432
+ return this.respond('Fake-mode agent install failed.', false);
433
+ }
434
+ if (this.fakeMode) {
435
+ row.status = 'ok';
436
+ row.verb = `${option.label} installed (fake-mode)`;
437
+ this.persist();
438
+ return this.respond('Fake-mode agent installed.', true);
439
+ }
440
+ // Real path: detect first — if already installed we skip install entirely.
441
+ const existingVer = commandVersion(option.launchCommand);
442
+ if (existingVer) {
443
+ row.status = 'ok';
444
+ row.verb = `${option.label} ${existingVer} detected`;
445
+ row.detail = existingVer;
446
+ this.persist();
447
+ return this.respond(`${option.label} already installed.`, true);
448
+ }
449
+ // Real install — npm install -g <package>. Stream output into row.streamOutput.
450
+ const result = (0, child_process_1.spawnSync)('npm', ['install', '-g', option.installPackage], {
451
+ encoding: 'utf8',
452
+ timeout: 300000,
453
+ });
454
+ const combined = `${result.stdout || ''}${result.stderr || ''}`.trim();
455
+ row.streamOutput = combined.slice(-4000);
456
+ if (result.status === 0) {
457
+ const ver = commandVersion(option.launchCommand) ?? 'installed';
458
+ row.status = 'ok';
459
+ row.verb = `${option.label} ${ver}`;
460
+ row.detail = ver;
461
+ this.persist();
462
+ return this.respond(`${option.label} installed.`, true);
463
+ }
464
+ this.setRowError(row, `Installing ${option.label} via npm install -g ${option.installPackage}`, combined || `npm install -g ${option.installPackage} exited ${result.status}`, this.buildAgentInstallActions(this.state.agentId));
465
+ this.persist();
466
+ return this.respond(`${option.label} install failed.`, false);
467
+ }
468
+ buildAgentInstallActions(currentAgentId) {
469
+ const others = types_1.FIRST_RUN_AGENT_OPTIONS.filter((option) => option.id !== currentAgentId);
470
+ const actions = [
471
+ { id: 'retry', label: 'Retry', variant: 'primary' },
472
+ ];
473
+ if (others.length > 0) {
474
+ actions.push({
475
+ id: 'alternative',
476
+ label: `Try ${others[0].label} instead`,
477
+ variant: 'secondary',
478
+ alternativeAgentId: others[0].id,
479
+ });
480
+ }
481
+ actions.push({ id: 'skip', label: 'Skip and continue', variant: 'ghost' });
482
+ return actions;
223
483
  }
224
- async initializeProject(projectPath, initializeGit = true) {
484
+ async runAgentLoginRow(request) {
485
+ const row = this.getRow('agent-login');
486
+ if (request.errorActionId === 'skip') {
487
+ row.status = 'manual-required';
488
+ row.verb = "you'll sign in later from the Hub";
489
+ this.persist();
490
+ return this.respond('Agent login deferred.', true);
491
+ }
492
+ const option = findAgentOption(this.state.agentId);
493
+ if (!option) {
494
+ this.setRowError(row, 'Resolving agent login command', `Unknown agent id: ${this.state.agentId}`, [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
495
+ this.persist();
496
+ return this.respond('Unknown agent for login.', false);
497
+ }
498
+ if (this.fakeMode) {
499
+ row.status = 'ok';
500
+ row.verb = 'Signed in (fake-mode)';
501
+ this.persist();
502
+ return this.respond('Fake-mode signed in.', true);
503
+ }
504
+ // First, see whether the agent is already authenticated.
505
+ const probe = this.probeAgentReady(this.state.agentId);
506
+ if (probe.ok) {
507
+ row.status = 'ok';
508
+ row.verb = 'Signed in (verified)';
509
+ this.persist();
510
+ return this.respond(`${option.label} signed in.`, true);
511
+ }
512
+ // Not authenticated yet — surface as manual-required with a clear message.
513
+ // We do NOT auto-spawn `claude login` from here because OAuth flows differ
514
+ // by agent and need the user's actual browser. The client renders a
515
+ // "Sign in to Claude in the new tab" prompt and polls back via Retry.
516
+ row.status = 'manual-required';
517
+ row.verb = `Sign in to ${option.label} (then click Retry)`;
518
+ row.manualMessage = `Open a terminal and run \`${option.loginCommand}\`. We'll detect when you're signed in.`;
519
+ this.persist();
520
+ return this.respond(`Awaiting ${option.label} login.`, true);
521
+ }
522
+ probeAgentReady(agentId) {
523
+ if (this.fakeMode)
524
+ return { ok: this.fakeMode !== 'agent-install-fails', output: 'fake' };
525
+ const option = findAgentOption(agentId);
526
+ if (!option)
527
+ return { ok: false, output: 'unknown agent' };
528
+ if (agentId === 'codex') {
529
+ const result = (0, child_process_1.spawnSync)('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'], {
530
+ cwd: this.state.workspacePath || process.cwd(),
531
+ input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
532
+ encoding: 'utf8',
533
+ timeout: 60000,
534
+ });
535
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
536
+ return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
537
+ }
538
+ if (agentId === 'claude-code') {
539
+ const result = (0, child_process_1.spawnSync)('claude', ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'], {
540
+ cwd: this.state.workspacePath || process.cwd(),
541
+ input: 'Reply exactly "FRAIM_READY" if FRAIM is available in this workspace. Otherwise reply exactly "NOT_READY".',
542
+ encoding: 'utf8',
543
+ timeout: 60000,
544
+ });
545
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
546
+ return { ok: result.status === 0 && output.toUpperCase().includes('FRAIM'), output };
547
+ }
548
+ // Gemini probe is `--help` based until Gemini ships a stronger non-interactive path.
549
+ const result = (0, child_process_1.spawnSync)('gemini', ['--help'], {
550
+ cwd: this.state.workspacePath || process.cwd(),
551
+ encoding: 'utf8',
552
+ timeout: 30000,
553
+ });
554
+ return { ok: result.status === 0, output: `${result.stdout || ''}${result.stderr || ''}`.trim() };
555
+ }
556
+ async runProjectRow(request) {
557
+ const row = this.getRow('project');
558
+ // Skip-and-continue contract: a user who explicitly skips this row chose
559
+ // to set the project up themselves later. Mark manual-required so the
560
+ // primary-button derivation can route to Open Hub via the skip-path rule.
561
+ if (request.errorActionId === 'skip') {
562
+ row.status = 'manual-required';
563
+ row.verb = "you'll pick a project folder later";
564
+ this.clearRowError(row);
565
+ this.persist();
566
+ return this.respond('Project deferred.', true);
567
+ }
568
+ const projectPath = request.projectPath ?? this.state.workspacePath;
569
+ if (!projectPath) {
570
+ row.status = 'manual-required';
571
+ row.verb = 'pick a folder where FRAIM should work';
572
+ this.persist();
573
+ return this.respond('Project path required.', false);
574
+ }
225
575
  const resolvedPath = path_1.default.resolve(projectPath);
226
576
  fs_1.default.mkdirSync(resolvedPath, { recursive: true });
227
- this.markStep('project', 'running');
228
- if (initializeGit && commandExists('git')) {
577
+ if (commandExists('git')) {
229
578
  try {
230
579
  (0, child_process_1.execSync)('git rev-parse --git-dir', { cwd: resolvedPath, stdio: 'ignore' });
231
580
  }
232
581
  catch {
233
- (0, child_process_1.execSync)('git init', { cwd: resolvedPath, stdio: 'ignore' });
582
+ try {
583
+ (0, child_process_1.execSync)('git init', { cwd: resolvedPath, stdio: 'ignore' });
584
+ }
585
+ catch (gitError) {
586
+ // Surface but don't fail the row — init-project will still work.
587
+ appendInstallLog(`git init failed at ${resolvedPath}: ${gitError instanceof Error ? gitError.message : 'unknown'}`);
588
+ }
589
+ }
590
+ }
591
+ if (!this.fakeMode) {
592
+ // Configure FRAIM globals + agent MCP wiring before init-project runs.
593
+ (0, setup_1.saveGlobalConfig)(this.key, 'conversational', {}, {});
594
+ const selectedAgent = (0, ide_detector_1.findIDEByName)(this.state.agentId);
595
+ const targetNames = selectedAgent ? [selectedAgent.name] : undefined;
596
+ try {
597
+ await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, targetNames, {});
598
+ }
599
+ catch (mcpError) {
600
+ appendInstallLog(`autoConfigureMCP non-fatal error: ${mcpError instanceof Error ? mcpError.message : 'unknown'}`);
601
+ }
602
+ // Pass failHard:'throw' so a sync 401 / network error / missing-key
603
+ // bubbles back here instead of calling process.exit(1) and killing the
604
+ // FRE server. The catch below converts it into the R6 error frame.
605
+ try {
606
+ await (0, init_project_1.runInitProject)({ projectRoot: resolvedPath, failHard: 'throw' });
607
+ }
608
+ catch (initError) {
609
+ const detail = initError instanceof Error ? initError.message : 'Unknown init error';
610
+ // Skip is intentionally omitted on the project row — without a
611
+ // project there is no Hub to open, so deferring this row would
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);
617
+ appendInstallLog(`project-init-failed ${resolvedPath}: ${detail}`);
618
+ this.persist();
619
+ return this.respond(`Initialization failed: ${detail}`, false);
234
620
  }
235
621
  }
236
- await (0, init_project_1.runInitProject)({ projectRoot: resolvedPath });
237
622
  this.state.workspacePath = resolvedPath;
238
- this.markStep('project', 'complete');
239
- this.markStep('launch', 'running');
623
+ row.status = 'ok';
624
+ row.verb = resolvedPath;
625
+ this.persist();
240
626
  appendInstallLog(`project-initialized ${resolvedPath}`);
241
- return {
242
- ok: true,
243
- message: `Initialized FRAIM in ${resolvedPath}.`,
244
- state: this.state,
245
- };
627
+ return this.respond(`Initialized FRAIM in ${resolvedPath}.`, true);
246
628
  }
247
- launchAndProbe() {
248
- const agentId = this.state.selectedAgentId;
249
- const projectRoot = this.state.workspacePath;
250
- if (!agentId || !projectRoot) {
251
- this.markStep('launch', 'failed');
252
- return {
253
- ok: false,
254
- message: 'Select an agent and project folder before launch.',
255
- state: this.state,
256
- };
629
+ /**
630
+ * Update the current agent selection (inline `Change…` picker). Recomputes
631
+ * the agent and agent-login row verbs but does not run any installs.
632
+ */
633
+ changeAgent(req) {
634
+ if (req.customAgent) {
635
+ this.state.customAgent = { name: req.customAgent.name, invocationPrefix: req.customAgent.invocationPrefix };
636
+ const agentRow = this.getRow('agent');
637
+ const loginRow = this.getRow('agent-login');
638
+ agentRow.status = 'ok';
639
+ agentRow.verb = `Custom CLI recorded: ${req.customAgent.name}`;
640
+ agentRow.detail = `(advanced — Hub will not auto-invoke unknown CLIs in v1)`;
641
+ loginRow.status = 'manual-required';
642
+ loginRow.verb = `Sign in to ${req.customAgent.name} yourself`;
643
+ this.persist();
644
+ appendInstallLog(`custom-agent recorded ${req.customAgent.name}`);
645
+ return this.respond(`Custom agent "${req.customAgent.name}" recorded.`, true);
257
646
  }
258
- const launchCommand = getLaunchInstruction(agentId, projectRoot);
259
- const opened = this.fakeMode ? true : maybeLaunchInteractiveShell(agentId, projectRoot);
260
- let probeResult = { ok: true, output: 'Fake test-mode launch succeeded.' };
261
- if (!this.fakeMode) {
262
- if (agentId === 'codex') {
263
- probeResult = probeCodex(projectRoot);
647
+ if (!req.agentId) {
648
+ return this.respond('agentId or customAgent required.', false);
649
+ }
650
+ const option = findAgentOption(req.agentId);
651
+ if (!option) {
652
+ return this.respond(`Unknown agent: ${req.agentId}`, false);
653
+ }
654
+ const previousAgentId = this.state.agentId;
655
+ this.state.agentId = req.agentId;
656
+ delete this.state.customAgent;
657
+ const agentRow = this.getRow('agent');
658
+ const loginRow = this.getRow('agent-login');
659
+ // If the user actually switched to a different agent, the row state from
660
+ // the previous agent is now stale — even if it was `ok`. The new agent
661
+ // has not been detected, installed, verified, or signed in. Re-detect
662
+ // the new agent and reset both rows so the next run executes against
663
+ // the new selection rather than silently keeping the previous verbs.
664
+ if (previousAgentId !== req.agentId) {
665
+ this.clearRowError(agentRow);
666
+ const detectedVer = this.fakeMode ? null : commandVersion(option.launchCommand);
667
+ if (detectedVer) {
668
+ agentRow.status = 'ok';
669
+ agentRow.verb = `${option.label} ${detectedVer} detected`;
670
+ agentRow.detail = detectedVer;
671
+ }
672
+ else {
673
+ agentRow.status = 'pending';
674
+ agentRow.verb = pendingVerbForAgent(req.agentId);
675
+ delete agentRow.detail;
264
676
  }
265
- else if (agentId === 'claude-code') {
266
- probeResult = probeClaude(projectRoot);
677
+ // Login state never carries across agents.
678
+ this.clearRowError(loginRow);
679
+ const loginReady = agentRow.status === 'ok' && this.probeAgentReady(req.agentId).ok;
680
+ if (loginReady) {
681
+ loginRow.status = 'ok';
682
+ loginRow.verb = 'Signed in (verified)';
267
683
  }
268
684
  else {
269
- probeResult = probeGemini(projectRoot);
685
+ loginRow.status = 'pending';
686
+ loginRow.verb = "you'll sign in after install";
270
687
  }
271
688
  }
272
- this.state.lastLaunchCommand = launchCommand;
273
- this.state.lastProbeMessage = probeResult.output;
274
- this.markStep('launch', probeResult.ok ? 'complete' : 'failed');
275
- if (probeResult.ok) {
276
- this.markStep('finish', 'running');
689
+ else {
690
+ // Same agent re-selected — only normalize verbs if rows were not yet ok.
691
+ if (agentRow.status !== 'ok') {
692
+ agentRow.verb = pendingVerbForAgent(req.agentId);
693
+ this.clearRowError(agentRow);
694
+ agentRow.status = 'pending';
695
+ }
696
+ if (loginRow.status !== 'ok') {
697
+ loginRow.verb = "you'll sign in after install";
698
+ loginRow.status = 'pending';
699
+ }
277
700
  }
278
- appendInstallLog(`launch agent=${agentId} opened=${opened} probeOk=${probeResult.ok}`);
279
- return {
280
- ok: probeResult.ok,
281
- message: opened
282
- ? 'Agent launch was attempted and the probe completed.'
283
- : 'Agent launch could not be opened automatically; use the provided command and review the probe output.',
284
- state: this.state,
285
- launchCommand,
286
- output: probeResult.output,
287
- };
701
+ this.persist();
702
+ appendInstallLog(`agent-changed ${req.agentId}`);
703
+ return this.respond(`Selected ${option.label}.`, true);
288
704
  }
289
705
  finish() {
290
706
  ensureOutputDirs();
291
707
  fs_1.default.writeFileSync(getNextPromptPath(), `${types_1.FIRST_RUN_PROMPT}${os_1.default.EOL}`, 'utf8');
292
708
  this.state.nextPrompt = types_1.FIRST_RUN_PROMPT;
293
- this.markStep('finish', 'complete');
709
+ this.persist();
294
710
  appendInstallLog('finish prompt-written');
295
- return {
296
- ok: true,
297
- message: 'Wrote the next prompt artifact and completed first-run.',
298
- state: this.state,
299
- };
711
+ return this.respond('Wrote the next prompt artifact and completed first-run.', true);
712
+ }
713
+ /**
714
+ * Start the Hub server for the chosen project and open the user's browser
715
+ * to it. Returns the Hub URL so the client can show a clear "we're at <url>"
716
+ * message instead of a CLI hand-off — the whole point of v1 is to never
717
+ * leave the user typing a command. The single-file launcher binary in v2
718
+ * (#355) replaces this in-process spawn with a durable tray-icon launcher.
719
+ */
720
+ async openHub() {
721
+ if (!this.state.workspacePath) {
722
+ return { ok: false, message: 'Pick a project folder before opening the Hub.' };
723
+ }
724
+ if (this.fakeMode) {
725
+ // Tests don't actually want a Hub server running — just confirm intent.
726
+ return { ok: true, message: 'Fake-mode Hub open requested.', hubUrl: 'http://127.0.0.1:0/ai-hub/' };
727
+ }
728
+ try {
729
+ const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('../ai-hub/server')));
730
+ const port = await findAvailablePort(43091);
731
+ const hubServer = new AiHubServer({ projectPath: this.state.workspacePath });
732
+ await hubServer.start(port);
733
+ const hubUrl = `http://127.0.0.1:${port}/ai-hub/`;
734
+ this.openBrowser(hubUrl);
735
+ appendInstallLog(`hub-opened ${hubUrl}`);
736
+ return {
737
+ ok: true,
738
+ message: `Hub is open at ${hubUrl}. From now on, run \`npx fraim-framework@latest hub --browser\` to launch it again — the standalone launcher binary ships in v2 (#355).`,
739
+ hubUrl,
740
+ };
741
+ }
742
+ catch (error) {
743
+ const detail = error instanceof Error ? error.message : 'Unknown error';
744
+ appendInstallLog(`hub-open-failed ${detail}`);
745
+ return {
746
+ ok: false,
747
+ message: `Could not open the Hub automatically: ${detail}. Run \`npx fraim-framework@latest hub --browser\` from a terminal to open it manually.`,
748
+ };
749
+ }
750
+ }
751
+ openBrowser(url) {
752
+ try {
753
+ if (process.platform === 'win32') {
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();
759
+ return;
760
+ }
761
+ if (process.platform === 'darwin') {
762
+ (0, child_process_1.spawn)('open', [url], { detached: true, stdio: 'ignore' }).unref();
763
+ return;
764
+ }
765
+ (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
766
+ }
767
+ catch {
768
+ // Best-effort — fall through; the URL is already in the response so the
769
+ // client surfaces it for the user.
770
+ }
300
771
  }
301
772
  }
302
773
  exports.FirstRunSessionService = FirstRunSessionService;