@aion0/forge 0.10.36 → 0.10.38

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.
@@ -0,0 +1,1002 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Onboarding wizard — first-run setup banner + right-side drawer.
5
+ *
6
+ * - Banner: thin bar at top of Dashboard when `settings.onboardingCompleted` is
7
+ * false. Clicking it opens the drawer. Drawer is a slide-in panel ~520px
8
+ * wide; the rest of the UI stays visible behind it (per user request).
9
+ * - 6 sections: Identity / API Profile / Connectors / Pipelines / Projects /
10
+ * Memory. Apply hits POST /api/onboarding (action:apply) once with the
11
+ * full payload — backend phases each step atomically and reports back.
12
+ *
13
+ * Re-runnable from Settings → "Re-run Onboarding" (POST action:reset).
14
+ */
15
+
16
+ import { useEffect, useRef, useState } from 'react';
17
+
18
+ // ─── Types echoing the API surface ───────────────────────────
19
+
20
+ interface PromptDef {
21
+ label: string;
22
+ hint?: string;
23
+ url?: string;
24
+ url_label?: string;
25
+ secret?: boolean;
26
+ required?: boolean;
27
+ /** Override the auto-detected group (default = first target's connector). */
28
+ group?: string;
29
+ /** Pre-filled value shown in the input on first render. */
30
+ default?: string;
31
+ }
32
+
33
+ interface OnboardingState {
34
+ ok: boolean;
35
+ onboardingCompleted: boolean;
36
+ current: {
37
+ displayName: string;
38
+ displayEmail: string;
39
+ apiProfileIds: string[];
40
+ apiProfile: {
41
+ id: string; name: string;
42
+ provider: 'anthropic' | 'openai-compatible';
43
+ model: string; baseUrl: string;
44
+ apiKeySet: boolean;
45
+ } | null;
46
+ chatAgent: string;
47
+ defaultAgent: string;
48
+ agents: Array<{ id: string; name: string; tool: string; path: string; enabled: boolean }>;
49
+ projectRoots: string[];
50
+ };
51
+ detected_cli: Array<{ name: string; path: string; version: string }>;
52
+ /** All non-meta connector ids in the template, in template order. */
53
+ template_connectors: Array<{
54
+ id: string;
55
+ default_enabled: boolean;
56
+ has_prompts: boolean;
57
+ already_installed: boolean;
58
+ }>;
59
+ template_prompts: Record<string, PromptDef>;
60
+ /** Per ${key} prompt: true = at least one target field already has a real value;
61
+ * leave blank to keep it. Backend's applyConnectors preserves existing on empty. */
62
+ prompt_values_set: Record<string, boolean>;
63
+ /** Per ${key}: which connector.field references it (for UI hint). */
64
+ prompt_targets: Record<string, Array<{ connector: string; field: string }>>;
65
+ suggested_pipelines: string[];
66
+ }
67
+
68
+ interface ApplyResultPhase {
69
+ phase: string;
70
+ ok: boolean;
71
+ error?: string;
72
+ detail?: any;
73
+ }
74
+
75
+ // ─── Banner ──────────────────────────────────────────────────
76
+
77
+ export function OnboardingBanner({ onOpen }: { onOpen: () => void }) {
78
+ return (
79
+ <div className="bg-[var(--accent)]/10 border-b border-[var(--accent)]/40 px-3 py-1.5 flex items-center justify-between text-[11px] text-[var(--text-primary)]">
80
+ <span>
81
+ <span className="font-medium">Welcome to Forge.</span>{' '}
82
+ Finish setup — identity, API key, connectors, pipelines (1 click).
83
+ </span>
84
+ <div className="flex items-center gap-2">
85
+ <button
86
+ onClick={onOpen}
87
+ className="text-[11px] px-2.5 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
88
+ >
89
+ Open setup →
90
+ </button>
91
+ <button
92
+ onClick={async () => {
93
+ await fetch('/api/onboarding', {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({ action: 'apply', payload: {} }),
97
+ });
98
+ window.location.reload();
99
+ }}
100
+ className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
101
+ title="Mark setup as completed without changes"
102
+ >
103
+ Skip
104
+ </button>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ // ─── Drawer ──────────────────────────────────────────────────
111
+
112
+ const inputCls = 'w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]';
113
+
114
+ const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
115
+ anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
116
+ deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
117
+ openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
118
+ qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-max' },
119
+ // LiteLLM proxy — baseUrl points to your LiteLLM instance (default
120
+ // local). Lets one token-cap profile front many backend models.
121
+ litellm: { baseUrl: 'http://127.0.0.1:4000/v1', model: 'gpt-4o-mini' },
122
+ };
123
+
124
+ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void; onComplete: () => void }) {
125
+ const [state, setState] = useState<OnboardingState | null>(null);
126
+
127
+ // Section 1: Identity
128
+ const [displayName, setDisplayName] = useState('');
129
+ const [displayEmail, setDisplayEmail] = useState('');
130
+
131
+ // Section 2: API Profile
132
+ const [apiProfileId, setApiProfileId] = useState('deepseek');
133
+ const [apiProvider, setApiProvider] = useState<'anthropic' | 'openai-compatible'>('openai-compatible');
134
+ const [apiBaseUrl, setApiBaseUrl] = useState(PROVIDER_DEFAULTS.deepseek.baseUrl);
135
+ const [apiModel, setApiModel] = useState(PROVIDER_DEFAULTS.deepseek.model);
136
+ const [apiKey, setApiKey] = useState('');
137
+ const [apiKeyExisting, setApiKeyExisting] = useState(false); // true → "leave blank to keep current"
138
+ const [apiSetAsDefault, setApiSetAsDefault] = useState(true);
139
+
140
+ // Section 2.5: CLI Agent
141
+ const [cliAgentId, setCliAgentId] = useState(''); // '' = don't configure
142
+ const [cliAgentTool, setCliAgentTool] = useState<'claude' | 'codex' | 'aider' | 'opencode'>('claude');
143
+ const [cliAgentPath, setCliAgentPath] = useState('');
144
+ const [cliSetAsDefault, setCliSetAsDefault] = useState(true);
145
+
146
+ // Section 3: Connector template values (one per ${key})
147
+ const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
148
+ // Section 3.5: which template connectors to install. Default = all
149
+ // (populated from template_connectors on state load). User-typed values
150
+ // for un-selected connectors are kept in connectorValues so re-checking
151
+ // restores them — only the payload skips unselected ones.
152
+ const [selectedConnectors, setSelectedConnectors] = useState<Set<string>>(new Set());
153
+
154
+ // Section 4: Pipelines
155
+ const [selectedPipelines, setSelectedPipelines] = useState<Set<string>>(new Set());
156
+
157
+ // Section 5: Projects
158
+ const [projectInput, setProjectInput] = useState('');
159
+ // Folder picker — uses Forge's existing /api/fs/browse endpoint.
160
+ const [pickerOpen, setPickerOpen] = useState(false);
161
+ const [pickerPath, setPickerPath] = useState('');
162
+ const [pickerEntries, setPickerEntries] = useState<Array<{ name: string; path: string }>>([]);
163
+ const [pickerParent, setPickerParent] = useState<string | null>(null);
164
+
165
+ async function openPicker(path?: string) {
166
+ setPickerOpen(true);
167
+ try {
168
+ const url = path ? `/api/fs/browse?path=${encodeURIComponent(path)}` : '/api/fs/browse';
169
+ const r = await fetch(url);
170
+ const j = await r.json();
171
+ if (j.ok) {
172
+ setPickerPath(j.path);
173
+ setPickerEntries(j.entries || []);
174
+ setPickerParent(j.parent || null);
175
+ }
176
+ } catch { /* ignore */ }
177
+ }
178
+
179
+ function addPickedPath(path: string) {
180
+ const lines = projectInput.split('\n').map(s => s.trim()).filter(Boolean);
181
+ if (!lines.includes(path)) lines.push(path);
182
+ setProjectInput(lines.join('\n'));
183
+ setPickerOpen(false);
184
+ }
185
+
186
+ // Apply state
187
+ const [applying, setApplying] = useState(false);
188
+ const [result, setResult] = useState<ApplyResultPhase[] | null>(null);
189
+
190
+ // Post-apply health-check step. Once apply succeeds we slide the
191
+ // drawer into a "checks" view that runs glab-sync / login-status /
192
+ // backend monitor probes — so users see what's actually live before
193
+ // we dismiss the wizard.
194
+ type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
195
+ const [phase, setPhase] = useState<'form' | 'checks'>('form');
196
+ const [checks, setChecks] = useState<Record<string, CheckState>>({
197
+ glabCli: { status: 'pending' },
198
+ loginStatus: { status: 'pending' },
199
+ services: { status: 'pending' },
200
+ });
201
+
202
+ async function runChecks() {
203
+ // glab sync — only meaningful when the user opted into gitlab.
204
+ // Avoid hitting the API at all when gitlab isn't selected, otherwise
205
+ // the route returns a hard error ("not installed or not enabled")
206
+ // and the check shows red for something the user explicitly skipped.
207
+ if (!selectedConnectors.has('gitlab')) {
208
+ setChecks(c => ({ ...c, glabCli: {
209
+ status: 'skipped',
210
+ message: 'gitlab not selected — skipped',
211
+ } }));
212
+ } else {
213
+ setChecks(c => ({ ...c, glabCli: { status: 'running' } }));
214
+ fetch('/api/connectors/gitlab/sync-cli', { method: 'POST' })
215
+ .then(r => r.json().then(j => ({ ok: r.ok && j.ok !== false, j })))
216
+ .then(({ ok, j }) => setChecks(c => ({
217
+ ...c,
218
+ glabCli: {
219
+ status: ok ? 'ok' : 'fail',
220
+ message: ok ? 'glab CLI synced with GitLab token' : (j.error || j.stderr || 'sync failed'),
221
+ detail: j,
222
+ },
223
+ })))
224
+ .catch(e => setChecks(c => ({ ...c, glabCli: { status: 'fail', message: e.message } })));
225
+ }
226
+
227
+ // Login status — POST triggers fresh check across all sources.
228
+ setChecks(c => ({ ...c, loginStatus: { status: 'running' } }));
229
+ fetch('/api/login-status', { method: 'POST' })
230
+ .then(r => r.json())
231
+ .then(j => {
232
+ const rows = j.rows || j.results || [];
233
+ const okCount = rows.filter((r: any) => r.result?.ok).length;
234
+ const failCount = rows.length - okCount;
235
+ setChecks(c => ({
236
+ ...c,
237
+ loginStatus: {
238
+ status: rows.length === 0 ? 'ok' : (failCount === 0 ? 'ok' : 'fail'),
239
+ message: rows.length === 0
240
+ ? 'no sources configured yet'
241
+ : `${okCount}/${rows.length} sources ok` + (failCount ? `, ${failCount} need attention` : ''),
242
+ detail: rows,
243
+ },
244
+ }));
245
+ })
246
+ .catch(e => setChecks(c => ({ ...c, loginStatus: { status: 'fail', message: e.message } })));
247
+
248
+ // Backend processes via /api/monitor.
249
+ setChecks(c => ({ ...c, services: { status: 'running' } }));
250
+ fetch('/api/monitor')
251
+ .then(r => r.json())
252
+ .then(j => {
253
+ const procs = j.processes || j;
254
+ const expected = ['nextjs', 'terminal', 'workspace', 'chat'];
255
+ const down = expected.filter(k => !procs[k]?.running);
256
+ setChecks(c => ({
257
+ ...c,
258
+ services: {
259
+ status: down.length === 0 ? 'ok' : 'fail',
260
+ message: down.length === 0 ? 'core services running' : `down: ${down.join(', ')}`,
261
+ detail: procs,
262
+ },
263
+ }));
264
+ })
265
+ .catch(e => setChecks(c => ({ ...c, services: { status: 'fail', message: e.message } })));
266
+ }
267
+ // Dirty tracking — flip true on any input touch. Guards close.
268
+ // Suppress the first effect run (initial state hydration from API).
269
+ const [dirty, setDirty] = useState(false);
270
+ const initializedRef = useRef(false);
271
+ useEffect(() => {
272
+ if (!initializedRef.current) { initializedRef.current = true; return; }
273
+ setDirty(true);
274
+ }, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
275
+ connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
276
+ // Inline marketplace sync (so user doesn't have to leave the wizard
277
+ // to populate the pipeline list when it's empty / stale).
278
+ const [syncingMarket, setSyncingMarket] = useState(false);
279
+ const [syncMsg, setSyncMsg] = useState<string>('');
280
+
281
+ async function syncMarketplaceAndReload() {
282
+ if (syncingMarket) return;
283
+ setSyncingMarket(true); setSyncMsg('');
284
+ try {
285
+ const r = await fetch('/api/workflows/marketplace', {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ body: JSON.stringify({ action: 'sync' }),
289
+ });
290
+ const j = await r.json();
291
+ if (!r.ok || j.ok === false) {
292
+ setSyncMsg(`sync failed: ${j.error || r.status}`);
293
+ return;
294
+ }
295
+ // Pull fresh onboarding state (which re-computes suggested_pipelines).
296
+ const s = await fetch('/api/onboarding').then(x => x.json()) as OnboardingState;
297
+ setState(s);
298
+ // Auto-select fortinet-* (existing user selections preserved).
299
+ setSelectedPipelines(prev => {
300
+ const next = new Set(prev);
301
+ for (const n of s.suggested_pipelines) next.add(n);
302
+ return next;
303
+ });
304
+ setSyncMsg(`synced — ${s.suggested_pipelines.length} fortinet-* pipeline(s) suggested`);
305
+ } catch (e) {
306
+ setSyncMsg(`sync failed: ${e instanceof Error ? e.message : String(e)}`);
307
+ } finally { setSyncingMarket(false); }
308
+ }
309
+
310
+ // ─── Load state ─────────────────────────────────────────
311
+ // detect-cli is a separate (slower) endpoint — fire it in parallel
312
+ // so the main state arrives instantly, CLI list fills in shortly
313
+ // after. Avoids blocking the wizard mount on `which claude` etc.
314
+ useEffect(() => {
315
+ Promise.all([
316
+ fetch('/api/onboarding').then(r => r.json()),
317
+ fetch('/api/onboarding/detect-cli').then(r => r.json()).catch(() => ({ detected: [] })),
318
+ ]).then(([s, d]: [OnboardingState, { detected: typeof s.detected_cli }]) => {
319
+ // Merge probe results into state
320
+ s.detected_cli = d.detected || [];
321
+ setState(s);
322
+ setDisplayName(s.current.displayName);
323
+ setDisplayEmail(s.current.displayEmail);
324
+ setSelectedPipelines(new Set(s.suggested_pipelines));
325
+
326
+ // Default-select rules:
327
+ // - template ships enabled=true → on by default
328
+ // - user already installed it (any prior run / manual setup) →
329
+ // on, even if template ships enabled=false (e.g. fortincm).
330
+ // Otherwise re-running the wizard would silently deselect a
331
+ // working connector and the next Apply would skip writing
332
+ // fresh tokens to it.
333
+ const defaultSelected = new Set(
334
+ (s.template_connectors || [])
335
+ .filter(c => c.default_enabled || c.already_installed)
336
+ .map(c => c.id),
337
+ );
338
+ setSelectedConnectors(defaultSelected);
339
+
340
+ // Pre-fill any prompt that declares a `default` (e.g. jenkins
341
+ // instance name = "default-jenkins"). User-set values from a prior
342
+ // run would shadow these via the "currently set" path on apply.
343
+ const presets: Record<string, string> = {};
344
+ for (const [key, def] of Object.entries(s.template_prompts || {})) {
345
+ if (def?.default && !s.prompt_values_set?.[key]) {
346
+ presets[key] = def.default;
347
+ }
348
+ }
349
+ if (Object.keys(presets).length > 0) setConnectorValues(presets);
350
+
351
+ // Pre-fill API profile from existing default (settings.chatAgent)
352
+ if (s.current.apiProfile) {
353
+ const a = s.current.apiProfile;
354
+ setApiProfileId(a.id);
355
+ setApiProvider(a.provider);
356
+ setApiBaseUrl(a.baseUrl);
357
+ setApiModel(a.model);
358
+ setApiKeyExisting(a.apiKeySet);
359
+ }
360
+
361
+ // Pre-fill CLI agent: pick existing default, else first detected
362
+ if (s.current.defaultAgent && s.current.agents.find(a => a.id === s.current.defaultAgent)) {
363
+ const a = s.current.agents.find(x => x.id === s.current.defaultAgent)!;
364
+ setCliAgentId(a.id);
365
+ if (['claude', 'codex', 'aider', 'opencode'].includes(a.tool)) {
366
+ setCliAgentTool(a.tool as any);
367
+ }
368
+ setCliAgentPath(a.path || '');
369
+ } else if (s.detected_cli.length > 0) {
370
+ const first = s.detected_cli[0];
371
+ setCliAgentId(first.name);
372
+ setCliAgentTool(first.name as any);
373
+ setCliAgentPath(first.path);
374
+ }
375
+ }).catch(() => setState({
376
+ ok: false, onboardingCompleted: false,
377
+ current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
378
+ detected_cli: [], template_connectors: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
379
+ }));
380
+ }, []);
381
+
382
+ // Quick provider preset switch
383
+ function setProviderPreset(key: keyof typeof PROVIDER_DEFAULTS) {
384
+ setApiProfileId(key);
385
+ const p = PROVIDER_DEFAULTS[key];
386
+ setApiBaseUrl(p.baseUrl);
387
+ setApiModel(p.model);
388
+ setApiProvider(key === 'anthropic' ? 'anthropic' : 'openai-compatible');
389
+ }
390
+
391
+ async function apply() {
392
+ if (applying) return;
393
+ setApplying(true); setResult(null);
394
+ try {
395
+ // Strip values whose target connector got unchecked — keeping them
396
+ // in state lets the user re-check and recover the typed value, but
397
+ // we don't want to write tokens into connectors we're not installing.
398
+ const filteredValues: Record<string, string> = {};
399
+ for (const [k, v] of Object.entries(connectorValues)) {
400
+ const targets = state?.prompt_targets?.[k] || [];
401
+ const anyTargetSelected = targets.some(t => selectedConnectors.has(t.connector));
402
+ if (anyTargetSelected || targets.length === 0) filteredValues[k] = v;
403
+ }
404
+ const payload: any = {
405
+ identity: { displayName, displayEmail },
406
+ connectorValues: filteredValues,
407
+ selectedConnectors: Array.from(selectedConnectors),
408
+ pipelines: Array.from(selectedPipelines),
409
+ projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
410
+ };
411
+ // Send apiProfile if a key was typed OR if we're re-confirming an
412
+ // existing profile (apiKeyExisting=true and user changed any field).
413
+ // Empty apiKey + existing → backend preserves the on-disk key.
414
+ if (apiKey.trim() || apiKeyExisting) {
415
+ payload.apiProfile = {
416
+ id: apiProfileId,
417
+ name: apiProfileId,
418
+ provider: apiProvider,
419
+ model: apiModel,
420
+ baseUrl: apiBaseUrl,
421
+ setAsDefault: apiSetAsDefault,
422
+ ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
423
+ };
424
+ }
425
+ if (cliAgentId) {
426
+ payload.cliAgent = {
427
+ id: cliAgentId,
428
+ tool: cliAgentTool,
429
+ ...(cliAgentPath ? { path: cliAgentPath } : {}),
430
+ setAsDefault: cliSetAsDefault,
431
+ };
432
+ }
433
+ const r = await fetch('/api/onboarding', {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify({ action: 'apply', payload }),
437
+ });
438
+ const j = await r.json();
439
+ setResult(j.phases || []);
440
+ if (j.ok) {
441
+ setDirty(false); // applied state is canonical now
442
+ setPhase('checks');
443
+ runChecks();
444
+ }
445
+ } catch (e) {
446
+ setResult([{ phase: 'request', ok: false, error: e instanceof Error ? e.message : String(e) }]);
447
+ } finally { setApplying(false); }
448
+ }
449
+
450
+ if (!state) {
451
+ return (
452
+ <DrawerShell onClose={onClose}>
453
+ <div className="p-4 text-[11px] text-[var(--text-secondary)]">Loading…</div>
454
+ </DrawerShell>
455
+ );
456
+ }
457
+
458
+ const prompts = state.template_prompts || {};
459
+ // Group prompts by the prompt's own `group` field (if set) — else
460
+ // fall back to the first connector target. Within a group, preserve
461
+ // template insertion order (gives template authors control over UI
462
+ // order; e.g. put 'gitlab token name' above 'gitlab PAT' just by
463
+ // listing it first in _prompts).
464
+ const promptGroups = (() => {
465
+ const byConnector = new Map<string, string[]>();
466
+ for (const key of Object.keys(prompts)) {
467
+ const explicit = prompts[key]?.group;
468
+ const targets = state.prompt_targets?.[key] || [];
469
+ const conn = explicit || targets[0]?.connector || '_other';
470
+ if (!byConnector.has(conn)) byConnector.set(conn, []);
471
+ byConnector.get(conn)!.push(key);
472
+ }
473
+ return Array.from(byConnector.entries());
474
+ })();
475
+
476
+ const guardedClose = () => {
477
+ if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose();
478
+ };
479
+
480
+ if (phase === 'checks') {
481
+ return (
482
+ <DrawerShell onClose={onClose}>
483
+ <div className="overflow-y-auto flex-1 p-4 space-y-4">
484
+ <h2 className="text-sm font-medium text-[var(--text-primary)]">Setup applied — verifying</h2>
485
+ <p className="text-[11px] text-[var(--text-secondary)]">
486
+ Running post-setup checks. You can finish anytime; failures here usually
487
+ just mean the corresponding token/connector wasn't configured.
488
+ </p>
489
+
490
+ <CheckRow
491
+ title="GitLab → glab CLI sync"
492
+ hint="Writes the GitLab PAT into ~/.config/glab-cli so shell `glab` matches Forge."
493
+ state={checks.glabCli}
494
+ />
495
+ <CheckRow
496
+ title="Connector login status"
497
+ hint="Probes each configured connector source (gitlab, mantis, etc.) for live auth."
498
+ state={checks.loginStatus}
499
+ renderDetail={d => Array.isArray(d) ? (
500
+ <ul className="space-y-0.5">
501
+ {d.map((row: any, i: number) => {
502
+ const ok = !!row.result?.ok;
503
+ const ref = row.source?.refresh;
504
+ return (
505
+ <li key={i} className="font-mono flex items-center gap-1 flex-wrap">
506
+ <span className={ok ? 'text-emerald-500' : 'text-red-400'}>
507
+ {ok ? '✓' : '✗'}
508
+ </span>
509
+ <span>{row.source?.label || row.source?.id || 'source'}</span>
510
+ {row.result?.message && (
511
+ <span className="text-[var(--text-secondary)]">— {row.result.message}</span>
512
+ )}
513
+ {/* Render a direct fix link only when the row failed */}
514
+ {!ok && ref?.kind === 'open-url' && ref.url && (
515
+ <a
516
+ href={ref.url}
517
+ target="_blank"
518
+ rel="noopener noreferrer"
519
+ className="text-[var(--accent)] hover:underline ml-auto"
520
+ title={ref.description || ref.url}
521
+ >
522
+ ↗ login
523
+ </a>
524
+ )}
525
+ {!ok && ref?.kind === 'show-command' && (
526
+ <code
527
+ className="text-[var(--accent)] cursor-pointer ml-auto"
528
+ title={`Click to copy — ${ref.description}`}
529
+ onClick={() => navigator.clipboard?.writeText(ref.command)}
530
+ >
531
+ ⧉ {ref.command}
532
+ </code>
533
+ )}
534
+ {!ok && ref?.kind === 'open-settings' && (
535
+ <span className="text-[var(--text-secondary)] ml-auto">
536
+ (configure in Settings → {ref.section})
537
+ </span>
538
+ )}
539
+ </li>
540
+ );
541
+ })}
542
+ </ul>
543
+ ) : null}
544
+ />
545
+ <CheckRow
546
+ title="Backend services"
547
+ hint="Web / terminal / workspace / chat process counts (per ps aux)."
548
+ state={checks.services}
549
+ renderDetail={d => d && typeof d === 'object' ? (
550
+ <ul className="space-y-0.5">
551
+ {Object.entries(d).map(([k, v]: [string, any]) => (
552
+ <li key={k} className="font-mono">
553
+ <span className={v?.running ? 'text-emerald-500' : 'text-red-400'}>
554
+ {v?.running ? '✓' : '✗'}
555
+ </span>{' '}
556
+ {k}{v?.pid ? ` (pid ${v.pid})` : ''}{v?.port ? `:${v.port}` : ''}
557
+ </li>
558
+ ))}
559
+ </ul>
560
+ ) : null}
561
+ />
562
+ </div>
563
+
564
+ <div className="border-t border-[var(--border)] p-3 flex items-center gap-2 shrink-0">
565
+ <button
566
+ onClick={runChecks}
567
+ className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)]"
568
+ >
569
+ ↻ Re-run checks
570
+ </button>
571
+ <div className="flex-1" />
572
+ <button
573
+ onClick={onComplete}
574
+ className="text-[11px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
575
+ >
576
+ Finish
577
+ </button>
578
+ </div>
579
+ </DrawerShell>
580
+ );
581
+ }
582
+
583
+ return (
584
+ <DrawerShell onClose={guardedClose}>
585
+ <div className="overflow-y-auto flex-1 p-4 space-y-5">
586
+ <h2 className="text-sm font-medium text-[var(--text-primary)]">Forge Setup</h2>
587
+ <p className="text-[11px] text-[var(--text-secondary)] -mt-3">
588
+ Fill in once. Re-runnable from Settings → "Re-run Onboarding".
589
+ </p>
590
+
591
+ {/* ── 1. Identity ──────────────────────────────────────── */}
592
+ <Section title="1. Identity" hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}.">
593
+ <Field label="Display name">
594
+ <input className={inputCls} value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Zhen Liu" />
595
+ </Field>
596
+ <Field label="Email">
597
+ <input className={inputCls} value={displayEmail} onChange={e => setDisplayEmail(e.target.value)} placeholder="zliu@fortinet.com" />
598
+ </Field>
599
+ </Section>
600
+
601
+ {/* ── 2. API Profile ───────────────────────────────────── */}
602
+ <Section title="2. Chat API key" hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest.">
603
+ <div className="flex gap-1 mb-1 flex-wrap">
604
+ {(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
605
+ <button
606
+ key={p}
607
+ onClick={() => setProviderPreset(p)}
608
+ className={`text-[10px] px-2 py-0.5 rounded ${apiProfileId === p ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'}`}
609
+ >
610
+ {p}
611
+ </button>
612
+ ))}
613
+ </div>
614
+ <Field label="Base URL">
615
+ <input className={inputCls + ' font-mono'} value={apiBaseUrl} onChange={e => setApiBaseUrl(e.target.value)} />
616
+ </Field>
617
+ <Field label="Model">
618
+ <input className={inputCls + ' font-mono'} value={apiModel} onChange={e => setApiModel(e.target.value)} />
619
+ </Field>
620
+ <Field label={apiKeyExisting ? `API Key (current profile "${apiProfileId}" already has a key — leave blank to keep, or paste new to overwrite)` : 'API Key (required to make chat work)'}>
621
+ <input
622
+ type="password"
623
+ className={inputCls + ' font-mono'}
624
+ value={apiKey}
625
+ onChange={e => setApiKey(e.target.value)}
626
+ placeholder={apiKeyExisting ? '•••••••• (keep existing)' : 'sk-...'}
627
+ />
628
+ </Field>
629
+ <label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
630
+ <input type="checkbox" checked={apiSetAsDefault} onChange={e => setApiSetAsDefault(e.target.checked)} />
631
+ Use this profile as the default chat agent
632
+ </label>
633
+ </Section>
634
+
635
+ {/* ── 2.5. CLI Agent ──────────────────────────────────── */}
636
+ <Section title="3. CLI Agent" hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below.">
637
+ {state.detected_cli.length === 0 && state.current.agents.length === 0 && (
638
+ <p className="text-[10px] text-amber-500">
639
+ No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
640
+ </p>
641
+ )}
642
+ {state.detected_cli.map(c => (
643
+ <label key={c.name} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
644
+ <input
645
+ type="radio"
646
+ name="cli-agent"
647
+ checked={cliAgentId === c.name}
648
+ onChange={() => {
649
+ setCliAgentId(c.name);
650
+ setCliAgentTool(c.name as any);
651
+ setCliAgentPath(c.path);
652
+ }}
653
+ />
654
+ <span className="font-mono">{c.name}</span>
655
+ {c.version && <span className="text-[9px] text-[var(--text-secondary)]">v{c.version}</span>}
656
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{c.path}</span>
657
+ </label>
658
+ ))}
659
+ {state.current.agents.filter(a => !state.detected_cli.find(c => c.name === a.tool)).map(a => (
660
+ <label key={a.id} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
661
+ <input
662
+ type="radio"
663
+ name="cli-agent"
664
+ checked={cliAgentId === a.id}
665
+ onChange={() => {
666
+ setCliAgentId(a.id);
667
+ if (['claude', 'codex', 'aider', 'opencode'].includes(a.tool)) {
668
+ setCliAgentTool(a.tool as any);
669
+ }
670
+ setCliAgentPath(a.path);
671
+ }}
672
+ />
673
+ <span className="font-mono">{a.id}</span>
674
+ <span className="text-[9px] text-[var(--text-secondary)]">{a.tool}</span>
675
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{a.path}</span>
676
+ </label>
677
+ ))}
678
+ {(state.detected_cli.length > 0 || state.current.agents.length > 0) && (
679
+ <label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)] mt-1">
680
+ <input type="checkbox" checked={cliSetAsDefault} onChange={e => setCliSetAsDefault(e.target.checked)} />
681
+ Use this CLI agent as the default for terminal sessions
682
+ </label>
683
+ )}
684
+ </Section>
685
+
686
+ {/* ── 3. Connectors ────────────────────────────────────── */}
687
+ <Section title="3. Connector tokens" hint="Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once.">
688
+ {(state.template_connectors || []).length === 0 && (
689
+ <p className="text-[10px] text-amber-500">
690
+ No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
691
+ </p>
692
+ )}
693
+ {/* Bulk select/deselect */}
694
+ {(state.template_connectors || []).length > 0 && (
695
+ <div className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
696
+ <button
697
+ type="button"
698
+ onClick={() => setSelectedConnectors(new Set((state.template_connectors || []).map(c => c.id)))}
699
+ className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
700
+ >Select all</button>
701
+ <button
702
+ type="button"
703
+ onClick={() => setSelectedConnectors(new Set())}
704
+ className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
705
+ >Deselect all</button>
706
+ <span className="ml-auto">{selectedConnectors.size}/{(state.template_connectors || []).length} selected</span>
707
+ </div>
708
+ )}
709
+ {(state.template_connectors || []).map(({ id: connector, has_prompts, already_installed }) => {
710
+ const checked = selectedConnectors.has(connector);
711
+ const promptKeys = (promptGroups.find(([c]) => c === connector)?.[1]) || [];
712
+ const toggle = () => {
713
+ setSelectedConnectors(prev => {
714
+ const next = new Set(prev);
715
+ if (next.has(connector)) next.delete(connector); else next.add(connector);
716
+ return next;
717
+ });
718
+ };
719
+ return (
720
+ <div key={connector} className={`border-l-2 pl-2.5 space-y-2 ${checked ? 'border-[var(--accent)]/40' : 'border-[var(--border)] opacity-60'}`}>
721
+ <label className="flex items-center gap-1.5 text-[11px] cursor-pointer">
722
+ <input type="checkbox" checked={checked} onChange={toggle} />
723
+ <span className="font-mono uppercase tracking-wider text-[var(--accent)]">{connector}</span>
724
+ {already_installed && <span className="text-[9px] text-emerald-500">● installed</span>}
725
+ {!has_prompts && <span className="text-[9px] text-[var(--text-secondary)]">(no tokens needed)</span>}
726
+ </label>
727
+ {checked && promptKeys.map(key => {
728
+ const p = prompts[key];
729
+ const v = connectorValues[key] ?? '';
730
+ const isSet = !!state.prompt_values_set?.[key];
731
+ const targets = state.prompt_targets?.[key] || [];
732
+ const sharedWith = targets.filter(t => t.connector !== connector);
733
+ return (
734
+ <div key={key} className="space-y-1 ml-5">
735
+ <label className="text-[10px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
736
+ {p.label}
737
+ {p.required && !isSet && <span className="text-red-500">*</span>}
738
+ {!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
739
+ {isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
740
+ {p.url && (
741
+ <a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
742
+ ↗ {p.url_label || 'Get token'}
743
+ </a>
744
+ )}
745
+ </label>
746
+ {p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
747
+ {sharedWith.length > 0 && (
748
+ <p className="text-[9px] text-[var(--text-secondary)] font-mono">
749
+ also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
750
+ </p>
751
+ )}
752
+ <input
753
+ type={p.secret ? 'password' : 'text'}
754
+ className={inputCls + ' font-mono'}
755
+ value={v}
756
+ onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
757
+ placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
758
+ />
759
+ </div>
760
+ );
761
+ })}
762
+ </div>
763
+ );
764
+ })}
765
+ </Section>
766
+
767
+ {/* ── 4. Pipelines ─────────────────────────────────────── */}
768
+ <Section title="4. Pipelines" hint="Installs from the workflow marketplace. fortinet-* default-selected.">
769
+ <div className="flex items-center gap-2 mb-1">
770
+ <button
771
+ type="button"
772
+ onClick={syncMarketplaceAndReload}
773
+ disabled={syncingMarket}
774
+ className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-40"
775
+ >
776
+ {syncingMarket ? 'Syncing…' : '↻ Sync marketplace'}
777
+ </button>
778
+ {syncMsg && <span className="text-[10px] text-[var(--text-secondary)]">{syncMsg}</span>}
779
+ </div>
780
+ {state.suggested_pipelines.length === 0 && (
781
+ <p className="text-[10px] text-amber-500">
782
+ No pipelines found yet. Click <strong>Sync marketplace</strong> above to pull the latest registry.
783
+ </p>
784
+ )}
785
+ {state.suggested_pipelines.map(name => (
786
+ <label key={name} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
787
+ <input
788
+ type="checkbox"
789
+ checked={selectedPipelines.has(name)}
790
+ onChange={e => {
791
+ const next = new Set(selectedPipelines);
792
+ if (e.target.checked) next.add(name); else next.delete(name);
793
+ setSelectedPipelines(next);
794
+ }}
795
+ />
796
+ <span className="font-mono">{name}</span>
797
+ </label>
798
+ ))}
799
+ </Section>
800
+
801
+ {/* ── 5. Projects ──────────────────────────────────────── */}
802
+ <Section title="5. Project roots (optional)" hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line).">
803
+ {state.current.projectRoots.length > 0 && (
804
+ <p className="text-[10px] text-[var(--text-secondary)]">
805
+ Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
806
+ </p>
807
+ )}
808
+ <div className="flex items-center gap-2">
809
+ <button
810
+ type="button"
811
+ onClick={() => openPicker()}
812
+ className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
813
+ >
814
+ 📁 Pick folder…
815
+ </button>
816
+ <span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
817
+ </div>
818
+ <textarea
819
+ rows={2}
820
+ className={inputCls + ' font-mono'}
821
+ value={projectInput}
822
+ onChange={e => setProjectInput(e.target.value)}
823
+ placeholder="/Users/you/IdeaProjects"
824
+ />
825
+
826
+ {pickerOpen && (
827
+ <div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
828
+ <div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
829
+ {pickerParent && (
830
+ <button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
831
+ )}
832
+ <span className="truncate">{pickerPath || '(loading)'}</span>
833
+ <button
834
+ onClick={() => setPickerOpen(false)}
835
+ className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
836
+ >✕</button>
837
+ </div>
838
+ <div className="max-h-40 overflow-y-auto space-y-0.5">
839
+ {pickerEntries.length === 0 && (
840
+ <p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
841
+ )}
842
+ {pickerEntries.map(e => (
843
+ <div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
844
+ <button
845
+ onClick={() => openPicker(e.path)}
846
+ className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
847
+ title="Drill into this folder"
848
+ >
849
+ 📁 {e.name}
850
+ </button>
851
+ <button
852
+ onClick={() => addPickedPath(e.path)}
853
+ className="text-[9px] text-[var(--accent)] hover:underline px-1"
854
+ title="Add this folder to project roots"
855
+ >+ pick</button>
856
+ </div>
857
+ ))}
858
+ </div>
859
+ {pickerPath && (
860
+ <button
861
+ onClick={() => addPickedPath(pickerPath)}
862
+ className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
863
+ >
864
+ Use current folder: <span className="font-mono">{pickerPath}</span>
865
+ </button>
866
+ )}
867
+ </div>
868
+ )}
869
+ </Section>
870
+
871
+ {/* ── 6. Memory (info only) ────────────────────────────── */}
872
+ <Section title="6. Memory">
873
+ <p className="text-[10px] text-[var(--text-secondary)]">
874
+ Defaults to local SQLite — no setup needed. Want team-shared memory?
875
+ Configure Temper later in Settings → Memory.
876
+ </p>
877
+ </Section>
878
+ </div>
879
+
880
+ {/* Footer */}
881
+ <div className="border-t border-[var(--border)] p-3 flex items-center gap-2 shrink-0">
882
+ {result && (
883
+ <div className="flex-1 text-[10px]">
884
+ {result.map((p, i) => (
885
+ <span key={i} className={`mr-2 ${p.ok ? 'text-emerald-500' : 'text-red-400'}`}>
886
+ {p.ok ? '✓' : '✗'} {p.phase}
887
+ </span>
888
+ ))}
889
+ </div>
890
+ )}
891
+ <button
892
+ onClick={() => { if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose(); }}
893
+ className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)]"
894
+ >
895
+ Close
896
+ </button>
897
+ <button
898
+ onClick={async () => {
899
+ if (dirty && !confirm('Skip setup? Your unsaved inputs will be discarded. (Banner will not appear again.)')) return;
900
+ // Mark onboarding completed without applying anything — empty payload.
901
+ await fetch('/api/onboarding', {
902
+ method: 'POST',
903
+ headers: { 'Content-Type': 'application/json' },
904
+ body: JSON.stringify({ action: 'apply', payload: {} }),
905
+ });
906
+ onComplete();
907
+ }}
908
+ className="text-[10px] px-2.5 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
909
+ title="Mark setup as done without applying anything"
910
+ >
911
+ Skip
912
+ </button>
913
+ <button
914
+ onClick={apply}
915
+ disabled={applying}
916
+ className="text-[11px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-40"
917
+ >
918
+ {applying ? 'Applying…' : 'Apply & Finish'}
919
+ </button>
920
+ </div>
921
+ </DrawerShell>
922
+ );
923
+ }
924
+
925
+ // ─── Drawer shell ────────────────────────────────────────────
926
+
927
+ function DrawerShell({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
928
+ return (
929
+ <>
930
+ {/* Backdrop — semi-opaque, click-to-close */}
931
+ <div className="fixed inset-0 bg-black/30 z-40" onClick={onClose} />
932
+ {/* Drawer */}
933
+ <div className="fixed right-0 top-0 bottom-0 w-[520px] max-w-full bg-[var(--bg-primary)] border-l border-[var(--border)] shadow-xl z-50 flex flex-col">
934
+ {children}
935
+ </div>
936
+ </>
937
+ );
938
+ }
939
+
940
+ function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
941
+ return (
942
+ <div className="space-y-1.5 pb-3 border-b border-[var(--border)]/40">
943
+ <h3 className="text-[12px] font-medium text-[var(--text-primary)]">{title}</h3>
944
+ {hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{hint}</p>}
945
+ {children}
946
+ </div>
947
+ );
948
+ }
949
+
950
+ function CheckRow({
951
+ title, hint, state, renderDetail,
952
+ }: {
953
+ title: string;
954
+ hint?: string;
955
+ state: { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
956
+ renderDetail?: (d: any) => React.ReactNode;
957
+ }) {
958
+ const [open, setOpen] = useState(false);
959
+ const icon = state.status === 'ok' ? '✓'
960
+ : state.status === 'fail' ? '✗'
961
+ : state.status === 'running' ? '⟳'
962
+ : state.status === 'skipped' ? '–'
963
+ : '○';
964
+ const color = state.status === 'ok' ? 'text-emerald-500'
965
+ : state.status === 'fail' ? 'text-red-400'
966
+ : state.status === 'running' ? 'text-amber-500'
967
+ : state.status === 'skipped' ? 'text-[var(--text-secondary)]'
968
+ : 'text-[var(--text-secondary)]';
969
+ return (
970
+ <div className="border border-[var(--border)] rounded p-2.5 space-y-1">
971
+ <div className="flex items-center gap-2 text-[12px]">
972
+ <span className={`${color} font-mono w-3 inline-block`}>{icon}</span>
973
+ <span className="text-[var(--text-primary)] font-medium">{title}</span>
974
+ {state.message && (
975
+ <span className={`text-[10px] ${state.status === 'fail' ? 'text-red-400' : 'text-[var(--text-secondary)]'} truncate`}>
976
+ — {state.message}
977
+ </span>
978
+ )}
979
+ {state.detail && renderDetail && (
980
+ <button onClick={() => setOpen(o => !o)} className="ml-auto text-[10px] text-[var(--accent)] hover:underline">
981
+ {open ? 'hide' : 'details'}
982
+ </button>
983
+ )}
984
+ </div>
985
+ {hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug ml-5">{hint}</p>}
986
+ {open && state.detail && renderDetail && (
987
+ <div className="text-[10px] text-[var(--text-secondary)] ml-5 pt-1 border-t border-[var(--border)]/40">
988
+ {renderDetail(state.detail)}
989
+ </div>
990
+ )}
991
+ </div>
992
+ );
993
+ }
994
+
995
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
996
+ return (
997
+ <div className="space-y-0.5">
998
+ <label className="text-[10px] text-[var(--text-secondary)]">{label}</label>
999
+ {children}
1000
+ </div>
1001
+ );
1002
+ }