@aion0/forge 0.10.41 → 0.10.43
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/RELEASE_NOTES.md +4 -82
- package/app/api/onboarding/route.ts +71 -1
- package/app/chat/page.tsx +8 -5
- package/bin/forge-server.mjs +5 -0
- package/components/OnboardingWizard.tsx +208 -91
- package/dev-test.sh +1 -0
- package/lib/chat/llm/anthropic.ts +6 -1
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,89 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.43
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-07
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
6
|
-
|
|
7
|
-
### Features
|
|
8
|
-
- fix: agent path resolution + wizard writes self-contained settings
|
|
9
|
-
- feat: pipelines auto-install on Reinstall + monitor self-detect
|
|
10
|
-
|
|
11
|
-
### Bug Fixes
|
|
12
|
-
- fix: agent path resolution + wizard writes self-contained settings
|
|
5
|
+
## Changes since v0.10.42
|
|
13
6
|
|
|
14
7
|
### Other
|
|
15
|
-
-
|
|
16
|
-
- feat(marketplace): single unified Sync all — covers every type + every source
|
|
17
|
-
- ui: restore Re-sync registry button in Enterprise section
|
|
18
|
-
- ui: consolidate sync into Marketplace Connectors → Refresh
|
|
19
|
-
- ui(settings): per-source ↻ Sync button on each enterprise row
|
|
20
|
-
- ui(settings): re-run wizard moves next to Re-sync, shorter copy
|
|
21
|
-
- fix(settings): remove "Reinstall all" button — too many footguns
|
|
22
|
-
- fix(login-status,reinstall): badge polls, Reinstall force-overwrites instance fields
|
|
23
|
-
- fix(login-status): panel re-probes on mount, drops stale-cache UX
|
|
24
|
-
- fix(login-status): Test button writes result to login-status cache
|
|
25
|
-
- feat(wizard,pipeline): temper auto-provision + scratch-first project resolution + login-status cache invalidate
|
|
26
|
-
- fix(wizard): sequential section numbers + always show Pipelines
|
|
27
|
-
- feat(wizard): show template-baked defaults under each connector
|
|
28
|
-
- fix(wizard): profile-as-defaults — open with user's company+dept
|
|
29
|
-
- revert(wizard): drop settings.company override on source default
|
|
30
|
-
- fix(wizard): tenant selector defaults to settings.company
|
|
31
|
-
- fix(wizard): revert force-apply complexity, dept default reads settings.dept
|
|
32
|
-
- fix(wizard): re-run with different dept actually rotates defaults
|
|
33
|
-
- ui(profile): dept edit just updates label, no template re-apply
|
|
34
|
-
- feat(profile): company + dept as user-profile fields
|
|
35
|
-
- ui(enterprise): dept chip is display-only, picker lives in wizard
|
|
36
|
-
- feat(enterprise): dept chip becomes a one-click selector
|
|
37
|
-
- feat(wizard): support template-less department entries
|
|
38
|
-
- ui(enterprise): show single active dept, not the full list
|
|
39
|
-
- feat(enterprise): highlight currently-active dept chip
|
|
40
|
-
- feat(enterprise): surface dept list under each tenant
|
|
41
|
-
- ui(settings): merge Onboarding into Enterprise section at top
|
|
42
|
-
- fix(wizard): default dept picker to last-applied dept
|
|
43
|
-
- fix(wizard): restore auto-open + add cold-boot tenant splash
|
|
44
|
-
- fix(wizard): hasCache check accepts multi-dept index too
|
|
45
|
-
- fix(wizard): priority chain reads multi-dept cache, not just legacy file
|
|
46
|
-
- feat(wizard): add-key → wizard handoff + cold-boot auto-open gate (E4)
|
|
47
|
-
- feat(wizard): multi-department templates per source (E3)
|
|
48
|
-
- feat(wizard): tenant-scoped wizard via ?source_id (E2)
|
|
49
|
-
- feat(enterprise): editable keys (E1 of wizard redesign)
|
|
50
|
-
- feat(chat): surface connector defaults + stem-match arg fallback
|
|
51
|
-
- fix(ui): anchor enterprise popover to left edge
|
|
52
|
-
- fix(ui): drop displayName suffix from top-left title
|
|
53
|
-
- feat(ui): enterprise badge popover next to Forge title + settings reorder
|
|
54
|
-
- ui(settings): split 'Add tenant' from sync/reinstall actions
|
|
55
|
-
- feat(marketplace): Reinstall also applies template defaults to connector configs
|
|
56
|
-
- feat(dispatcher): auto-fill missing tool args from settings.default_<name>
|
|
57
|
-
- fix(http): expand tokens in body_form_inject_from values + keys
|
|
58
|
-
- fix(onboarding): fall back to displayName when email is empty for {user.login}
|
|
59
|
-
- feat(api): /api/bridge-info — expose browser-bridge port for the extension
|
|
60
|
-
- feat(auth): first-time admin password setup — no current-password required
|
|
61
|
-
- cli: forge --reset-password forwards trailing args (so --dir picks target instance)
|
|
62
|
-
- fix(onboarding): synchronously sync enterprise template on first launch
|
|
63
|
-
- fix(db): silence expected 'no such table' on fresh DB init
|
|
64
|
-
- onboarding: orphan instance rows drop unconditionally — template is canonical
|
|
65
|
-
- fix(connectors): cross-ref regex must also accept single-token refs like {base_url}
|
|
66
|
-
- fix(connectors): jenkins template — preserve cross-refs, refill empty, drop orphans
|
|
67
|
-
- feat(chat): preflight required connector settings with wizard prompt URL
|
|
68
|
-
- template: drop hardcoded default-jenkins from bundled fallback
|
|
69
|
-
- fix(onboarding): bake {user.X} tokens into persisted connector config
|
|
70
|
-
- marketplace: surface enterprise availability even when versions match
|
|
71
|
-
- fix(marketplace): surface update_source_id so Update button shows where it pulls from
|
|
72
|
-
- feat(marketplace): Reinstall-all button — force-flip installed connectors to enterprise
|
|
73
|
-
- feat(connectors): {user.*} global identity tokens + instances merge by name
|
|
74
|
-
- fix(wizard): minimal mode no longer hides required-prompt connector cards
|
|
75
|
-
- feat(marketplace): per-source sync status + PAT-scope diagnostic
|
|
76
|
-
- feat(onboarding): _wizard.minimal flag — hide non-required steps
|
|
77
|
-
- fix(enterprise): dedupe sources, scope monitor, tighten browser probe, default chat agent
|
|
78
|
-
- feat(wizard): data-driven UI — collapse hardcoded sections when template fills them
|
|
79
|
-
- feat(templates): {connector:id.field} cross-ref + _derive in wizard apply
|
|
80
|
-
- refactor(enterprise): unify ftnt → fortinet + dev-test passthrough + secret-scan guard
|
|
81
|
-
- feat(enterprise): obfuscated secrets + _agents preset in wizard template
|
|
82
|
-
- docs(help): enterprise marketplace + multi-source connector flow
|
|
83
|
-
- feat(wizard): per-enterprise onboarding template via resolver chain
|
|
84
|
-
- feat(enterprise): Dashboard badge + add_enterprise_key chat tool
|
|
85
|
-
- feat(ui): enterprise sources in marketplace + Settings panel
|
|
86
|
-
- feat(marketplace): multi-source connector + workflow registries (enterprise keys)
|
|
8
|
+
- wizard: required fields + email pattern + skip means skip + dedup
|
|
87
9
|
|
|
88
10
|
|
|
89
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.42...v0.10.43
|
|
@@ -449,7 +449,15 @@ function preprocessTemplate(
|
|
|
449
449
|
if (changed) saveSettings({ ...fresh, agents: current });
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
-
// _apiProfiles — same shape rules
|
|
452
|
+
// _apiProfiles — same shape rules + dedup-by-backend.
|
|
453
|
+
//
|
|
454
|
+
// Dedup: if the user already has an apiProfile pointing at the same
|
|
455
|
+
// (baseUrl, model) tuple, skip the template entry even when the IDs
|
|
456
|
+
// differ. This handles wizard-template rename scenarios — e.g. the
|
|
457
|
+
// template changed `forti-k2-chat` (provider: anthropic) into
|
|
458
|
+
// `fortinac-qwen-chat` (provider: litellm). Without this check we'd
|
|
459
|
+
// add the renamed entry alongside the user's existing one, leaving
|
|
460
|
+
// two profiles for the same backend.
|
|
453
461
|
const profilesApplied: string[] = [];
|
|
454
462
|
const profilesDict = template._apiProfiles as Record<string, any> | undefined;
|
|
455
463
|
if (profilesDict && typeof profilesDict === 'object') {
|
|
@@ -457,12 +465,27 @@ function preprocessTemplate(
|
|
|
457
465
|
const current = { ...(fresh.apiProfiles || {}) };
|
|
458
466
|
let changed = false;
|
|
459
467
|
let defaultPick: string | undefined;
|
|
468
|
+
// Build (baseUrl|model) → existing id index for dedup lookup.
|
|
469
|
+
const backendKey = (p: any): string =>
|
|
470
|
+
`${String(p?.baseUrl || '').toLowerCase().replace(/\/+$/, '')}|${String(p?.model || '').toLowerCase()}`;
|
|
471
|
+
const existingByBackend = new Map<string, string>();
|
|
472
|
+
for (const [id, p] of Object.entries(current)) {
|
|
473
|
+
const k = backendKey(p);
|
|
474
|
+
if (k !== '|') existingByBackend.set(k, id);
|
|
475
|
+
}
|
|
460
476
|
for (const [profileId, raw] of Object.entries(profilesDict)) {
|
|
461
477
|
if (!raw || typeof raw !== 'object') continue;
|
|
462
478
|
const overwrite = (raw as any)._overwrite === true;
|
|
463
479
|
if (current[profileId] && !overwrite) continue;
|
|
480
|
+
// Skip if a different-id profile already targets the same (baseUrl, model).
|
|
481
|
+
const tplKey = backendKey(raw);
|
|
482
|
+
if (!overwrite && tplKey !== '|' && existingByBackend.has(tplKey)
|
|
483
|
+
&& existingByBackend.get(tplKey) !== profileId) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
464
486
|
const { _overwrite, _default, ...entry } = raw as Record<string, unknown>;
|
|
465
487
|
current[profileId] = entry as unknown as ApiProfile;
|
|
488
|
+
existingByBackend.set(tplKey, profileId);
|
|
466
489
|
profilesApplied.push(profileId);
|
|
467
490
|
changed = true;
|
|
468
491
|
// First entry flagged `_default: true` (or the first applied entry
|
|
@@ -1248,6 +1271,14 @@ export async function GET(req: Request) {
|
|
|
1248
1271
|
// {dept.name} substitution and surfaced in the UI breadcrumb.
|
|
1249
1272
|
template_department_name:
|
|
1250
1273
|
typeof template._department_name === 'string' ? template._department_name : undefined,
|
|
1274
|
+
// Per-template identity validation. Currently just an email regex —
|
|
1275
|
+
// e.g. `_identity.email_pattern: "^[^@]+@fortinet(-[a-z]+)?\\.com$"`
|
|
1276
|
+
// lets the fortinac template enforce @fortinet.com / @fortinet-us.com
|
|
1277
|
+
// suffixes. Surfaced raw so the client can show the pattern in error
|
|
1278
|
+
// messages.
|
|
1279
|
+
template_identity: (template._identity && typeof template._identity === 'object')
|
|
1280
|
+
? template._identity as Record<string, unknown>
|
|
1281
|
+
: undefined,
|
|
1251
1282
|
});
|
|
1252
1283
|
}
|
|
1253
1284
|
|
|
@@ -1265,6 +1296,19 @@ export async function POST(req: Request) {
|
|
|
1265
1296
|
return NextResponse.json({ ok: true });
|
|
1266
1297
|
}
|
|
1267
1298
|
|
|
1299
|
+
// 'skip' — user closes the wizard without applying. Just flip the
|
|
1300
|
+
// gate; do NOT run any of the apply phases (identity / template
|
|
1301
|
+
// _agents / _apiProfiles / connectors / pipelines / temper). The old
|
|
1302
|
+
// behaviour reused action=apply with payload={} which still ran the
|
|
1303
|
+
// template-side writes (applyConnectors → applyTemplateAgentsAndProfiles),
|
|
1304
|
+
// so Skip silently committed the template even though the user said no.
|
|
1305
|
+
if (action === 'skip') {
|
|
1306
|
+
const settings = loadSettings();
|
|
1307
|
+
settings.onboardingCompleted = true;
|
|
1308
|
+
saveSettings(settings);
|
|
1309
|
+
return NextResponse.json({ ok: true, skipped: true });
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1268
1312
|
if (action !== 'apply') {
|
|
1269
1313
|
return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
|
|
1270
1314
|
}
|
|
@@ -1272,6 +1316,32 @@ export async function POST(req: Request) {
|
|
|
1272
1316
|
const payload = (body?.payload || {}) as OnboardingPayload;
|
|
1273
1317
|
const phases: Array<{ phase: string; ok: boolean; error?: string; detail?: any }> = [];
|
|
1274
1318
|
|
|
1319
|
+
// Server-side enforcement of template's identity rules. The wizard
|
|
1320
|
+
// also blocks at the client, but a malformed POST (curl / older client /
|
|
1321
|
+
// dev-tools edit) can bypass that. Reject before any phase runs — no
|
|
1322
|
+
// partial writes.
|
|
1323
|
+
try {
|
|
1324
|
+
const tpl = resolveTemplate(payload.sourceId, payload.deptId);
|
|
1325
|
+
const idCfg = (tpl && typeof tpl._identity === 'object') ? tpl._identity as any : null;
|
|
1326
|
+
const patternStr = idCfg?.email_pattern as string | undefined;
|
|
1327
|
+
const email = payload.identity?.displayEmail?.trim();
|
|
1328
|
+
if (patternStr && email) {
|
|
1329
|
+
let pat: RegExp | null = null;
|
|
1330
|
+
try { pat = new RegExp(patternStr); } catch { pat = null; }
|
|
1331
|
+
if (pat && !pat.test(email)) {
|
|
1332
|
+
const hint = (idCfg.email_pattern_hint as string | undefined)
|
|
1333
|
+
|| `Must match: ${patternStr}`;
|
|
1334
|
+
return NextResponse.json({
|
|
1335
|
+
ok: false,
|
|
1336
|
+
error: `email "${email}" does not match the template's required pattern. ${hint}`,
|
|
1337
|
+
}, { status: 400 });
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
} catch {
|
|
1341
|
+
// Template resolution failure → fall through; client validation
|
|
1342
|
+
// already covers the happy path.
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1275
1345
|
try {
|
|
1276
1346
|
const e1 = applyIdentity(payload.identity);
|
|
1277
1347
|
phases.push({ phase: 'identity', ok: !e1, ...(e1 ? { error: e1 } : {}) });
|
package/app/chat/page.tsx
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
'use client';
|
|
21
21
|
|
|
22
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
22
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
23
|
import MarkdownContent from '@/components/MarkdownContent';
|
|
24
24
|
import WatchesPanel from '@/components/WatchesPanel';
|
|
25
25
|
import type { ContentBlock, Message, Session } from '@/lib/chat/types';
|
|
@@ -497,7 +497,10 @@ function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: nu
|
|
|
497
497
|
);
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
-
|
|
500
|
+
// Memoized — keystroke in the composer re-renders the page, and without
|
|
501
|
+
// memo every prior message re-runs its markdown parse → typing in a long
|
|
502
|
+
// chat became visibly laggy.
|
|
503
|
+
const MessageView = memo(function MessageView({ m }: { m: Message }) {
|
|
501
504
|
return (
|
|
502
505
|
<RoleBlock role={m.role} ts={m.ts}>
|
|
503
506
|
{m.blocks.map((b, i) => (
|
|
@@ -510,9 +513,9 @@ function MessageView({ m }: { m: Message }) {
|
|
|
510
513
|
)}
|
|
511
514
|
</RoleBlock>
|
|
512
515
|
);
|
|
513
|
-
}
|
|
516
|
+
});
|
|
514
517
|
|
|
515
|
-
function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
|
|
518
|
+
const BlockView = memo(function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
|
|
516
519
|
if (b.type === 'text') {
|
|
517
520
|
return <MarkdownContent content={b.text} linkify={role === 'assistant'} />;
|
|
518
521
|
}
|
|
@@ -524,7 +527,7 @@ function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }
|
|
|
524
527
|
return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
|
|
525
528
|
}
|
|
526
529
|
return null;
|
|
527
|
-
}
|
|
530
|
+
});
|
|
528
531
|
|
|
529
532
|
function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
|
|
530
533
|
const [open, setOpen] = useState(false);
|
package/bin/forge-server.mjs
CHANGED
|
@@ -119,6 +119,10 @@ const enterpriseKeysFromArgv = collectEnterpriseKeys();
|
|
|
119
119
|
const webPort = parseInt(getArg('--port')) || 8403;
|
|
120
120
|
const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
|
|
121
121
|
const workspacePort = parseInt(getArg('--workspace-port')) || (webPort + 2);
|
|
122
|
+
// MCP server (inside workspace-standalone) was hardcoded to 8406, so a
|
|
123
|
+
// second instance (dev-test on a different webPort) collided with the
|
|
124
|
+
// main instance. Offset from webPort like every other service.
|
|
125
|
+
const mcpPort = parseInt(getArg('--mcp-port')) || (webPort + 3);
|
|
122
126
|
const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
|
|
123
127
|
|
|
124
128
|
const PID_FILE = join(DATA_DIR, 'forge.pid');
|
|
@@ -240,6 +244,7 @@ if (existsSync(envFile)) {
|
|
|
240
244
|
process.env.PORT = String(webPort);
|
|
241
245
|
process.env.TERMINAL_PORT = String(terminalPort);
|
|
242
246
|
process.env.WORKSPACE_PORT = String(workspacePort);
|
|
247
|
+
process.env.MCP_PORT = String(mcpPort);
|
|
243
248
|
process.env.FORGE_DATA_DIR = DATA_DIR;
|
|
244
249
|
|
|
245
250
|
// ── Password setup (first run or --reset-password) ──
|
|
@@ -111,6 +111,13 @@ interface OnboardingState {
|
|
|
111
111
|
/** Template's self-declared `_department_name`. Surfaced for the
|
|
112
112
|
* breadcrumb so the user sees what {dept.name} resolves to. */
|
|
113
113
|
template_department_name?: string;
|
|
114
|
+
/** Per-template identity validation. Currently supports
|
|
115
|
+
* `email_pattern` (JS-compatible regex) — e.g. fortinet enforces
|
|
116
|
+
* ^[^@]+@fortinet(-[a-z]+)?\.com$. Optional. */
|
|
117
|
+
template_identity?: {
|
|
118
|
+
email_pattern?: string;
|
|
119
|
+
email_pattern_hint?: string;
|
|
120
|
+
};
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
interface ApplyResultPhase {
|
|
@@ -509,6 +516,51 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
509
516
|
|
|
510
517
|
async function apply() {
|
|
511
518
|
if (applying) return;
|
|
519
|
+
// Identity is required — display name + email are referenced via
|
|
520
|
+
// {user.name} / {user.email} all over the template (jenkins username,
|
|
521
|
+
// gitlab commit author, etc.). Empty values produce silently-broken
|
|
522
|
+
// configs that surface much later.
|
|
523
|
+
const missing: string[] = [];
|
|
524
|
+
if (!displayName.trim()) missing.push('Display name');
|
|
525
|
+
if (!displayEmail.trim()) missing.push('Email');
|
|
526
|
+
// Template-side email pattern (e.g. fortinet template restricts to
|
|
527
|
+
// @fortinet.com / @fortinet-us.com / @fortinet-*.com). Skip when no
|
|
528
|
+
// pattern is declared. Compile-on-demand — pattern is a string from
|
|
529
|
+
// JSON so an invalid regex falls back to "accept all" rather than
|
|
530
|
+
// crashing the user's Apply flow.
|
|
531
|
+
const emailPatternStr = state?.template_identity?.email_pattern;
|
|
532
|
+
if (displayEmail.trim() && emailPatternStr) {
|
|
533
|
+
let pat: RegExp | null = null;
|
|
534
|
+
try { pat = new RegExp(emailPatternStr); } catch { pat = null; }
|
|
535
|
+
if (pat && !pat.test(displayEmail.trim())) {
|
|
536
|
+
const hint = state?.template_identity?.email_pattern_hint;
|
|
537
|
+
alert(`Email "${displayEmail.trim()}" doesn't match the required pattern.\n\n${hint || `Must match: ${emailPatternStr}`}`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Required connector prompts (e.g. gitlab_token_name, gitlab_pat).
|
|
542
|
+
// Only check when their owning connector is selected AND the value
|
|
543
|
+
// isn't already saved from a prior wizard run (prompt_values_set).
|
|
544
|
+
if (state) {
|
|
545
|
+
const prompts = state.template_prompts || {};
|
|
546
|
+
const valuesSet = state.prompt_values_set || {};
|
|
547
|
+
const targets = state.prompt_targets || {};
|
|
548
|
+
for (const [key, def] of Object.entries(prompts)) {
|
|
549
|
+
if (!def.required) continue;
|
|
550
|
+
if (valuesSet[key]) continue; // already saved
|
|
551
|
+
const owners = targets[key] || [];
|
|
552
|
+
const owningConnectorSelected = owners.length === 0
|
|
553
|
+
|| owners.some(t => selectedConnectors.has(t.connector));
|
|
554
|
+
if (!owningConnectorSelected) continue; // unrelated to selected connectors
|
|
555
|
+
if (!(connectorValues[key] || '').trim()) {
|
|
556
|
+
missing.push(def.label || key);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (missing.length > 0) {
|
|
561
|
+
alert(`Required field${missing.length > 1 ? 's' : ''} missing:\n\n• ${missing.join('\n• ')}`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
512
564
|
setApplying(true); setResult(null);
|
|
513
565
|
try {
|
|
514
566
|
// Strip values whose target connector got unchecked — keeping them
|
|
@@ -826,11 +878,36 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
826
878
|
|
|
827
879
|
{/* ── Identity ─────────────────────────────────────────── */}
|
|
828
880
|
<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.">
|
|
829
|
-
<Field label=
|
|
830
|
-
<input
|
|
881
|
+
<Field label={<><span>Display name</span> <span className="text-red-400 font-bold ml-0.5">*</span></>}>
|
|
882
|
+
<input
|
|
883
|
+
className={inputCls + (displayName.trim() ? '' : ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5')}
|
|
884
|
+
value={displayName}
|
|
885
|
+
onChange={e => setDisplayName(e.target.value)}
|
|
886
|
+
placeholder="Zhen Liu (required)"
|
|
887
|
+
required
|
|
888
|
+
/>
|
|
831
889
|
</Field>
|
|
832
|
-
<Field label=
|
|
833
|
-
<input
|
|
890
|
+
<Field label={<><span>Email</span> <span className="text-red-400 font-bold ml-0.5">*</span></>}>
|
|
891
|
+
<input
|
|
892
|
+
className={inputCls + (() => {
|
|
893
|
+
if (!displayEmail.trim()) return ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5';
|
|
894
|
+
const pat = state.template_identity?.email_pattern;
|
|
895
|
+
if (pat) {
|
|
896
|
+
try { if (!new RegExp(pat).test(displayEmail.trim())) return ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5'; } catch {}
|
|
897
|
+
}
|
|
898
|
+
return '';
|
|
899
|
+
})()}
|
|
900
|
+
value={displayEmail}
|
|
901
|
+
onChange={e => setDisplayEmail(e.target.value)}
|
|
902
|
+
placeholder="zliu@fortinet.com (required)"
|
|
903
|
+
required
|
|
904
|
+
/>
|
|
905
|
+
{state.template_identity?.email_pattern && (
|
|
906
|
+
<p className="text-[9px] text-[var(--text-secondary)] italic">
|
|
907
|
+
{state.template_identity.email_pattern_hint
|
|
908
|
+
|| `Must match: ${state.template_identity.email_pattern}`}
|
|
909
|
+
</p>
|
|
910
|
+
)}
|
|
834
911
|
</Field>
|
|
835
912
|
{state.template_derive_keys?.length ? (
|
|
836
913
|
<p className="text-[9px] text-[var(--text-secondary)] italic">
|
|
@@ -839,13 +916,91 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
839
916
|
) : null}
|
|
840
917
|
</Section>
|
|
841
918
|
|
|
842
|
-
{/* ──
|
|
919
|
+
{/* ── Project roots — moved up so users address it before
|
|
920
|
+
the template-installed sections (which most just glance at). */}
|
|
921
|
+
<Section title={`${stepN()}. Project roots`} hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line). Optional — pipelines fall back to <dataDir>/scratch when none configured.">
|
|
922
|
+
{state.current.projectRoots.length > 0 ? (
|
|
923
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
924
|
+
Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
|
|
925
|
+
</p>
|
|
926
|
+
) : (
|
|
927
|
+
<p className="text-[10px] text-amber-400/90">
|
|
928
|
+
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).
|
|
929
|
+
</p>
|
|
930
|
+
)}
|
|
931
|
+
<div className="flex items-center gap-2">
|
|
932
|
+
<button
|
|
933
|
+
type="button"
|
|
934
|
+
onClick={() => openPicker()}
|
|
935
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
936
|
+
>
|
|
937
|
+
📁 Pick folder…
|
|
938
|
+
</button>
|
|
939
|
+
<span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
|
|
940
|
+
</div>
|
|
941
|
+
<textarea
|
|
942
|
+
rows={2}
|
|
943
|
+
className={inputCls + ' font-mono'}
|
|
944
|
+
value={projectInput}
|
|
945
|
+
onChange={e => setProjectInput(e.target.value)}
|
|
946
|
+
placeholder="/Users/you/IdeaProjects"
|
|
947
|
+
/>
|
|
948
|
+
|
|
949
|
+
{pickerOpen && (
|
|
950
|
+
<div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
|
|
951
|
+
<div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
|
|
952
|
+
{pickerParent && (
|
|
953
|
+
<button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
|
|
954
|
+
)}
|
|
955
|
+
<span className="truncate">{pickerPath || '(loading)'}</span>
|
|
956
|
+
<button
|
|
957
|
+
onClick={() => setPickerOpen(false)}
|
|
958
|
+
className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
959
|
+
>✕</button>
|
|
960
|
+
</div>
|
|
961
|
+
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
|
962
|
+
{pickerEntries.length === 0 && (
|
|
963
|
+
<p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
|
|
964
|
+
)}
|
|
965
|
+
{pickerEntries.map(e => (
|
|
966
|
+
<div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
|
|
967
|
+
<button
|
|
968
|
+
onClick={() => openPicker(e.path)}
|
|
969
|
+
className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
|
|
970
|
+
title="Drill into this folder"
|
|
971
|
+
>
|
|
972
|
+
📁 {e.name}
|
|
973
|
+
</button>
|
|
974
|
+
<button
|
|
975
|
+
onClick={() => addPickedPath(e.path)}
|
|
976
|
+
className="text-[9px] text-[var(--accent)] hover:underline px-1"
|
|
977
|
+
title="Add this folder to project roots"
|
|
978
|
+
>+ pick</button>
|
|
979
|
+
</div>
|
|
980
|
+
))}
|
|
981
|
+
</div>
|
|
982
|
+
{pickerPath && (
|
|
983
|
+
<button
|
|
984
|
+
onClick={() => addPickedPath(pickerPath)}
|
|
985
|
+
className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
|
|
986
|
+
>
|
|
987
|
+
Use current folder: <span className="font-mono">{pickerPath}</span>
|
|
988
|
+
</button>
|
|
989
|
+
)}
|
|
990
|
+
</div>
|
|
991
|
+
)}
|
|
992
|
+
</Section>
|
|
993
|
+
|
|
994
|
+
{/* ── Template pre-baked items ─────────────────────────────
|
|
995
|
+
Collapsed by default — once the template's installed there's
|
|
996
|
+
nothing to do here, click to expand to inspect. */}
|
|
843
997
|
{(state.template_agents_preview?.length || state.template_api_profiles_preview?.length) && (
|
|
844
998
|
<Section
|
|
845
999
|
title={`${stepN()}. ${state.template_enterprise_name
|
|
846
1000
|
? `🔒 ${state.template_enterprise_name} — auto-installed`
|
|
847
1001
|
: '🔒 Template — auto-installed'}`}
|
|
848
1002
|
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."
|
|
1003
|
+
defaultCollapsed
|
|
849
1004
|
>
|
|
850
1005
|
{state.template_agents_preview?.length ? (
|
|
851
1006
|
<div>
|
|
@@ -881,9 +1036,14 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
881
1036
|
</Section>
|
|
882
1037
|
)}
|
|
883
1038
|
|
|
884
|
-
{/* ── 2. API Profile (hidden when template provides one) ─
|
|
1039
|
+
{/* ── 2. API Profile (hidden when template provides one) ─
|
|
1040
|
+
Auto-collapse when user already has a chat profile configured. */}
|
|
885
1041
|
{!state.template_api_profiles_preview?.length && (
|
|
886
|
-
<Section
|
|
1042
|
+
<Section
|
|
1043
|
+
title={`${stepN()}. Chat API key`}
|
|
1044
|
+
hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest."
|
|
1045
|
+
defaultCollapsed={!!state.current.apiProfile || apiKeyExisting}
|
|
1046
|
+
>
|
|
887
1047
|
<div className="flex gap-1 mb-1 flex-wrap">
|
|
888
1048
|
{(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
|
|
889
1049
|
<button
|
|
@@ -917,9 +1077,14 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
917
1077
|
</Section>
|
|
918
1078
|
)}
|
|
919
1079
|
|
|
920
|
-
{/* ── 2.5. CLI Agent (hidden when template provides _agents) ─
|
|
1080
|
+
{/* ── 2.5. CLI Agent (hidden when template provides _agents) ─
|
|
1081
|
+
Auto-collapse when user already has CLI agents configured. */}
|
|
921
1082
|
{!state.template_agents_preview?.length && (
|
|
922
|
-
<Section
|
|
1083
|
+
<Section
|
|
1084
|
+
title={`${stepN()}. CLI Agent`}
|
|
1085
|
+
hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below."
|
|
1086
|
+
defaultCollapsed={state.current.agents.length > 0}
|
|
1087
|
+
>
|
|
923
1088
|
{state.detected_cli.length === 0 && state.current.agents.length === 0 && (
|
|
924
1089
|
<p className="text-[10px] text-amber-500">
|
|
925
1090
|
No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
|
|
@@ -1090,7 +1255,11 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
1090
1255
|
)}
|
|
1091
1256
|
<input
|
|
1092
1257
|
type={p.secret ? 'password' : 'text'}
|
|
1093
|
-
className={inputCls + ' font-mono'
|
|
1258
|
+
className={inputCls + ' font-mono' + (
|
|
1259
|
+
p.required && !isSet && !v.trim()
|
|
1260
|
+
? ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5'
|
|
1261
|
+
: ''
|
|
1262
|
+
)}
|
|
1094
1263
|
value={v}
|
|
1095
1264
|
onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
|
|
1096
1265
|
placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
|
|
@@ -1165,79 +1334,7 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
1165
1334
|
))}
|
|
1166
1335
|
</Section>
|
|
1167
1336
|
|
|
1168
|
-
{/*
|
|
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 ? (
|
|
1171
|
-
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
1172
|
-
Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
|
|
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>
|
|
1178
|
-
)}
|
|
1179
|
-
<div className="flex items-center gap-2">
|
|
1180
|
-
<button
|
|
1181
|
-
type="button"
|
|
1182
|
-
onClick={() => openPicker()}
|
|
1183
|
-
className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
1184
|
-
>
|
|
1185
|
-
📁 Pick folder…
|
|
1186
|
-
</button>
|
|
1187
|
-
<span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
|
|
1188
|
-
</div>
|
|
1189
|
-
<textarea
|
|
1190
|
-
rows={2}
|
|
1191
|
-
className={inputCls + ' font-mono'}
|
|
1192
|
-
value={projectInput}
|
|
1193
|
-
onChange={e => setProjectInput(e.target.value)}
|
|
1194
|
-
placeholder="/Users/you/IdeaProjects"
|
|
1195
|
-
/>
|
|
1196
|
-
|
|
1197
|
-
{pickerOpen && (
|
|
1198
|
-
<div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
|
|
1199
|
-
<div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
|
|
1200
|
-
{pickerParent && (
|
|
1201
|
-
<button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
|
|
1202
|
-
)}
|
|
1203
|
-
<span className="truncate">{pickerPath || '(loading)'}</span>
|
|
1204
|
-
<button
|
|
1205
|
-
onClick={() => setPickerOpen(false)}
|
|
1206
|
-
className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
1207
|
-
>✕</button>
|
|
1208
|
-
</div>
|
|
1209
|
-
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
|
1210
|
-
{pickerEntries.length === 0 && (
|
|
1211
|
-
<p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
|
|
1212
|
-
)}
|
|
1213
|
-
{pickerEntries.map(e => (
|
|
1214
|
-
<div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
|
|
1215
|
-
<button
|
|
1216
|
-
onClick={() => openPicker(e.path)}
|
|
1217
|
-
className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
|
|
1218
|
-
title="Drill into this folder"
|
|
1219
|
-
>
|
|
1220
|
-
📁 {e.name}
|
|
1221
|
-
</button>
|
|
1222
|
-
<button
|
|
1223
|
-
onClick={() => addPickedPath(e.path)}
|
|
1224
|
-
className="text-[9px] text-[var(--accent)] hover:underline px-1"
|
|
1225
|
-
title="Add this folder to project roots"
|
|
1226
|
-
>+ pick</button>
|
|
1227
|
-
</div>
|
|
1228
|
-
))}
|
|
1229
|
-
</div>
|
|
1230
|
-
{pickerPath && (
|
|
1231
|
-
<button
|
|
1232
|
-
onClick={() => addPickedPath(pickerPath)}
|
|
1233
|
-
className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
|
|
1234
|
-
>
|
|
1235
|
-
Use current folder: <span className="font-mono">{pickerPath}</span>
|
|
1236
|
-
</button>
|
|
1237
|
-
)}
|
|
1238
|
-
</div>
|
|
1239
|
-
)}
|
|
1240
|
-
</Section>
|
|
1337
|
+
{/* Project roots — moved up to step 2 (after Identity). */}
|
|
1241
1338
|
|
|
1242
1339
|
{/* ── Memory (info only) ──────────────────────────────── */}
|
|
1243
1340
|
<Section title={`${stepN()}. Memory`}>
|
|
@@ -1284,11 +1381,14 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
1284
1381
|
<button
|
|
1285
1382
|
onClick={async () => {
|
|
1286
1383
|
if (dirty && !confirm('Skip setup? Your unsaved inputs will be discarded. (Banner will not appear again.)')) return;
|
|
1287
|
-
//
|
|
1384
|
+
// action:'skip' — server only flips onboardingCompleted, does
|
|
1385
|
+
// NOT run any apply phases. Previously this was action:'apply'
|
|
1386
|
+
// with empty payload, which still wrote template _agents /
|
|
1387
|
+
// _apiProfiles via the connectors phase.
|
|
1288
1388
|
await fetch('/api/onboarding', {
|
|
1289
1389
|
method: 'POST',
|
|
1290
1390
|
headers: { 'Content-Type': 'application/json' },
|
|
1291
|
-
body: JSON.stringify({ action: '
|
|
1391
|
+
body: JSON.stringify({ action: 'skip' }),
|
|
1292
1392
|
});
|
|
1293
1393
|
onComplete();
|
|
1294
1394
|
}}
|
|
@@ -1324,12 +1424,29 @@ function DrawerShell({ onClose, children }: { onClose: () => void; children: Rea
|
|
|
1324
1424
|
);
|
|
1325
1425
|
}
|
|
1326
1426
|
|
|
1327
|
-
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
|
1427
|
+
function Section({ title, hint, children, defaultCollapsed }: { title: string; hint?: string; children: React.ReactNode; defaultCollapsed?: boolean }) {
|
|
1428
|
+
const [open, setOpen] = useState(!defaultCollapsed);
|
|
1429
|
+
const collapsible = defaultCollapsed !== undefined;
|
|
1328
1430
|
return (
|
|
1329
1431
|
<div className="space-y-1.5 pb-3 border-b border-[var(--border)]/40">
|
|
1330
|
-
<h3 className="text-[12px] font-medium text-[var(--text-primary)]">
|
|
1331
|
-
|
|
1332
|
-
|
|
1432
|
+
<h3 className="text-[12px] font-medium text-[var(--text-primary)] flex items-center gap-1">
|
|
1433
|
+
{collapsible && (
|
|
1434
|
+
<button
|
|
1435
|
+
type="button"
|
|
1436
|
+
onClick={() => setOpen(o => !o)}
|
|
1437
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-3 inline-flex items-center justify-center"
|
|
1438
|
+
title={open ? 'Collapse' : 'Expand'}
|
|
1439
|
+
>
|
|
1440
|
+
{open ? '▾' : '▸'}
|
|
1441
|
+
</button>
|
|
1442
|
+
)}
|
|
1443
|
+
<span
|
|
1444
|
+
onClick={collapsible ? () => setOpen(o => !o) : undefined}
|
|
1445
|
+
className={collapsible ? 'cursor-pointer flex-1' : 'flex-1'}
|
|
1446
|
+
>{title}</span>
|
|
1447
|
+
</h3>
|
|
1448
|
+
{open && hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{hint}</p>}
|
|
1449
|
+
{open && children}
|
|
1333
1450
|
</div>
|
|
1334
1451
|
);
|
|
1335
1452
|
}
|
|
@@ -1379,10 +1496,10 @@ function CheckRow({
|
|
|
1379
1496
|
);
|
|
1380
1497
|
}
|
|
1381
1498
|
|
|
1382
|
-
function Field({ label, children }: { label:
|
|
1499
|
+
function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
|
|
1383
1500
|
return (
|
|
1384
1501
|
<div className="space-y-0.5">
|
|
1385
|
-
<label className="text-[10px] text-[var(--text-secondary)]">{label}</label>
|
|
1502
|
+
<label className="text-[10px] text-[var(--text-secondary)] inline-flex items-center">{label}</label>
|
|
1386
1503
|
{children}
|
|
1387
1504
|
</div>
|
|
1388
1505
|
);
|
package/dev-test.sh
CHANGED
|
@@ -167,7 +167,12 @@ export const anthropicAdapter: LlmAdapter = {
|
|
|
167
167
|
const content: ContentBlock[] = [];
|
|
168
168
|
let textBuf = '';
|
|
169
169
|
for await (const part of result.fullStream) {
|
|
170
|
-
if (part.type === 'text-delta') {
|
|
170
|
+
if (part.type === 'text-delta' || part.type === 'reasoning-delta') {
|
|
171
|
+
// Treat reasoning-delta the same as text-delta — Fortinet
|
|
172
|
+
// relay endpoints (forti-k2, forti-coder, etc.) return their
|
|
173
|
+
// entire response as reasoning blocks instead of regular text.
|
|
174
|
+
// Without this branch the model output silently disappears
|
|
175
|
+
// (assistant message saved with blocks=[]).
|
|
171
176
|
textBuf += part.text;
|
|
172
177
|
cb.onTextDelta(part.text);
|
|
173
178
|
} else if (part.type === 'tool-call') {
|
package/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED