@aion0/forge 0.10.36 → 0.10.37

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