@aion0/forge 0.10.39 → 0.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +83 -6
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +897 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/bin/forge-server.mjs +189 -30
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- package/templates/connector-config-template.json +0 -7
|
@@ -55,6 +55,10 @@ interface OnboardingState {
|
|
|
55
55
|
default_enabled: boolean;
|
|
56
56
|
has_prompts: boolean;
|
|
57
57
|
already_installed: boolean;
|
|
58
|
+
/** Template-baked field values for this connector (excluding ${...}
|
|
59
|
+
* prompts, which appear in section 3). Secrets are masked to
|
|
60
|
+
* '••••••••'. Instances arrays are flattened to '<name>.<field>'. */
|
|
61
|
+
defaults?: Array<{ field: string; value: string; is_secret: boolean }>;
|
|
58
62
|
}>;
|
|
59
63
|
template_prompts: Record<string, PromptDef>;
|
|
60
64
|
/** Per ${key} prompt: true = at least one target field already has a real value;
|
|
@@ -62,7 +66,51 @@ interface OnboardingState {
|
|
|
62
66
|
prompt_values_set: Record<string, boolean>;
|
|
63
67
|
/** Per ${key}: which connector.field references it (for UI hint). */
|
|
64
68
|
prompt_targets: Record<string, Array<{ connector: string; field: string }>>;
|
|
69
|
+
/** Pre-baked CLI agents the template will install (from `_agents`). */
|
|
70
|
+
template_agents_preview?: Array<{ id: string; tool?: string; has_secret: boolean }>;
|
|
71
|
+
/** Pre-baked API profiles the template will install (from `_apiProfiles`). */
|
|
72
|
+
template_api_profiles_preview?: Array<{ id: string; provider?: string; model?: string; has_secret: boolean }>;
|
|
73
|
+
/** Keys the template auto-derives from other prompts (e.g. user_email_local). */
|
|
74
|
+
template_derive_keys?: string[];
|
|
75
|
+
/** Lowercased enterprise name when the template is enterprise-sourced. */
|
|
76
|
+
template_enterprise_name?: string;
|
|
77
|
+
/** Wizard layout knobs declared by template._wizard — controls how much of
|
|
78
|
+
* the wizard's collapsing/hiding logic kicks in. Enterprise templates with
|
|
79
|
+
* nearly-everything baked set minimal:true so the user only sees Identity
|
|
80
|
+
* + required prompts + preview. */
|
|
81
|
+
template_wizard?: {
|
|
82
|
+
minimal: boolean;
|
|
83
|
+
hide_connectors: boolean;
|
|
84
|
+
hide_pipelines: boolean;
|
|
85
|
+
required_only: boolean;
|
|
86
|
+
};
|
|
87
|
+
/** When the template ships a `_temperAdmin: {url, token}` block (or the user
|
|
88
|
+
* set temperAdminToken manually), Forge will auto-provision a Temper memory
|
|
89
|
+
* account on Apply. `provisioned` reflects whether settings.temperKey is
|
|
90
|
+
* already populated (= no re-provision will happen). */
|
|
91
|
+
memory_auto_provision?: {
|
|
92
|
+
url: string;
|
|
93
|
+
provisioned: boolean;
|
|
94
|
+
};
|
|
65
95
|
suggested_pipelines: string[];
|
|
96
|
+
/** E2: list of source ids the wizard can switch between. Powers the
|
|
97
|
+
* tenant selector at the top. `public` always present; user override
|
|
98
|
+
* + each enterprise tenant appear when configured. */
|
|
99
|
+
available_sources?: Array<{ id: string; display_name: string; has_template: boolean }>;
|
|
100
|
+
/** Source id that actually fed the rendered template — null when none
|
|
101
|
+
* cached (bundled fallback in use). */
|
|
102
|
+
resolved_source?: string | null;
|
|
103
|
+
/** The source_id the GET request asked for (null when none requested). */
|
|
104
|
+
requested_source_id?: string | null;
|
|
105
|
+
/** E3: dept picker — list of departments within the currently-scoped
|
|
106
|
+
* source. Empty = single-template source, no picker shown. */
|
|
107
|
+
available_departments?: Array<{ id: string; display_name: string }>;
|
|
108
|
+
/** Dept id actually rendered (defaults to first dept when none requested). */
|
|
109
|
+
resolved_dept?: string | null;
|
|
110
|
+
requested_dept_id?: string | null;
|
|
111
|
+
/** Template's self-declared `_department_name`. Surfaced for the
|
|
112
|
+
* breadcrumb so the user sees what {dept.name} resolves to. */
|
|
113
|
+
template_department_name?: string;
|
|
66
114
|
}
|
|
67
115
|
|
|
68
116
|
interface ApplyResultPhase {
|
|
@@ -111,6 +159,13 @@ export function OnboardingBanner({ onOpen }: { onOpen: () => void }) {
|
|
|
111
159
|
|
|
112
160
|
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
161
|
|
|
162
|
+
function buildOnboardingUrl(sourceId: string | null, deptId: string | null): string {
|
|
163
|
+
const q = new URLSearchParams();
|
|
164
|
+
if (sourceId) q.set('source_id', sourceId);
|
|
165
|
+
if (deptId) q.set('dept', deptId);
|
|
166
|
+
return q.size ? `/api/onboarding?${q.toString()}` : '/api/onboarding';
|
|
167
|
+
}
|
|
168
|
+
|
|
114
169
|
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
|
|
115
170
|
anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
|
|
116
171
|
deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
@@ -121,8 +176,19 @@ const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
|
|
|
121
176
|
litellm: { baseUrl: 'http://127.0.0.1:4000/v1', model: 'gpt-4o-mini' },
|
|
122
177
|
};
|
|
123
178
|
|
|
124
|
-
export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void; onComplete: () => void }) {
|
|
179
|
+
export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onClose: () => void; onComplete: () => void; initialSourceId?: string | null }) {
|
|
125
180
|
const [state, setState] = useState<OnboardingState | null>(null);
|
|
181
|
+
// E2: which source's template is currently scoped to. null = use the
|
|
182
|
+
// default priority chain (server's resolveTemplate decides). Changing
|
|
183
|
+
// this triggers a refetch + re-render of the entire wizard so prompts
|
|
184
|
+
// and connector lists swap to the chosen tenant's template.
|
|
185
|
+
// E4: when EnterpriseBadge hands off after a fresh key add, the
|
|
186
|
+
// source id arrives via prop so the wizard renders scoped from the
|
|
187
|
+
// first mount.
|
|
188
|
+
const [currentSource, setCurrentSource] = useState<string | null>(initialSourceId ?? null);
|
|
189
|
+
// E3: which dept within `currentSource`. null = use the source's first
|
|
190
|
+
// dept. Changing this re-fetches the wizard state.
|
|
191
|
+
const [currentDept, setCurrentDept] = useState<string | null>(null);
|
|
126
192
|
|
|
127
193
|
// Section 1: Identity
|
|
128
194
|
const [displayName, setDisplayName] = useState('');
|
|
@@ -192,7 +258,15 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
192
258
|
// backend monitor probes — so users see what's actually live before
|
|
193
259
|
// we dismiss the wizard.
|
|
194
260
|
type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
|
|
195
|
-
|
|
261
|
+
// 'splash' is the cold-boot choose-tenant gate: shown when there's no
|
|
262
|
+
// enterprise source yet so the user picks between adding an enterprise
|
|
263
|
+
// key or continuing with the public template. Skipped entirely when
|
|
264
|
+
// the wizard launches scoped to an existing source (e.g. handoff from
|
|
265
|
+
// EnterpriseBadge add) or when enterprise is already configured.
|
|
266
|
+
const [phase, setPhase] = useState<'splash' | 'form' | 'checks'>('form');
|
|
267
|
+
const [splashAddKey, setSplashAddKey] = useState('');
|
|
268
|
+
const [splashBusy, setSplashBusy] = useState(false);
|
|
269
|
+
const [splashError, setSplashError] = useState('');
|
|
196
270
|
const [checks, setChecks] = useState<Record<string, CheckState>>({
|
|
197
271
|
glabCli: { status: 'pending' },
|
|
198
272
|
loginStatus: { status: 'pending' },
|
|
@@ -293,7 +367,8 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
293
367
|
return;
|
|
294
368
|
}
|
|
295
369
|
// Pull fresh onboarding state (which re-computes suggested_pipelines).
|
|
296
|
-
const
|
|
370
|
+
const url = buildOnboardingUrl(currentSource, currentDept);
|
|
371
|
+
const s = await fetch(url).then(x => x.json()) as OnboardingState;
|
|
297
372
|
setState(s);
|
|
298
373
|
// Auto-select fortinet-* (existing user selections preserved).
|
|
299
374
|
setSelectedPipelines(prev => {
|
|
@@ -312,13 +387,27 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
312
387
|
// so the main state arrives instantly, CLI list fills in shortly
|
|
313
388
|
// after. Avoids blocking the wizard mount on `which claude` etc.
|
|
314
389
|
useEffect(() => {
|
|
390
|
+
// E2/E3: re-runs whenever `currentSource` or `currentDept` changes,
|
|
391
|
+
// so switching either dropdown pulls a fresh template + prompts.
|
|
392
|
+
// detect-cli is scope-agnostic, only re-fetch on mount.
|
|
393
|
+
const url = buildOnboardingUrl(currentSource, currentDept);
|
|
315
394
|
Promise.all([
|
|
316
|
-
fetch(
|
|
395
|
+
fetch(url).then(r => r.json()),
|
|
317
396
|
fetch('/api/onboarding/detect-cli').then(r => r.json()).catch(() => ({ detected: [] })),
|
|
318
397
|
]).then(([s, d]: [OnboardingState, { detected: typeof s.detected_cli }]) => {
|
|
319
398
|
// Merge probe results into state
|
|
320
399
|
s.detected_cli = d.detected || [];
|
|
321
400
|
setState(s);
|
|
401
|
+
// Cold-boot gate: no enterprise sources AND no explicit scope from
|
|
402
|
+
// the EnterpriseBadge handoff → show the choose-tenant splash.
|
|
403
|
+
// Once user picks (add enterprise OR continue with public), we
|
|
404
|
+
// flip to 'form' and the load effect re-runs with the choice
|
|
405
|
+
// baked in.
|
|
406
|
+
const hasEnterpriseSource = Array.isArray(s.available_sources)
|
|
407
|
+
&& s.available_sources.some(src => src.id !== 'public' && src.id !== 'user' && src.has_template);
|
|
408
|
+
if (!hasEnterpriseSource && !currentSource && !initialSourceId) {
|
|
409
|
+
setPhase('splash');
|
|
410
|
+
}
|
|
322
411
|
setDisplayName(s.current.displayName);
|
|
323
412
|
setDisplayEmail(s.current.displayEmail);
|
|
324
413
|
setSelectedPipelines(new Set(s.suggested_pipelines));
|
|
@@ -377,7 +466,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
377
466
|
current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
|
|
378
467
|
detected_cli: [], template_connectors: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
|
|
379
468
|
}));
|
|
380
|
-
}, []);
|
|
469
|
+
}, [currentSource, currentDept]);
|
|
381
470
|
|
|
382
471
|
// Quick provider preset switch
|
|
383
472
|
function setProviderPreset(key: keyof typeof PROVIDER_DEFAULTS) {
|
|
@@ -388,6 +477,36 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
388
477
|
setApiProvider(key === 'anthropic' ? 'anthropic' : 'openai-compatible');
|
|
389
478
|
}
|
|
390
479
|
|
|
480
|
+
async function splashAddEnterprise() {
|
|
481
|
+
const key = splashAddKey.trim();
|
|
482
|
+
if (!key || splashBusy) return;
|
|
483
|
+
setSplashBusy(true); setSplashError('');
|
|
484
|
+
try {
|
|
485
|
+
const r = await fetch('/api/enterprise-keys', {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { 'Content-Type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({ key }),
|
|
489
|
+
});
|
|
490
|
+
const data = await r.json();
|
|
491
|
+
if (!data.ok) { setSplashError(data.error || 'failed to add key'); return; }
|
|
492
|
+
// Hand off to the scoped wizard. Setting currentSource fires the
|
|
493
|
+
// load effect which re-fetches the rendered template, prompts,
|
|
494
|
+
// dept list etc. for the new tenant. `phase` flips to 'form' so
|
|
495
|
+
// the splash is gone on next render.
|
|
496
|
+
if (data.source_id) setCurrentSource(data.source_id);
|
|
497
|
+
setPhase('form');
|
|
498
|
+
} catch (e) {
|
|
499
|
+
setSplashError(e instanceof Error ? e.message : String(e));
|
|
500
|
+
} finally {
|
|
501
|
+
setSplashBusy(false);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function splashUsePublic() {
|
|
506
|
+
setCurrentSource(null); // priority chain → public falls through
|
|
507
|
+
setPhase('form');
|
|
508
|
+
}
|
|
509
|
+
|
|
391
510
|
async function apply() {
|
|
392
511
|
if (applying) return;
|
|
393
512
|
setApplying(true); setResult(null);
|
|
@@ -407,6 +526,11 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
407
526
|
selectedConnectors: Array.from(selectedConnectors),
|
|
408
527
|
pipelines: Array.from(selectedPipelines),
|
|
409
528
|
projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
|
529
|
+
// E2/E3: apply against the same template GET rendered. Use
|
|
530
|
+
// state.resolved_* as fallback so an untouched dropdown still
|
|
531
|
+
// sends what was rendered (server's default lookup matches).
|
|
532
|
+
...(currentSource || state?.resolved_source ? { sourceId: currentSource || state?.resolved_source } : {}),
|
|
533
|
+
...(currentDept || state?.resolved_dept ? { deptId: currentDept || state?.resolved_dept } : {}),
|
|
410
534
|
};
|
|
411
535
|
// Send apiProfile if a key was typed OR if we're re-confirming an
|
|
412
536
|
// existing profile (apiKeyExisting=true and user changed any field).
|
|
@@ -477,6 +601,62 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
477
601
|
if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose();
|
|
478
602
|
};
|
|
479
603
|
|
|
604
|
+
if (phase === 'splash') {
|
|
605
|
+
return (
|
|
606
|
+
<DrawerShell onClose={onClose}>
|
|
607
|
+
<div className="overflow-y-auto flex-1 p-5 space-y-4">
|
|
608
|
+
<h2 className="text-sm font-medium text-[var(--text-primary)]">Welcome to Forge</h2>
|
|
609
|
+
<p className="text-[11px] text-[var(--text-secondary)] leading-snug">
|
|
610
|
+
Start with an enterprise template (Fortinet, etc. — pre-baked
|
|
611
|
+
tokens, agents, and pipelines for your org), or continue with
|
|
612
|
+
the public template (you wire each connector by hand).
|
|
613
|
+
</p>
|
|
614
|
+
|
|
615
|
+
<div className="border border-[var(--accent)]/30 bg-[var(--accent)]/5 rounded p-3 space-y-2">
|
|
616
|
+
<div className="text-[11px] font-medium text-[var(--text-primary)]">🔒 Add enterprise key</div>
|
|
617
|
+
<div className="text-[10px] text-[var(--text-secondary)] leading-snug">
|
|
618
|
+
Paste a key like <code>fortinet:<PAT></code> or the long form
|
|
619
|
+
<code> <PAT>@github.com/org/repo</code>. After validation the
|
|
620
|
+
wizard reloads scoped to that tenant.
|
|
621
|
+
</div>
|
|
622
|
+
<input
|
|
623
|
+
type="password"
|
|
624
|
+
value={splashAddKey}
|
|
625
|
+
onChange={(e) => setSplashAddKey(e.target.value)}
|
|
626
|
+
placeholder="enterprise key"
|
|
627
|
+
autoFocus
|
|
628
|
+
className={inputCls}
|
|
629
|
+
onKeyDown={(e) => { if (e.key === 'Enter') splashAddEnterprise(); }}
|
|
630
|
+
/>
|
|
631
|
+
{splashError && (
|
|
632
|
+
<div className="text-[10px] text-red-400">{splashError}</div>
|
|
633
|
+
)}
|
|
634
|
+
<button
|
|
635
|
+
onClick={splashAddEnterprise}
|
|
636
|
+
disabled={splashBusy || !splashAddKey.trim()}
|
|
637
|
+
className="text-[11px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
|
|
638
|
+
>
|
|
639
|
+
{splashBusy ? 'Adding…' : 'Add + continue →'}
|
|
640
|
+
</button>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
<div className="text-center text-[10px] text-[var(--text-secondary)]">or</div>
|
|
644
|
+
|
|
645
|
+
<button
|
|
646
|
+
onClick={splashUsePublic}
|
|
647
|
+
className="w-full text-[11px] px-3 py-2 rounded border border-[var(--border)] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
|
648
|
+
>
|
|
649
|
+
Continue with public template →
|
|
650
|
+
</button>
|
|
651
|
+
|
|
652
|
+
<p className="text-[9px] text-[var(--text-secondary)] italic">
|
|
653
|
+
You can always add an enterprise key later from the badge in the top-left.
|
|
654
|
+
</p>
|
|
655
|
+
</div>
|
|
656
|
+
</DrawerShell>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
480
660
|
if (phase === 'checks') {
|
|
481
661
|
return (
|
|
482
662
|
<DrawerShell onClose={onClose}>
|
|
@@ -580,6 +760,12 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
580
760
|
);
|
|
581
761
|
}
|
|
582
762
|
|
|
763
|
+
// Section numbers are computed render-time so they stay sequential even
|
|
764
|
+
// when the template auto-installs API profile / agents (which hides sections
|
|
765
|
+
// 2 and 3) and minimal-mode pipelines stay visible.
|
|
766
|
+
let step = 0;
|
|
767
|
+
const stepN = () => ++step;
|
|
768
|
+
|
|
583
769
|
return (
|
|
584
770
|
<DrawerShell onClose={guardedClose}>
|
|
585
771
|
<div className="overflow-y-auto flex-1 p-4 space-y-5">
|
|
@@ -587,19 +773,117 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
587
773
|
<p className="text-[11px] text-[var(--text-secondary)] -mt-3">
|
|
588
774
|
Fill in once. Re-runnable from Settings → "Re-run Onboarding".
|
|
589
775
|
</p>
|
|
776
|
+
{/* E2: tenant selector — only renders when there's more than one
|
|
777
|
+
source to pick from (typical user has 1 enterprise + public).
|
|
778
|
+
`resolved_source` is highlighted with ✓; sources that haven't
|
|
779
|
+
cached a template yet show greyed out with "(not synced)". */}
|
|
780
|
+
{((state.available_sources && state.available_sources.filter(s => s.has_template).length > 1)
|
|
781
|
+
|| (state.available_departments && state.available_departments.length > 1)) && (
|
|
782
|
+
<div className="flex items-center gap-2 text-[11px] -mt-2 flex-wrap">
|
|
783
|
+
<span className="text-[var(--text-secondary)]">Wizard for:</span>
|
|
784
|
+
{state.available_sources && state.available_sources.filter(s => s.has_template).length > 1 && (
|
|
785
|
+
<select
|
|
786
|
+
value={currentSource ?? (state.resolved_source ?? '')}
|
|
787
|
+
onChange={(e) => {
|
|
788
|
+
// Source switch resets the dept choice — the new tenant's
|
|
789
|
+
// depts are a different namespace, so let the server pick
|
|
790
|
+
// the default again rather than mis-mapping the slug.
|
|
791
|
+
setCurrentSource(e.target.value || null);
|
|
792
|
+
setCurrentDept(null);
|
|
793
|
+
}}
|
|
794
|
+
className="text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
795
|
+
>
|
|
796
|
+
{state.available_sources.map(s => (
|
|
797
|
+
<option key={s.id} value={s.id} disabled={!s.has_template}>
|
|
798
|
+
{s.display_name}{!s.has_template ? ' (not synced)' : ''}{s.id === state.resolved_source ? ' ✓' : ''}
|
|
799
|
+
</option>
|
|
800
|
+
))}
|
|
801
|
+
</select>
|
|
802
|
+
)}
|
|
803
|
+
{state.available_departments && state.available_departments.length > 1 && (
|
|
804
|
+
<>
|
|
805
|
+
<span className="text-[var(--text-secondary)]">·</span>
|
|
806
|
+
<select
|
|
807
|
+
value={currentDept ?? (state.resolved_dept ?? '')}
|
|
808
|
+
onChange={(e) => setCurrentDept(e.target.value || null)}
|
|
809
|
+
className="text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
810
|
+
>
|
|
811
|
+
{state.available_departments.map(d => (
|
|
812
|
+
<option key={d.id} value={d.id}>
|
|
813
|
+
{d.display_name}{d.id === state.resolved_dept ? ' ✓' : ''}
|
|
814
|
+
</option>
|
|
815
|
+
))}
|
|
816
|
+
</select>
|
|
817
|
+
</>
|
|
818
|
+
)}
|
|
819
|
+
{state.template_department_name && (
|
|
820
|
+
<span className="text-[10px] text-[var(--text-secondary)] italic">
|
|
821
|
+
{`{dept.name} = "${state.template_department_name}"`}
|
|
822
|
+
</span>
|
|
823
|
+
)}
|
|
824
|
+
</div>
|
|
825
|
+
)}
|
|
590
826
|
|
|
591
|
-
{/* ──
|
|
592
|
-
<Section title=
|
|
827
|
+
{/* ── Identity ─────────────────────────────────────────── */}
|
|
828
|
+
<Section title={`${stepN()}. Identity`} hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}. Forge derives things like jenkins username from the email's local-part.">
|
|
593
829
|
<Field label="Display name">
|
|
594
830
|
<input className={inputCls} value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Zhen Liu" />
|
|
595
831
|
</Field>
|
|
596
832
|
<Field label="Email">
|
|
597
833
|
<input className={inputCls} value={displayEmail} onChange={e => setDisplayEmail(e.target.value)} placeholder="zliu@fortinet.com" />
|
|
598
834
|
</Field>
|
|
835
|
+
{state.template_derive_keys?.length ? (
|
|
836
|
+
<p className="text-[9px] text-[var(--text-secondary)] italic">
|
|
837
|
+
Auto-derived from your email: {state.template_derive_keys.join(', ')}.
|
|
838
|
+
</p>
|
|
839
|
+
) : null}
|
|
599
840
|
</Section>
|
|
600
841
|
|
|
601
|
-
{/* ──
|
|
602
|
-
|
|
842
|
+
{/* ── Template pre-baked items ─────────────────────────── */}
|
|
843
|
+
{(state.template_agents_preview?.length || state.template_api_profiles_preview?.length) && (
|
|
844
|
+
<Section
|
|
845
|
+
title={`${stepN()}. ${state.template_enterprise_name
|
|
846
|
+
? `🔒 ${state.template_enterprise_name} — auto-installed`
|
|
847
|
+
: '🔒 Template — auto-installed'}`}
|
|
848
|
+
hint="The enterprise template pre-bakes these on your behalf. Tokens are encrypted in git and decrypted locally. You can edit / disable any of them in Settings after onboarding."
|
|
849
|
+
>
|
|
850
|
+
{state.template_agents_preview?.length ? (
|
|
851
|
+
<div>
|
|
852
|
+
<div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1">CLI agents</div>
|
|
853
|
+
<ul className="space-y-0.5">
|
|
854
|
+
{state.template_agents_preview.map((a) => (
|
|
855
|
+
<li key={a.id} className="text-[11px] font-mono flex items-center gap-1.5">
|
|
856
|
+
<span className="text-emerald-500">✓</span>
|
|
857
|
+
<span className="text-[var(--text-primary)]">{a.id}</span>
|
|
858
|
+
{a.tool && <span className="text-[9px] text-[var(--text-secondary)]">{a.tool}</span>}
|
|
859
|
+
{a.has_secret && <span className="text-[9px] text-amber-500">🔐 secret</span>}
|
|
860
|
+
</li>
|
|
861
|
+
))}
|
|
862
|
+
</ul>
|
|
863
|
+
</div>
|
|
864
|
+
) : null}
|
|
865
|
+
{state.template_api_profiles_preview?.length ? (
|
|
866
|
+
<div className="mt-2">
|
|
867
|
+
<div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1">Chat profiles</div>
|
|
868
|
+
<ul className="space-y-0.5">
|
|
869
|
+
{state.template_api_profiles_preview.map((p) => (
|
|
870
|
+
<li key={p.id} className="text-[11px] font-mono flex items-center gap-1.5">
|
|
871
|
+
<span className="text-emerald-500">✓</span>
|
|
872
|
+
<span className="text-[var(--text-primary)]">{p.id}</span>
|
|
873
|
+
{p.provider && <span className="text-[9px] text-[var(--text-secondary)]">{p.provider}</span>}
|
|
874
|
+
{p.model && <span className="text-[9px] text-[var(--text-secondary)]">{p.model}</span>}
|
|
875
|
+
{p.has_secret && <span className="text-[9px] text-amber-500">🔐 secret</span>}
|
|
876
|
+
</li>
|
|
877
|
+
))}
|
|
878
|
+
</ul>
|
|
879
|
+
</div>
|
|
880
|
+
) : null}
|
|
881
|
+
</Section>
|
|
882
|
+
)}
|
|
883
|
+
|
|
884
|
+
{/* ── 2. API Profile (hidden when template provides one) ─ */}
|
|
885
|
+
{!state.template_api_profiles_preview?.length && (
|
|
886
|
+
<Section title={`${stepN()}. Chat API key`} hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest.">
|
|
603
887
|
<div className="flex gap-1 mb-1 flex-wrap">
|
|
604
888
|
{(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
|
|
605
889
|
<button
|
|
@@ -631,9 +915,11 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
631
915
|
Use this profile as the default chat agent
|
|
632
916
|
</label>
|
|
633
917
|
</Section>
|
|
918
|
+
)}
|
|
634
919
|
|
|
635
|
-
{/* ── 2.5. CLI Agent
|
|
636
|
-
|
|
920
|
+
{/* ── 2.5. CLI Agent (hidden when template provides _agents) ─ */}
|
|
921
|
+
{!state.template_agents_preview?.length && (
|
|
922
|
+
<Section title={`${stepN()}. CLI Agent`} hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below.">
|
|
637
923
|
{state.detected_cli.length === 0 && state.current.agents.length === 0 && (
|
|
638
924
|
<p className="text-[10px] text-amber-500">
|
|
639
925
|
No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
|
|
@@ -682,20 +968,73 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
682
968
|
</label>
|
|
683
969
|
)}
|
|
684
970
|
</Section>
|
|
971
|
+
)}
|
|
972
|
+
|
|
973
|
+
{/* ── 3. Connectors ──────────────────────────────────────
|
|
974
|
+
In minimal mode we drop the bulk selector AND filter the connector
|
|
975
|
+
list to ones that still need user input — anything fully baked or
|
|
976
|
+
already-set silently rides along on the pre-selected set. */}
|
|
977
|
+
{(() => {
|
|
978
|
+
const w = state.template_wizard;
|
|
979
|
+
const minimal = !!w?.minimal;
|
|
980
|
+
const hideConnectors = !!w?.hide_connectors;
|
|
981
|
+
const requiredOnly = !!w?.required_only;
|
|
982
|
+
const allConnectors = state.template_connectors || [];
|
|
983
|
+
const promptsByKey = state.template_prompts || {};
|
|
984
|
+
const valuesSet = state.prompt_values_set || {};
|
|
985
|
+
|
|
986
|
+
// Which prompts to show for a given connector under the current
|
|
987
|
+
// visibility rules. In `requiredOnly` mode: drop non-required ones,
|
|
988
|
+
// but ALWAYS keep required prompts visible (set or not) so the user
|
|
989
|
+
// can re-enter a password / rotate a token without leaving minimal
|
|
990
|
+
// mode. The "● currently set" badge plus "leave blank to keep"
|
|
991
|
+
// placeholder already communicates that they don't have to touch it.
|
|
992
|
+
const visiblePromptsFor = (connectorId: string): string[] => {
|
|
993
|
+
const keys = (promptGroups.find(([c]) => c === connectorId)?.[1]) || [];
|
|
994
|
+
if (!requiredOnly) return keys;
|
|
995
|
+
return keys.filter(k => promptsByKey[k]?.required);
|
|
996
|
+
};
|
|
685
997
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
998
|
+
// Show every connector that has SOMETHING to display — either
|
|
999
|
+
// user-visible prompts or template-baked defaults. Even in
|
|
1000
|
+
// minimal mode we no longer hide connectors with no prompts:
|
|
1001
|
+
// the user explicitly wants visibility into what the template
|
|
1002
|
+
// installs (e.g. mantis base_url, pmdb base_url).
|
|
1003
|
+
const visibleConnectors = hideConnectors
|
|
1004
|
+
? []
|
|
1005
|
+
: (minimal
|
|
1006
|
+
? allConnectors.filter(({ id, has_prompts, defaults }) =>
|
|
1007
|
+
(has_prompts && visiblePromptsFor(id).length > 0)
|
|
1008
|
+
|| (Array.isArray(defaults) && defaults.length > 0))
|
|
1009
|
+
: allConnectors);
|
|
1010
|
+
return (
|
|
1011
|
+
<Section
|
|
1012
|
+
title={`${stepN()}. Required tokens`}
|
|
1013
|
+
hint={minimal
|
|
1014
|
+
? "Only fields you must fill in are shown. Everything else (defaults, presets) is auto-installed."
|
|
1015
|
+
: "Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once."}
|
|
1016
|
+
>
|
|
1017
|
+
{allConnectors.length === 0 && (
|
|
689
1018
|
<p className="text-[10px] text-amber-500">
|
|
690
1019
|
No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
|
|
691
1020
|
</p>
|
|
692
1021
|
)}
|
|
693
|
-
{
|
|
694
|
-
|
|
1022
|
+
{hideConnectors && allConnectors.length > 0 && visibleConnectors.length === 0 && (
|
|
1023
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
1024
|
+
✓ All connectors are pre-configured by your template. Nothing to fill in here.
|
|
1025
|
+
</p>
|
|
1026
|
+
)}
|
|
1027
|
+
{!hideConnectors && minimal && allConnectors.length > 0 && visibleConnectors.length === 0 && (
|
|
1028
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
1029
|
+
✓ All required tokens already set. You can re-enter values to overwrite them.
|
|
1030
|
+
</p>
|
|
1031
|
+
)}
|
|
1032
|
+
{/* Bulk select/deselect — hidden in minimal mode (all pre-selected) */}
|
|
1033
|
+
{!minimal && allConnectors.length > 0 && (
|
|
695
1034
|
<div className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
|
|
696
1035
|
<button
|
|
697
1036
|
type="button"
|
|
698
|
-
onClick={() => setSelectedConnectors(new Set(
|
|
1037
|
+
onClick={() => setSelectedConnectors(new Set(allConnectors.map(c => c.id)))}
|
|
699
1038
|
className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
|
|
700
1039
|
>Select all</button>
|
|
701
1040
|
<button
|
|
@@ -703,12 +1042,12 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
703
1042
|
onClick={() => setSelectedConnectors(new Set())}
|
|
704
1043
|
className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
|
|
705
1044
|
>Deselect all</button>
|
|
706
|
-
<span className="ml-auto">{selectedConnectors.size}/{
|
|
1045
|
+
<span className="ml-auto">{selectedConnectors.size}/{allConnectors.length} selected</span>
|
|
707
1046
|
</div>
|
|
708
1047
|
)}
|
|
709
|
-
{
|
|
1048
|
+
{visibleConnectors.map(({ id: connector, has_prompts, already_installed, defaults }) => {
|
|
710
1049
|
const checked = selectedConnectors.has(connector);
|
|
711
|
-
const promptKeys = (
|
|
1050
|
+
const promptKeys = visiblePromptsFor(connector);
|
|
712
1051
|
const toggle = () => {
|
|
713
1052
|
setSelectedConnectors(prev => {
|
|
714
1053
|
const next = new Set(prev);
|
|
@@ -759,13 +1098,41 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
759
1098
|
</div>
|
|
760
1099
|
);
|
|
761
1100
|
})}
|
|
1101
|
+
{/* Template-baked defaults — what lands on Apply for the
|
|
1102
|
+
fields the user doesn't fill in. Distinct from the
|
|
1103
|
+
italic gray hint text by being monospace + chip-style.
|
|
1104
|
+
Auto-collapsed; click to expand. */}
|
|
1105
|
+
{checked && defaults && defaults.length > 0 && (
|
|
1106
|
+
<details className="ml-5">
|
|
1107
|
+
<summary className="text-[9px] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]">
|
|
1108
|
+
Template defaults ({defaults.length})
|
|
1109
|
+
</summary>
|
|
1110
|
+
<div className="mt-1 space-y-0.5">
|
|
1111
|
+
{defaults.map(d => (
|
|
1112
|
+
<div key={d.field} className="flex items-baseline gap-1.5 text-[10px] font-mono leading-relaxed">
|
|
1113
|
+
<span className="text-[var(--text-secondary)] shrink-0">{d.field}</span>
|
|
1114
|
+
<span className="text-[var(--text-secondary)]">=</span>
|
|
1115
|
+
<span className={
|
|
1116
|
+
d.is_secret
|
|
1117
|
+
? 'px-1 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 break-all'
|
|
1118
|
+
: 'px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border)] break-all'
|
|
1119
|
+
}>
|
|
1120
|
+
{d.value}
|
|
1121
|
+
</span>
|
|
1122
|
+
</div>
|
|
1123
|
+
))}
|
|
1124
|
+
</div>
|
|
1125
|
+
</details>
|
|
1126
|
+
)}
|
|
762
1127
|
</div>
|
|
763
1128
|
);
|
|
764
1129
|
})}
|
|
765
1130
|
</Section>
|
|
1131
|
+
);
|
|
1132
|
+
})()}
|
|
766
1133
|
|
|
767
|
-
{/* ──
|
|
768
|
-
<Section title=
|
|
1134
|
+
{/* ── Pipelines ───────────────────────────────────────── */}
|
|
1135
|
+
<Section title={`${stepN()}. Pipelines`} hint="Installs from the workflow marketplace. fortinet-* default-selected.">
|
|
769
1136
|
<div className="flex items-center gap-2 mb-1">
|
|
770
1137
|
<button
|
|
771
1138
|
type="button"
|
|
@@ -798,12 +1165,16 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
798
1165
|
))}
|
|
799
1166
|
</Section>
|
|
800
1167
|
|
|
801
|
-
{/* ──
|
|
802
|
-
<Section title=
|
|
803
|
-
{state.current.projectRoots.length > 0
|
|
1168
|
+
{/* ── Projects ────────────────────────────────────────── */}
|
|
1169
|
+
<Section title={`${stepN()}. Project roots (optional)`} hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line).">
|
|
1170
|
+
{state.current.projectRoots.length > 0 ? (
|
|
804
1171
|
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
805
1172
|
Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
|
|
806
1173
|
</p>
|
|
1174
|
+
) : (
|
|
1175
|
+
<p className="text-[10px] text-amber-400/90">
|
|
1176
|
+
No project roots yet. Pipelines first try to auto-clone the GitLab connector's <span className="font-mono">default_project_path</span>; if that's not set or clone fails, they fall back to <span className="font-mono"><dataDir>/scratch</span> so they still run (worktrees land inside scratch).
|
|
1177
|
+
</p>
|
|
807
1178
|
)}
|
|
808
1179
|
<div className="flex items-center gap-2">
|
|
809
1180
|
<button
|
|
@@ -868,12 +1239,28 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
|
|
|
868
1239
|
)}
|
|
869
1240
|
</Section>
|
|
870
1241
|
|
|
871
|
-
{/* ──
|
|
872
|
-
<Section title=
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1242
|
+
{/* ── Memory (info only) ──────────────────────────────── */}
|
|
1243
|
+
<Section title={`${stepN()}. Memory`}>
|
|
1244
|
+
{state.memory_auto_provision ? (
|
|
1245
|
+
<div className="space-y-1">
|
|
1246
|
+
<div className="text-[11px] px-2 py-1.5 rounded border border-emerald-500/40 bg-emerald-500/10 text-emerald-300">
|
|
1247
|
+
<strong>Auto:</strong>{' '}
|
|
1248
|
+
{state.memory_auto_provision.provisioned
|
|
1249
|
+
? <>Temper account already provisioned — chat memory backed by <span className="font-mono">{state.memory_auto_provision.url}</span>.</>
|
|
1250
|
+
: <>On <strong>Apply</strong>, Forge will provision a Temper account at <span className="font-mono">{state.memory_auto_provision.url}</span> using your identity above and wire chat memory to it.</>
|
|
1251
|
+
}
|
|
1252
|
+
</div>
|
|
1253
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
1254
|
+
Prefer local-only memory? Switch to <strong>Local</strong> in Settings → Memory after onboarding —
|
|
1255
|
+
Forge falls back to a local SQLite store and your Temper credentials remain inert.
|
|
1256
|
+
</p>
|
|
1257
|
+
</div>
|
|
1258
|
+
) : (
|
|
1259
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
1260
|
+
Defaults to local SQLite — no setup needed. Want team-shared memory?
|
|
1261
|
+
Configure Temper later in Settings → Memory.
|
|
1262
|
+
</p>
|
|
1263
|
+
)}
|
|
877
1264
|
</Section>
|
|
878
1265
|
</div>
|
|
879
1266
|
|