@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
|
@@ -21,18 +21,21 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { NextResponse } from 'next/server';
|
|
24
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
-
import { join } from 'node:path';
|
|
26
24
|
import { loadSettings, saveSettings } from '@/lib/settings';
|
|
27
|
-
import { getDataDir } from '@/lib/dirs';
|
|
28
25
|
import {
|
|
29
26
|
getConnector,
|
|
30
27
|
getInstalledConnector,
|
|
28
|
+
listInstalledConnectors,
|
|
31
29
|
setConnectorConfig,
|
|
32
30
|
setConnectorEnabled,
|
|
31
|
+
setConnectorDept,
|
|
33
32
|
} from '@/lib/connectors/registry';
|
|
34
33
|
import { installFromMarketplace, listMarketplace } from '@/lib/workflow-marketplace';
|
|
35
34
|
import { installFromRegistry } from '@/lib/connectors/sync';
|
|
35
|
+
import { resolveWizardTemplate } from '@/lib/connectors/wizard-template';
|
|
36
|
+
import { decryptDeep } from '@/lib/enterprise-secret';
|
|
37
|
+
import { provisionTemperUser } from '@/lib/memory/temper-provision';
|
|
38
|
+
import type { AgentEntry, ApiProfile } from '@/lib/settings';
|
|
36
39
|
import bundledTemplate from '@/templates/connector-config-template.json';
|
|
37
40
|
|
|
38
41
|
interface OnboardingPayload {
|
|
@@ -65,24 +68,167 @@ interface OnboardingPayload {
|
|
|
65
68
|
selectedConnectors?: string[];
|
|
66
69
|
pipelines?: string[]; // marketplace names to install
|
|
67
70
|
projectRoots?: string[]; // paths to append
|
|
71
|
+
/** E2: scope apply to one tenant's template (e.g. enterprise-fortinet).
|
|
72
|
+
* Missing = use the default priority chain (user → enterprise → public),
|
|
73
|
+
* matching pre-E2 behavior. The same template that GET rendered should
|
|
74
|
+
* be the one POST applies — otherwise the prompts the user filled
|
|
75
|
+
* point at fields that aren't in the applied template. */
|
|
76
|
+
sourceId?: string;
|
|
77
|
+
/** E3: scope further into a specific department template within the
|
|
78
|
+
* source. Missing = source's first dept as default. */
|
|
79
|
+
deptId?: string;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
// ─── Helpers (mirrors import-config-template/route.ts) ───────
|
|
71
83
|
|
|
72
84
|
const PLACEHOLDER_RE = /\$\{([a-zA-Z0-9_]+)\}/g;
|
|
73
|
-
|
|
85
|
+
// `{user.X}` resolves from settings.displayName / displayEmail at apply
|
|
86
|
+
// time. Done eagerly here (not at runtime via lib/plugins/templates) so
|
|
87
|
+
// the persisted config holds the literal value — otherwise users see
|
|
88
|
+
// `username: "{user.login}"` in their connector settings UI, which is
|
|
89
|
+
// confusing even though it would expand correctly at request time.
|
|
90
|
+
const USER_TOKEN_RE = /\{user\.([a-zA-Z0-9_]+)\}/g;
|
|
91
|
+
// E3: `{dept.name}` lets a template reference its own _department_name
|
|
92
|
+
// without repeating "FortiNAC" in 6 different connector defaults. Other
|
|
93
|
+
// dept fields can be added here as the design grows (display_name, etc.).
|
|
94
|
+
const DEPT_TOKEN_RE = /\{dept\.([a-zA-Z0-9_]+)\}/g;
|
|
95
|
+
function expandUserTokens(s: string, user: { name: string; email: string; login: string }): string {
|
|
96
|
+
return s.replace(USER_TOKEN_RE, (_, field) => {
|
|
97
|
+
switch (field) {
|
|
98
|
+
case 'name': return user.name;
|
|
99
|
+
case 'email': return user.email;
|
|
100
|
+
case 'login': return user.login;
|
|
101
|
+
default: return `{user.${field}}`;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function expandDeptTokens(s: string, dept: { name: string } | undefined): string {
|
|
106
|
+
if (!dept) return s;
|
|
107
|
+
return s.replace(DEPT_TOKEN_RE, (_, field) => {
|
|
108
|
+
switch (field) {
|
|
109
|
+
case 'name': return dept.name;
|
|
110
|
+
default: return `{dept.${field}}`;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function substituteAll(
|
|
115
|
+
value: unknown,
|
|
116
|
+
values: Record<string, string>,
|
|
117
|
+
user?: { name: string; email: string; login: string },
|
|
118
|
+
dept?: { name: string },
|
|
119
|
+
): unknown {
|
|
74
120
|
if (typeof value === 'string') {
|
|
75
|
-
|
|
121
|
+
let out = value.replace(PLACEHOLDER_RE, (_, key) => values[key] ?? '');
|
|
122
|
+
if (user) out = expandUserTokens(out, user);
|
|
123
|
+
if (dept) out = expandDeptTokens(out, dept);
|
|
124
|
+
return out;
|
|
76
125
|
}
|
|
77
|
-
if (Array.isArray(value)) return value.map((v) => substituteAll(v, values));
|
|
126
|
+
if (Array.isArray(value)) return value.map((v) => substituteAll(v, values, user, dept));
|
|
78
127
|
if (value && typeof value === 'object') {
|
|
79
128
|
const out: Record<string, unknown> = {};
|
|
80
|
-
for (const [k, v] of Object.entries(value)) out[k] = substituteAll(v, values);
|
|
129
|
+
for (const [k, v] of Object.entries(value)) out[k] = substituteAll(v, values, user, dept);
|
|
81
130
|
return out;
|
|
82
131
|
}
|
|
83
132
|
return value;
|
|
84
133
|
}
|
|
85
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Merge two `type: instances` payloads (each a JSON-encoded array of
|
|
137
|
+
* `{name, ...}` rows) by row name:
|
|
138
|
+
* - Same-name rows: keep existing values but FILL any empty fields
|
|
139
|
+
* from the template. Lets a re-run land a freshly-baked api_token
|
|
140
|
+
* when the previous run added the instance with an empty one.
|
|
141
|
+
* - Template rows not in existing: appended verbatim.
|
|
142
|
+
* - Existing rows not in template: kept only if any non-name field
|
|
143
|
+
* has a meaningful value (so a stale `default-jenkins` row left
|
|
144
|
+
* over from an older template — empty username, empty token —
|
|
145
|
+
* gets pruned automatically). User-added instances with real
|
|
146
|
+
* creds are preserved.
|
|
147
|
+
*
|
|
148
|
+
* Inputs can be either a string (the on-disk shape) or a parsed array.
|
|
149
|
+
* Returns `{value, changed}` where `value` is the JSON string to write
|
|
150
|
+
* back and `changed` is true iff anything got added/filled/dropped.
|
|
151
|
+
*/
|
|
152
|
+
function mergeInstancesByName(
|
|
153
|
+
existing: unknown,
|
|
154
|
+
fromTemplate: unknown,
|
|
155
|
+
forceOverwriteByName?: Map<string, Set<string>>,
|
|
156
|
+
): { value: string; changed: boolean } {
|
|
157
|
+
const parse = (v: unknown): any[] => {
|
|
158
|
+
if (Array.isArray(v)) return v;
|
|
159
|
+
if (typeof v === 'string' && v.trim()) {
|
|
160
|
+
try { const j = JSON.parse(v); return Array.isArray(j) ? j : []; } catch { return []; }
|
|
161
|
+
}
|
|
162
|
+
return [];
|
|
163
|
+
};
|
|
164
|
+
const isEmpty = (v: unknown): boolean =>
|
|
165
|
+
v == null
|
|
166
|
+
|| (typeof v === 'string' && v.trim() === '')
|
|
167
|
+
|| (Array.isArray(v) && v.length === 0);
|
|
168
|
+
const hasAnyRealValue = (row: any): boolean => {
|
|
169
|
+
if (!row || typeof row !== 'object') return false;
|
|
170
|
+
for (const [k, v] of Object.entries(row)) {
|
|
171
|
+
if (k === 'name') continue;
|
|
172
|
+
if (!isEmpty(v)) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
};
|
|
176
|
+
const exRows = parse(existing);
|
|
177
|
+
const tmplRows = parse(fromTemplate);
|
|
178
|
+
const tmplByName = new Map<string, any>();
|
|
179
|
+
for (const r of tmplRows) {
|
|
180
|
+
const n = String(r?.name ?? '').trim();
|
|
181
|
+
if (n) tmplByName.set(n, r);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let changed = false;
|
|
185
|
+
const out: any[] = [];
|
|
186
|
+
const keptNames = new Set<string>();
|
|
187
|
+
|
|
188
|
+
for (const ex of exRows) {
|
|
189
|
+
const name = String(ex?.name ?? '').trim();
|
|
190
|
+
const tmpl = name ? tmplByName.get(name) : undefined;
|
|
191
|
+
if (tmpl) {
|
|
192
|
+
const forceFields = forceOverwriteByName?.get(name);
|
|
193
|
+
// Fill empty fields from template. Existing non-empty values win,
|
|
194
|
+
// EXCEPT for identity-derived fields (template's raw value contains
|
|
195
|
+
// `{user.*}` / `{dept.*}` tokens) — those always re-track the user's
|
|
196
|
+
// current identity so a displayEmail change refreshes jenkins
|
|
197
|
+
// username, etc.
|
|
198
|
+
const merged: any = { ...ex };
|
|
199
|
+
for (const [k, v] of Object.entries(tmpl)) {
|
|
200
|
+
if (k === 'name') continue;
|
|
201
|
+
const force = forceFields?.has(k);
|
|
202
|
+
if (force && !isEmpty(v) && JSON.stringify((merged as any)[k]) !== JSON.stringify(v)) {
|
|
203
|
+
(merged as any)[k] = v;
|
|
204
|
+
changed = true;
|
|
205
|
+
} else if (isEmpty((merged as any)[k]) && !isEmpty(v)) {
|
|
206
|
+
(merged as any)[k] = v;
|
|
207
|
+
changed = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
out.push(merged);
|
|
211
|
+
keptNames.add(name);
|
|
212
|
+
} else {
|
|
213
|
+
// Orphan (name not in current template) — drop. The template
|
|
214
|
+
// is the canonical instance list; pre-existing rows from a
|
|
215
|
+
// prior template version or auto-defaults get pruned here so
|
|
216
|
+
// the user sees exactly what the active template prescribes.
|
|
217
|
+
// Users who manually want a custom instance should add it
|
|
218
|
+
// AFTER wizard apply — those won't get clobbered until the
|
|
219
|
+
// next wizard re-run.
|
|
220
|
+
changed = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Append template rows that didn't match any existing.
|
|
224
|
+
for (const [name, row] of tmplByName) {
|
|
225
|
+
if (keptNames.has(name)) continue;
|
|
226
|
+
out.push(row);
|
|
227
|
+
changed = true;
|
|
228
|
+
}
|
|
229
|
+
return { value: JSON.stringify(out), changed };
|
|
230
|
+
}
|
|
231
|
+
|
|
86
232
|
function isEffectivelyEmpty(v: unknown): boolean {
|
|
87
233
|
if (v == null) return true;
|
|
88
234
|
if (typeof v === 'string') return v.trim() === '';
|
|
@@ -91,11 +237,25 @@ function isEffectivelyEmpty(v: unknown): boolean {
|
|
|
91
237
|
return false;
|
|
92
238
|
}
|
|
93
239
|
|
|
94
|
-
function resolveTemplate(): any {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
240
|
+
function resolveTemplate(sourceId?: string, deptId?: string): any {
|
|
241
|
+
// resolveWizardTemplate covers user upload → enterprise priority list →
|
|
242
|
+
// public cache. When all those miss, the bundled JSON shipped inside
|
|
243
|
+
// Forge is the final fallback so the wizard always has something to
|
|
244
|
+
// work with even offline / on a fresh install.
|
|
245
|
+
//
|
|
246
|
+
// When sourceId is given, the resolver scopes to that single source
|
|
247
|
+
// (no priority traversal). When deptId is also given, reads the
|
|
248
|
+
// per-dept template (E3). If that source isn't cached, we still fall
|
|
249
|
+
// back to the bundled template so the wizard renders something.
|
|
250
|
+
const resolved = resolveWizardTemplate(sourceId, deptId);
|
|
251
|
+
if (resolved) return resolved.template;
|
|
252
|
+
// E5+: dept exists in the source index but has no template file (the
|
|
253
|
+
// registry entry omitted `path`). Fall through to the public source
|
|
254
|
+
// template so the user gets *something* to work with — their dept
|
|
255
|
+
// identity is still recorded via installed_dept once they apply.
|
|
256
|
+
if (sourceId && deptId) {
|
|
257
|
+
const publicT = resolveWizardTemplate('public');
|
|
258
|
+
if (publicT) return publicT.template;
|
|
99
259
|
}
|
|
100
260
|
return bundledTemplate;
|
|
101
261
|
}
|
|
@@ -159,28 +319,243 @@ function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
|
|
|
159
319
|
return null;
|
|
160
320
|
}
|
|
161
321
|
|
|
162
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Run template-level pre-processing once:
|
|
324
|
+
* • _derive — populate computed `values` (e.g. user_email_local = email split @)
|
|
325
|
+
* • ent:… secret blobs — decrypted via _enterprise_name
|
|
326
|
+
* • _agents / _apiProfiles — written to settings on the side
|
|
327
|
+
* Returns the (possibly decrypted) template, plus the same values map with
|
|
328
|
+
* derived keys merged in. The connector-loop downstream then runs as before.
|
|
329
|
+
*/
|
|
330
|
+
function preprocessTemplate(
|
|
331
|
+
rawTemplate: any,
|
|
332
|
+
values: Record<string, string>,
|
|
333
|
+
): { template: any; values: Record<string, string>; agentsApplied: string[]; profilesApplied: string[] } {
|
|
334
|
+
let template = rawTemplate;
|
|
335
|
+
|
|
336
|
+
// _derive — same recipes as the import-config-template route
|
|
337
|
+
const deriveDict = (template?._derive ?? {}) as Record<string, unknown>;
|
|
338
|
+
for (const [derivedKey, spec] of Object.entries(deriveDict)) {
|
|
339
|
+
if (values[derivedKey]) continue;
|
|
340
|
+
let sourceKey: string;
|
|
341
|
+
let op = 'auto';
|
|
342
|
+
if (typeof spec === 'string') sourceKey = spec;
|
|
343
|
+
else if (spec && typeof spec === 'object' && typeof (spec as any).from === 'string') {
|
|
344
|
+
sourceKey = (spec as any).from;
|
|
345
|
+
op = String((spec as any).op || 'auto');
|
|
346
|
+
} else continue;
|
|
347
|
+
const raw = values[sourceKey] ?? '';
|
|
348
|
+
if (!raw) continue;
|
|
349
|
+
let derived = raw;
|
|
350
|
+
if (op === 'email_local' || (op === 'auto' && raw.includes('@'))) {
|
|
351
|
+
derived = raw.split('@')[0];
|
|
352
|
+
}
|
|
353
|
+
values[derivedKey] = derived;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Decrypt ent:… blobs everywhere in the template up front
|
|
357
|
+
const enterpriseName = (template?._enterprise_name as string | undefined) || '';
|
|
358
|
+
if (enterpriseName) {
|
|
359
|
+
template = decryptDeep(template, enterpriseName);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// _agents — preserve existing user agents unless template entry sets _overwrite.
|
|
363
|
+
//
|
|
364
|
+
// Derived agents (template entry has `tool: claude` but no `path`) get the
|
|
365
|
+
// missing identity fields filled in from the base agent's settings entry —
|
|
366
|
+
// path, skipPermissionsFlag, cliType, requiresTTY, resumeFlag,
|
|
367
|
+
// outputFormat. This produces a self-contained settings.yaml: every reader
|
|
368
|
+
// just reads the agent's own row, no runtime base-inheritance gymnastics.
|
|
369
|
+
// listAgents() also auto-detects claude/codex/aider, so even on first run
|
|
370
|
+
// we can read settings.claude.path here.
|
|
371
|
+
const agentsApplied: string[] = [];
|
|
372
|
+
const agentsDict = template._agents as Record<string, any> | undefined;
|
|
373
|
+
if (agentsDict && typeof agentsDict === 'object') {
|
|
374
|
+
const fresh = loadSettings();
|
|
375
|
+
const current = { ...(fresh.agents || {}) };
|
|
376
|
+
let changed = false;
|
|
377
|
+
const { listAgents } = require('../../../lib/agents');
|
|
378
|
+
const detected = listAgents();
|
|
379
|
+
const detectedById = new Map(detected.map((a: any) => [a.id, a]));
|
|
380
|
+
// Helper — merge base-agent identity into a derived entry.
|
|
381
|
+
function inheritFromBase(entry: any, toolId: string) {
|
|
382
|
+
const base: any = detectedById.get(toolId);
|
|
383
|
+
if (!base) return false;
|
|
384
|
+
let filled = false;
|
|
385
|
+
if (base.path && !entry.path) { entry.path = base.path; filled = true; }
|
|
386
|
+
if (base.skipPermissionsFlag && !entry.skipPermissionsFlag) {
|
|
387
|
+
entry.skipPermissionsFlag = base.skipPermissionsFlag; filled = true;
|
|
388
|
+
}
|
|
389
|
+
if (base.cliType && !entry.cliType) { entry.cliType = base.cliType; filled = true; }
|
|
390
|
+
if (base.requiresTTY !== undefined && entry.requiresTTY === undefined) {
|
|
391
|
+
entry.requiresTTY = base.requiresTTY; filled = true;
|
|
392
|
+
}
|
|
393
|
+
return filled;
|
|
394
|
+
}
|
|
395
|
+
for (const [agentId, raw] of Object.entries(agentsDict)) {
|
|
396
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
397
|
+
const overwrite = (raw as any)._overwrite === true;
|
|
398
|
+
const existing = current[agentId];
|
|
399
|
+
const { _overwrite, ...templateEntry } = raw as Record<string, unknown>;
|
|
400
|
+
let entry: any;
|
|
401
|
+
if (existing && !overwrite) {
|
|
402
|
+
// Existing entry — don't replace, but BACKFILL missing identity
|
|
403
|
+
// fields (path / skipPermissionsFlag / cliType / requiresTTY) from
|
|
404
|
+
// the base agent. This is how Re-run wizard heals settings.yaml
|
|
405
|
+
// entries written by older wizards that didn't inherit.
|
|
406
|
+
entry = { ...existing };
|
|
407
|
+
const toolId = entry.tool;
|
|
408
|
+
if (toolId && toolId !== agentId && inheritFromBase(entry, toolId)) {
|
|
409
|
+
current[agentId] = entry as AgentEntry;
|
|
410
|
+
agentsApplied.push(agentId);
|
|
411
|
+
changed = true;
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
entry = templateEntry;
|
|
416
|
+
// New entry — also inherit
|
|
417
|
+
const toolId = entry.tool;
|
|
418
|
+
if (toolId && toolId !== agentId) inheritFromBase(entry, toolId);
|
|
419
|
+
current[agentId] = entry as AgentEntry;
|
|
420
|
+
agentsApplied.push(agentId);
|
|
421
|
+
changed = true;
|
|
422
|
+
}
|
|
423
|
+
// ALSO write detected builtins (claude/codex/aider) into settings so
|
|
424
|
+
// every reader can see them via settings.agents.<id>.path directly.
|
|
425
|
+
// listAgents() already auto-detects them on every call, but writing
|
|
426
|
+
// them out makes settings.yaml self-documenting and lets backend code
|
|
427
|
+
// that reads raw settings (without going through listAgents) work
|
|
428
|
+
// correctly too.
|
|
429
|
+
for (const a of detected as any[]) {
|
|
430
|
+
if (!['claude', 'codex', 'aider'].includes(a.id)) continue;
|
|
431
|
+
const existing = current[a.id] || {};
|
|
432
|
+
const merged: any = {
|
|
433
|
+
tool: existing.tool || a.id,
|
|
434
|
+
name: existing.name || a.name || a.id,
|
|
435
|
+
enabled: existing.enabled !== false,
|
|
436
|
+
...existing,
|
|
437
|
+
};
|
|
438
|
+
if (!merged.path && a.path) { merged.path = a.path; changed = true; }
|
|
439
|
+
if (!merged.skipPermissionsFlag && a.skipPermissionsFlag) {
|
|
440
|
+
merged.skipPermissionsFlag = a.skipPermissionsFlag; changed = true;
|
|
441
|
+
}
|
|
442
|
+
if (!merged.cliType && a.cliType) { merged.cliType = a.cliType; changed = true; }
|
|
443
|
+
// Only write back if we actually added something new
|
|
444
|
+
if (JSON.stringify(merged) !== JSON.stringify(current[a.id] || {})) {
|
|
445
|
+
current[a.id] = merged;
|
|
446
|
+
changed = true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (changed) saveSettings({ ...fresh, agents: current });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// _apiProfiles — same shape rules
|
|
453
|
+
const profilesApplied: string[] = [];
|
|
454
|
+
const profilesDict = template._apiProfiles as Record<string, any> | undefined;
|
|
455
|
+
if (profilesDict && typeof profilesDict === 'object') {
|
|
456
|
+
const fresh = loadSettings();
|
|
457
|
+
const current = { ...(fresh.apiProfiles || {}) };
|
|
458
|
+
let changed = false;
|
|
459
|
+
let defaultPick: string | undefined;
|
|
460
|
+
for (const [profileId, raw] of Object.entries(profilesDict)) {
|
|
461
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
462
|
+
const overwrite = (raw as any)._overwrite === true;
|
|
463
|
+
if (current[profileId] && !overwrite) continue;
|
|
464
|
+
const { _overwrite, _default, ...entry } = raw as Record<string, unknown>;
|
|
465
|
+
current[profileId] = entry as unknown as ApiProfile;
|
|
466
|
+
profilesApplied.push(profileId);
|
|
467
|
+
changed = true;
|
|
468
|
+
// First entry flagged `_default: true` (or the first applied entry
|
|
469
|
+
// when no flag is set anywhere) becomes the chat backend default —
|
|
470
|
+
// but only if the user hasn't already chosen one.
|
|
471
|
+
if (_default === true && !defaultPick) defaultPick = profileId;
|
|
472
|
+
}
|
|
473
|
+
if (changed) {
|
|
474
|
+
const nextSettings = { ...fresh, apiProfiles: current };
|
|
475
|
+
// Pick a default chatAgent if user has none yet. Either honour an
|
|
476
|
+
// explicit `_default: true` flag, or fall back to the first applied
|
|
477
|
+
// profile so chat works out of the box.
|
|
478
|
+
if (!fresh.chatAgent && (defaultPick || profilesApplied[0])) {
|
|
479
|
+
nextSettings.chatAgent = defaultPick || profilesApplied[0];
|
|
480
|
+
}
|
|
481
|
+
saveSettings(nextSettings);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { template, values, agentsApplied, profilesApplied };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Exported so /api/enterprise-keys can re-apply the template's literal
|
|
489
|
+
// defaults (new fields added in a template update, e.g. default_job_path
|
|
490
|
+
// / default_project_path) after a Reinstall without forcing the user
|
|
491
|
+
// to re-run the entire wizard. Existing non-empty values are preserved
|
|
492
|
+
// — see the merge logic inside.
|
|
493
|
+
export async function applyConnectors(
|
|
163
494
|
values: Record<string, string> | undefined,
|
|
164
495
|
selectedConnectors: string[] | undefined,
|
|
496
|
+
sourceId?: string,
|
|
497
|
+
deptId?: string,
|
|
498
|
+
opts: { forceAll?: boolean } = {},
|
|
165
499
|
): Promise<{
|
|
166
500
|
applied: string[];
|
|
167
501
|
installed_from_registry: string[];
|
|
168
502
|
skipped_missing_manifest: string[];
|
|
169
503
|
skipped_unselected: string[];
|
|
170
504
|
fields_preserved: Array<{ connector: string; field: string }>;
|
|
505
|
+
agents_applied?: string[];
|
|
506
|
+
api_profiles_applied?: string[];
|
|
171
507
|
}> {
|
|
172
|
-
const
|
|
508
|
+
const rawTemplate = resolveTemplate(sourceId, deptId);
|
|
173
509
|
// Undefined = install everything; explicit [] = install nothing.
|
|
174
510
|
const selected = selectedConnectors ? new Set(selectedConnectors) : null;
|
|
175
511
|
// Auto-inject user identity from settings so connectors (e.g. tp.username)
|
|
176
512
|
// can reference {user_name} / {user_email} without prompting again.
|
|
177
513
|
// User-supplied values still win over auto-injected ones.
|
|
178
514
|
const settings = loadSettings();
|
|
179
|
-
const
|
|
515
|
+
const initialValues: Record<string, string> = {
|
|
180
516
|
user_name: settings.displayName || '',
|
|
181
517
|
user_email: settings.displayEmail || '',
|
|
182
518
|
...(values || {}),
|
|
183
519
|
};
|
|
520
|
+
// {user.X} ident — distinct from the legacy ${user_name} prompt key.
|
|
521
|
+
// Used by substituteAll to bake displayName/login into per-connector
|
|
522
|
+
// settings (e.g. Jenkins instances[].username) so the persisted config
|
|
523
|
+
// holds a literal value, not the curly-token placeholder.
|
|
524
|
+
//
|
|
525
|
+
// Fallback chain matters: a user who only filled in Display Name in
|
|
526
|
+
// the wizard (no email yet) still gets a usable login, instead of
|
|
527
|
+
// ending up with Jenkins.username="". And vice versa.
|
|
528
|
+
const emailLocal = (settings.displayEmail || '').split('@')[0] || '';
|
|
529
|
+
const niceName = (settings.displayName && settings.displayName.trim() !== 'Forge')
|
|
530
|
+
? settings.displayName.trim() : '';
|
|
531
|
+
const userIdent = {
|
|
532
|
+
name: niceName || emailLocal,
|
|
533
|
+
email: settings.displayEmail || '',
|
|
534
|
+
login: emailLocal || niceName,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// E3: dept identity from the template's `_department_name`. Used by
|
|
538
|
+
// {dept.name} substitution so templates can write
|
|
539
|
+
// `default_project: "{dept.name}"` instead of hardcoding "FortiNAC".
|
|
540
|
+
// E5+: template-less depts have no `_department_name` — fall back to
|
|
541
|
+
// the dept index's display_name so the user's choice still flows
|
|
542
|
+
// through to installed_dept + {dept.name}.
|
|
543
|
+
let deptName = typeof (rawTemplate as any)?._department_name === 'string'
|
|
544
|
+
? String((rawTemplate as any)._department_name).trim() : '';
|
|
545
|
+
if (!deptName && sourceId && deptId) {
|
|
546
|
+
try {
|
|
547
|
+
const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
|
|
548
|
+
const match = listSourceDepartments(sourceId).find(d => d.id === deptId);
|
|
549
|
+
if (match) deptName = match.display_name;
|
|
550
|
+
} catch {}
|
|
551
|
+
}
|
|
552
|
+
const deptIdent = deptName ? { name: deptName } : undefined;
|
|
553
|
+
|
|
554
|
+
// _derive + ent: decryption + _agents/_apiProfiles apply happens here,
|
|
555
|
+
// regardless of which connectors the user selected. (Agents and API
|
|
556
|
+
// profiles belong to the user as a whole, not to a specific connector.)
|
|
557
|
+
const { template, values: subst, agentsApplied, profilesApplied } =
|
|
558
|
+
preprocessTemplate(rawTemplate, initialValues);
|
|
184
559
|
const applied: string[] = [];
|
|
185
560
|
const installedFromRegistry: string[] = [];
|
|
186
561
|
const missing: string[] = [];
|
|
@@ -208,10 +583,58 @@ async function applyConnectors(
|
|
|
208
583
|
const merged: Record<string, unknown> = { ...existing };
|
|
209
584
|
|
|
210
585
|
for (const [field, rawVal] of Object.entries(templateConfig)) {
|
|
211
|
-
const resolved = substituteAll(rawVal, subst);
|
|
586
|
+
const resolved = substituteAll(rawVal, subst, userIdent, deptIdent);
|
|
212
587
|
const existingVal = existing[field];
|
|
588
|
+
const fieldDef = (def.settings as any)?.[field];
|
|
589
|
+
|
|
590
|
+
// `type: instances` (multi-row settings, e.g. Jenkins instances)
|
|
591
|
+
// gets row-by-row merge by `name`. Existing rows are preserved
|
|
592
|
+
// (passwords stay, user edits stay); template rows whose name
|
|
593
|
+
// isn't already present get added. This means a template can
|
|
594
|
+
// bake a recommended instance (fortinac-jenkins / zliu) without
|
|
595
|
+
// clobbering whatever the user already configured.
|
|
596
|
+
if (fieldDef?.type === 'instances') {
|
|
597
|
+
// Two force-overwrite triggers per row+field:
|
|
598
|
+
// (a) Reinstall (`opts.forceAll`): every template field is canonical —
|
|
599
|
+
// this is what "Reinstall" means semantically (make config match
|
|
600
|
+
// template exactly, drop user drift).
|
|
601
|
+
// (b) Identity-derived fields ({user.*} / {dept.*} tokens in raw
|
|
602
|
+
// template): re-track the user's current identity even on a
|
|
603
|
+
// normal Apply, so displayEmail changes refresh derived values
|
|
604
|
+
// like jenkins username.
|
|
605
|
+
const forceMap = new Map<string, Set<string>>();
|
|
606
|
+
const rawRows = Array.isArray(rawVal) ? rawVal : [];
|
|
607
|
+
for (const r of rawRows) {
|
|
608
|
+
if (!r || typeof r !== 'object') continue;
|
|
609
|
+
const rowName = String((r as any).name ?? '').trim();
|
|
610
|
+
if (!rowName) continue;
|
|
611
|
+
const flagged = new Set<string>();
|
|
612
|
+
for (const [k, v] of Object.entries(r as Record<string, unknown>)) {
|
|
613
|
+
if (k === 'name') continue;
|
|
614
|
+
if (opts.forceAll) flagged.add(k);
|
|
615
|
+
else if (typeof v === 'string' && /\{(?:user|dept)\.[a-zA-Z_]+\}/.test(v)) flagged.add(k);
|
|
616
|
+
}
|
|
617
|
+
if (flagged.size > 0) forceMap.set(rowName, flagged);
|
|
618
|
+
}
|
|
619
|
+
const mergedRows = mergeInstancesByName(existingVal, resolved, forceMap);
|
|
620
|
+
if (mergedRows.changed) merged[field] = mergedRows.value;
|
|
621
|
+
else if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
|
|
622
|
+
preserved.push({ connector: id, field });
|
|
623
|
+
}
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Existing values that still carry `${...}` placeholders are
|
|
628
|
+
// half-applied from an earlier wizard run (e.g. a template
|
|
629
|
+
// referenced ${jenkins_username} before we baked 'zliu' as the
|
|
630
|
+
// default). Treat them as empty so the new template wins —
|
|
631
|
+
// otherwise a re-run preserves the broken half instead of fixing
|
|
632
|
+
// it. `TODO_` is the legacy placeholder convention.
|
|
633
|
+
const existingHasStalePlaceholder = typeof existingVal === 'string'
|
|
634
|
+
&& /\$\{[a-zA-Z0-9_]+\}/.test(existingVal);
|
|
213
635
|
const existingNonEmpty = !isEffectivelyEmpty(existingVal)
|
|
214
|
-
&& !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'))
|
|
636
|
+
&& !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'))
|
|
637
|
+
&& !existingHasStalePlaceholder;
|
|
215
638
|
if (existingNonEmpty) {
|
|
216
639
|
if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
|
|
217
640
|
preserved.push({ connector: id, field });
|
|
@@ -224,6 +647,43 @@ async function applyConnectors(
|
|
|
224
647
|
setConnectorConfig(id, merged);
|
|
225
648
|
applied.push(id);
|
|
226
649
|
if (typeof enabledTemplate === 'boolean') setConnectorEnabled(id, enabledTemplate);
|
|
650
|
+
// E3: remember which dept's template owns this row so a future
|
|
651
|
+
// wizard re-run lands on the same dept by default. Unsetting when
|
|
652
|
+
// there's no _department_name (public/single-template tier) keeps
|
|
653
|
+
// the row honest.
|
|
654
|
+
setConnectorDept(id, deptIdent?.name);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Stamp company + dept onto the user profile so Settings → Identity
|
|
658
|
+
// and the Dashboard prefix show "Fortinet · FortiADC" without having
|
|
659
|
+
// to scan installed_dept off every connector row.
|
|
660
|
+
//
|
|
661
|
+
// Source for company:
|
|
662
|
+
// 1. The enterprise source's display_name (e.g. "Fortinet") when
|
|
663
|
+
// sourceId is known — case-correct, matches the dropdown's
|
|
664
|
+
// option values.
|
|
665
|
+
// 2. Fallback to the template's _enterprise_name (e.g. "fortinet")
|
|
666
|
+
// which may be lower-case from the template author.
|
|
667
|
+
try {
|
|
668
|
+
let companyName = '';
|
|
669
|
+
if (sourceId) {
|
|
670
|
+
try {
|
|
671
|
+
const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
|
|
672
|
+
const src = listEnterpriseSourceMetas().find(m => m.id === sourceId);
|
|
673
|
+
if (src?.display_name) companyName = src.display_name;
|
|
674
|
+
} catch {}
|
|
675
|
+
}
|
|
676
|
+
if (!companyName && typeof (rawTemplate as any)?._enterprise_name === 'string') {
|
|
677
|
+
companyName = String((rawTemplate as any)._enterprise_name).trim();
|
|
678
|
+
}
|
|
679
|
+
if (companyName || deptName) {
|
|
680
|
+
const s = loadSettings();
|
|
681
|
+
if (companyName) s.company = companyName;
|
|
682
|
+
if (deptName) s.dept = deptName;
|
|
683
|
+
saveSettings(s);
|
|
684
|
+
}
|
|
685
|
+
} catch (e) {
|
|
686
|
+
console.warn('[onboarding] profile stamp failed:', (e as Error).message);
|
|
227
687
|
}
|
|
228
688
|
|
|
229
689
|
return {
|
|
@@ -232,6 +692,126 @@ async function applyConnectors(
|
|
|
232
692
|
skipped_missing_manifest: missing,
|
|
233
693
|
skipped_unselected: skippedUnselected,
|
|
234
694
|
fields_preserved: preserved,
|
|
695
|
+
agents_applied: agentsApplied.length ? agentsApplied : undefined,
|
|
696
|
+
api_profiles_applied: profilesApplied.length ? profilesApplied : undefined,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Install the pipelines listed in the wizard template's `_pipelines: [name…]`
|
|
702
|
+
* block. Used by Reinstall so connectors and pipelines have the same default
|
|
703
|
+
* behavior — both auto-land from the template when the user re-pulls. If the
|
|
704
|
+
* template omits `_pipelines`, falls back to `suggested_pipelines` (the
|
|
705
|
+
* marketplace's fortinet-* prefix heuristic).
|
|
706
|
+
*/
|
|
707
|
+
export async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
|
|
708
|
+
installed: string[];
|
|
709
|
+
errors: Array<{ name: string; error: string }>;
|
|
710
|
+
}> {
|
|
711
|
+
const tpl = resolveTemplate(sourceId, deptId) as any;
|
|
712
|
+
let names: string[] | undefined = Array.isArray(tpl?._pipelines)
|
|
713
|
+
? (tpl._pipelines as any[]).filter((n): n is string => typeof n === 'string' && !!n)
|
|
714
|
+
: undefined;
|
|
715
|
+
if (!names || names.length === 0) {
|
|
716
|
+
try {
|
|
717
|
+
const m = listMarketplace();
|
|
718
|
+
names = (m.pipelines || [])
|
|
719
|
+
.filter((e: any) => e.name?.startsWith('fortinet-'))
|
|
720
|
+
.map((e: any) => e.name);
|
|
721
|
+
} catch { names = []; }
|
|
722
|
+
}
|
|
723
|
+
return applyPipelines(names);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Auto-provision a per-user Temper memory account.
|
|
728
|
+
*
|
|
729
|
+
* Template-driven, one-shot. The wizard template's `_temperAdmin: { url,
|
|
730
|
+
* token: 'ent:…' }` block (encrypted with the enterprise key) is decrypted
|
|
731
|
+
* here and used to call Temper's `/v1/onboarding/provision`. The admin
|
|
732
|
+
* token NEVER persists to settings — it's a wizard-only side door for
|
|
733
|
+
* initialization. Different depts can omit the block to opt out, or swap
|
|
734
|
+
* for their own provisioning hooks later.
|
|
735
|
+
*
|
|
736
|
+
* Persisted on success: `temperUrl` + `temperKey` (the user's mk_… key) +
|
|
737
|
+
* `temperNamespace` (= org_slug). `memoryBackend` stays whatever the user
|
|
738
|
+
* picked (default 'auto' Temper-when-URL+key).
|
|
739
|
+
*
|
|
740
|
+
* Re-run is a no-op when `temperKey` is already set — re-provisioning a
|
|
741
|
+
* user requires clearing the key first via Settings → Memory.
|
|
742
|
+
*/
|
|
743
|
+
async function applyTemperAutoProvision(
|
|
744
|
+
sourceId: string | undefined,
|
|
745
|
+
deptId: string | undefined,
|
|
746
|
+
): Promise<Record<string, unknown>> {
|
|
747
|
+
const rawTpl = resolveTemplate(sourceId, deptId) as any;
|
|
748
|
+
const entName = typeof rawTpl?._enterprise_name === 'string' ? rawTpl._enterprise_name : '';
|
|
749
|
+
const decryptedTpl = entName ? (decryptDeep(rawTpl, entName) as any) : rawTpl;
|
|
750
|
+
const tplAdmin = (decryptedTpl?._temperAdmin ?? {}) as { url?: string; token?: string };
|
|
751
|
+
|
|
752
|
+
const adminToken = typeof tplAdmin.token === 'string' ? tplAdmin.token : '';
|
|
753
|
+
const adminUrl = typeof tplAdmin.url === 'string' ? tplAdmin.url : '';
|
|
754
|
+
|
|
755
|
+
if (!adminToken || !adminUrl) {
|
|
756
|
+
return { skipped: 'no_admin_in_template' };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const settings = loadSettings();
|
|
760
|
+
if (settings.temperKey) {
|
|
761
|
+
return { skipped: 'already_provisioned' };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const email = (settings.displayEmail || '').trim();
|
|
765
|
+
const company = (settings.company || '').trim();
|
|
766
|
+
const dept = (settings.dept || '').trim();
|
|
767
|
+
if (!email || !company || !dept) {
|
|
768
|
+
return {
|
|
769
|
+
skipped: 'identity_incomplete',
|
|
770
|
+
missing: { email: !email, company: !company, dept: !dept },
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const username = email.split('@')[0];
|
|
775
|
+
const niceName = settings.displayName && settings.displayName.trim() !== 'Forge'
|
|
776
|
+
? settings.displayName.trim()
|
|
777
|
+
: username;
|
|
778
|
+
|
|
779
|
+
const result = await provisionTemperUser({
|
|
780
|
+
url: adminUrl,
|
|
781
|
+
adminToken,
|
|
782
|
+
username,
|
|
783
|
+
email,
|
|
784
|
+
company,
|
|
785
|
+
dept,
|
|
786
|
+
displayName: niceName,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
if (!result.ok) {
|
|
790
|
+
// 409 = user already exists in Temper. We don't have their existing
|
|
791
|
+
// mk_ key (the admin endpoint mints on first-create only), so the
|
|
792
|
+
// Forge admin has to recover it out-of-band. Don't fail the wizard
|
|
793
|
+
// — just surface the conflict.
|
|
794
|
+
return {
|
|
795
|
+
error: result.error,
|
|
796
|
+
status: result.status,
|
|
797
|
+
...(result.conflict ? { conflict: true } : {}),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Persist user-scoped creds only. Admin token stays out of settings —
|
|
802
|
+
// re-runs read it from the template again on demand.
|
|
803
|
+
const s2 = loadSettings();
|
|
804
|
+
s2.temperUrl = adminUrl;
|
|
805
|
+
s2.temperKey = result.api_key;
|
|
806
|
+
s2.temperNamespace = result.org_slug || s2.temperNamespace || '';
|
|
807
|
+
saveSettings(s2);
|
|
808
|
+
|
|
809
|
+
return {
|
|
810
|
+
provisioned: true,
|
|
811
|
+
user_id: result.user_id,
|
|
812
|
+
username: result.username,
|
|
813
|
+
org_slug: result.org_slug,
|
|
814
|
+
group_slug: result.group_slug,
|
|
235
815
|
};
|
|
236
816
|
}
|
|
237
817
|
|
|
@@ -269,9 +849,143 @@ function applyProjectRoots(paths: string[] | undefined): string | null {
|
|
|
269
849
|
|
|
270
850
|
// ─── Routes ──────────────────────────────────────────────────
|
|
271
851
|
|
|
272
|
-
export async function GET() {
|
|
852
|
+
export async function GET(req: Request) {
|
|
273
853
|
const settings = loadSettings();
|
|
274
|
-
|
|
854
|
+
// E2: optional ?source_id=<id> scopes the wizard to a single tenant's
|
|
855
|
+
// template. Without it, the priority chain (user → enterprise → public)
|
|
856
|
+
// resolves the default. The UI passes source_id via the tenant
|
|
857
|
+
// selector at the top of the wizard, or after a fresh add-key handoff.
|
|
858
|
+
const url = new URL(req.url);
|
|
859
|
+
let requestedSourceId = (url.searchParams.get('source_id') || '').trim() || undefined;
|
|
860
|
+
// E3: dept selector scopes further into a specific department template
|
|
861
|
+
// when the source ships `wizard_templates: [...]`. Omitted = use the
|
|
862
|
+
// source's first dept as default (resolveWizardTemplate handles).
|
|
863
|
+
let requestedDeptId = (url.searchParams.get('dept') || '').trim() || undefined;
|
|
864
|
+
|
|
865
|
+
// Profile defaults: if the URL doesn't pin a source/dept, treat
|
|
866
|
+
// settings.company + settings.dept as the requested scope. Matches
|
|
867
|
+
// by display_name (case-insensitive). No match → leave undefined and
|
|
868
|
+
// the priority chain takes over (which lands on public when nothing
|
|
869
|
+
// else fits). Simple, no inference, no derivation.
|
|
870
|
+
if (!requestedSourceId && settings.company) {
|
|
871
|
+
try {
|
|
872
|
+
const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
|
|
873
|
+
const want = settings.company.trim().toLowerCase();
|
|
874
|
+
const m = listEnterpriseSourceMetas().find(s => s.display_name.toLowerCase() === want);
|
|
875
|
+
if (m) requestedSourceId = m.id;
|
|
876
|
+
} catch {}
|
|
877
|
+
}
|
|
878
|
+
if (!requestedDeptId && requestedSourceId && settings.dept) {
|
|
879
|
+
try {
|
|
880
|
+
const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
|
|
881
|
+
const want = settings.dept.trim().toLowerCase();
|
|
882
|
+
const d = listSourceDepartments(requestedSourceId).find(x => x.display_name.toLowerCase() === want);
|
|
883
|
+
if (d) requestedDeptId = d.id;
|
|
884
|
+
} catch {}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// First-launch race fix: if the user has enterprise sources configured
|
|
888
|
+
// but none of them has a wizard-template.json cached yet, sync now
|
|
889
|
+
// (synchronously) before resolving the template. Otherwise the wizard
|
|
890
|
+
// would render against the public/bundled template with all the wrong
|
|
891
|
+
// defaults — exactly the issue users hit when they boot a fresh data
|
|
892
|
+
// dir with --enterprise-key set. Best-effort: any sync failure (PAT
|
|
893
|
+
// wrong, network down) just falls through to the existing fallback
|
|
894
|
+
// chain so the wizard at least loads.
|
|
895
|
+
try {
|
|
896
|
+
const { listEnterpriseSourceMetas, syncRegistry } = await import('@/lib/connectors/sync');
|
|
897
|
+
const { existsSync } = await import('node:fs');
|
|
898
|
+
const { join } = await import('node:path');
|
|
899
|
+
const { getDataDir } = await import('@/lib/dirs');
|
|
900
|
+
const sources = listEnterpriseSourceMetas();
|
|
901
|
+
// E3: a source counts as cached if EITHER the legacy single
|
|
902
|
+
// wizard-template.json OR the multi-dept wizards/_index.json is
|
|
903
|
+
// on disk. Without this second check, fortinet (which post-E5
|
|
904
|
+
// ships only wizards/) would re-trigger sync on every onboarding
|
|
905
|
+
// GET because the legacy file never lands.
|
|
906
|
+
const anyCached = sources.some((s: any) => {
|
|
907
|
+
const base = join(getDataDir(), 'connectors', 'sources', s.id);
|
|
908
|
+
return existsSync(join(base, 'wizard-template.json'))
|
|
909
|
+
|| existsSync(join(base, 'wizards', '_index.json'));
|
|
910
|
+
});
|
|
911
|
+
if (sources.length > 0 && !anyCached) {
|
|
912
|
+
console.log('[onboarding] first-run enterprise sync (pulling templates)…');
|
|
913
|
+
await syncRegistry({ refreshInstalled: false });
|
|
914
|
+
}
|
|
915
|
+
} catch (e) {
|
|
916
|
+
console.warn('[onboarding] pre-render sync skipped:', (e as Error).message);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const template = resolveTemplate(requestedSourceId, requestedDeptId);
|
|
920
|
+
|
|
921
|
+
// Expose the list of source ids the UI can switch between, so the
|
|
922
|
+
// wizard's tenant selector knows what's available. 'public' is always
|
|
923
|
+
// present implicitly; user override (config-template.json) shows only
|
|
924
|
+
// when present on disk.
|
|
925
|
+
const availableSources: Array<{ id: string; display_name: string; has_template: boolean }> = [];
|
|
926
|
+
try {
|
|
927
|
+
const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
|
|
928
|
+
const { existsSync } = await import('node:fs');
|
|
929
|
+
const { join } = await import('node:path');
|
|
930
|
+
const { getDataDir } = await import('@/lib/dirs');
|
|
931
|
+
const dd = getDataDir();
|
|
932
|
+
// Either shape counts as cached — legacy single file OR the
|
|
933
|
+
// multi-dept index (E3). Without this, sources that ship only
|
|
934
|
+
// wizards/ get marked has_template:false and disappear from the
|
|
935
|
+
// wizard's tenant selector.
|
|
936
|
+
const hasCache = (id: string) => {
|
|
937
|
+
const base = join(dd, 'connectors', 'sources', id);
|
|
938
|
+
return existsSync(join(base, 'wizard-template.json'))
|
|
939
|
+
|| existsSync(join(base, 'wizards', '_index.json'));
|
|
940
|
+
};
|
|
941
|
+
for (const s of listEnterpriseSourceMetas()) {
|
|
942
|
+
availableSources.push({ id: s.id, display_name: s.display_name, has_template: hasCache(s.id) });
|
|
943
|
+
}
|
|
944
|
+
availableSources.push({ id: 'public', display_name: 'Public', has_template: hasCache('public') });
|
|
945
|
+
if (existsSync(join(dd, 'config-template.json'))) {
|
|
946
|
+
availableSources.push({ id: 'user', display_name: 'User override', has_template: true });
|
|
947
|
+
}
|
|
948
|
+
} catch (e) {
|
|
949
|
+
console.warn('[onboarding] listing sources failed:', (e as Error).message);
|
|
950
|
+
}
|
|
951
|
+
// Which source actually fed the rendered template — null when neither
|
|
952
|
+
// the priority chain nor the requested id resolved (caller showed the
|
|
953
|
+
// bundled fallback).
|
|
954
|
+
const resolvedSource = resolveWizardTemplate(requestedSourceId, requestedDeptId)?.source ?? null;
|
|
955
|
+
|
|
956
|
+
// E3: dept list for the scoped source (when the user picked one or
|
|
957
|
+
// the chain resolved to one). The wizard renders the second-tier
|
|
958
|
+
// dropdown from this. Empty list = source has only a single template,
|
|
959
|
+
// no dept picker shown.
|
|
960
|
+
let availableDepartments: Array<{ id: string; display_name: string }> = [];
|
|
961
|
+
let resolvedDept: string | null = null;
|
|
962
|
+
try {
|
|
963
|
+
const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
|
|
964
|
+
const scopedSource = requestedSourceId || resolvedSource;
|
|
965
|
+
if (scopedSource && scopedSource !== 'user') {
|
|
966
|
+
availableDepartments = listSourceDepartments(scopedSource);
|
|
967
|
+
if (availableDepartments.length > 0) {
|
|
968
|
+
if (requestedDeptId && availableDepartments.some(d => d.id === requestedDeptId)) {
|
|
969
|
+
resolvedDept = requestedDeptId;
|
|
970
|
+
} else {
|
|
971
|
+
// Default to the user's profile dept (settings.dept) so the
|
|
972
|
+
// wizard re-opens on the last template they picked. Falls
|
|
973
|
+
// through to installed_dept stamp, then to entry #0.
|
|
974
|
+
const profileDept = (settings.dept || '').trim();
|
|
975
|
+
const stampDept = listInstalledConnectors()
|
|
976
|
+
.map(c => c.installed_dept)
|
|
977
|
+
.find((d): d is string => typeof d === 'string' && !!d);
|
|
978
|
+
const lookup = profileDept || stampDept;
|
|
979
|
+
const matched = lookup
|
|
980
|
+
? availableDepartments.find(d => d.display_name === lookup || d.id === lookup.toLowerCase())
|
|
981
|
+
: undefined;
|
|
982
|
+
resolvedDept = matched ? matched.id : availableDepartments[0].id;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
} catch (e) {
|
|
987
|
+
console.warn('[onboarding] listing depts failed:', (e as Error).message);
|
|
988
|
+
}
|
|
275
989
|
|
|
276
990
|
// Suggest fortinet-* pipelines from the marketplace as default-selected.
|
|
277
991
|
let suggested_pipelines: string[] = [];
|
|
@@ -317,23 +1031,93 @@ export async function GET() {
|
|
|
317
1031
|
// in template order. UI renders this as checkboxes — default all
|
|
318
1032
|
// selected, user can opt out before Apply. `default_enabled` mirrors
|
|
319
1033
|
// the template's `enabled:` (e.g. fortincm is opt-in).
|
|
1034
|
+
//
|
|
1035
|
+
// `defaults` exposes the template's baked field values so the wizard
|
|
1036
|
+
// can show them before Apply (otherwise users only see what the
|
|
1037
|
+
// template ships *after* installation in Settings). Rules:
|
|
1038
|
+
// - skip ${...} placeholder values (those go through the prompt
|
|
1039
|
+
// inputs in section 3, no point listing them twice)
|
|
1040
|
+
// - mask secrets: field type=secret OR value starts with ent:/enc:
|
|
1041
|
+
// - resolve {user.*} + {dept.*} so users see the literal that will
|
|
1042
|
+
// land, e.g. "username: zliu" not "username: {user.login}"
|
|
1043
|
+
// - flatten `type: instances` rows into "<name>.<field>" entries
|
|
1044
|
+
const previewUserIdent = (() => {
|
|
1045
|
+
const emailLocal = (settings.displayEmail || '').split('@')[0] || '';
|
|
1046
|
+
const niceName = (settings.displayName && settings.displayName.trim() !== 'Forge')
|
|
1047
|
+
? settings.displayName.trim() : '';
|
|
1048
|
+
return {
|
|
1049
|
+
name: niceName || emailLocal,
|
|
1050
|
+
email: settings.displayEmail || '',
|
|
1051
|
+
login: emailLocal || niceName,
|
|
1052
|
+
};
|
|
1053
|
+
})();
|
|
1054
|
+
const previewDeptIdent = (() => {
|
|
1055
|
+
const n = typeof (template as any)?._department_name === 'string'
|
|
1056
|
+
? String((template as any)._department_name).trim() : '';
|
|
1057
|
+
return n ? { name: n } : undefined;
|
|
1058
|
+
})();
|
|
1059
|
+
function previewResolve(raw: unknown): string {
|
|
1060
|
+
if (typeof raw !== 'string') return String(raw);
|
|
1061
|
+
let out = raw.replace(/\$\{[a-zA-Z0-9_]+\}/g, ''); // strip leftover prompts
|
|
1062
|
+
out = out.replace(/\{user\.(name|email|login)\}/g, (_, f) => (previewUserIdent as any)[f] || '');
|
|
1063
|
+
if (previewDeptIdent) out = out.replace(/\{dept\.name\}/g, previewDeptIdent.name);
|
|
1064
|
+
return out;
|
|
1065
|
+
}
|
|
1066
|
+
function isPlaceholder(v: unknown): boolean {
|
|
1067
|
+
return typeof v === 'string' && /\$\{[a-zA-Z0-9_]+\}/.test(v);
|
|
1068
|
+
}
|
|
1069
|
+
function isMaskedSecret(v: unknown): boolean {
|
|
1070
|
+
return typeof v === 'string' && (v.startsWith('ent:') || v.startsWith('enc:'));
|
|
1071
|
+
}
|
|
1072
|
+
|
|
320
1073
|
const templateConnectors: Array<{
|
|
321
1074
|
id: string;
|
|
322
1075
|
default_enabled: boolean;
|
|
323
1076
|
has_prompts: boolean;
|
|
324
1077
|
already_installed: boolean;
|
|
1078
|
+
defaults: Array<{ field: string; value: string; is_secret: boolean }>;
|
|
325
1079
|
}> = [];
|
|
326
1080
|
for (const [connId, row] of Object.entries(template)) {
|
|
327
1081
|
if (connId.startsWith('_')) continue;
|
|
328
1082
|
const cfg = (row as any)?.config ?? {};
|
|
329
|
-
const hasPrompts = Object.values(cfg).some(
|
|
330
|
-
|
|
331
|
-
);
|
|
1083
|
+
const hasPrompts = Object.values(cfg).some(v => isPlaceholder(v));
|
|
1084
|
+
|
|
1085
|
+
const def = getConnector(connId);
|
|
1086
|
+
const defaults: Array<{ field: string; value: string; is_secret: boolean }> = [];
|
|
1087
|
+
for (const [field, rawVal] of Object.entries(cfg)) {
|
|
1088
|
+
if (isPlaceholder(rawVal)) continue;
|
|
1089
|
+
const fieldDef = (def?.settings as any)?.[field];
|
|
1090
|
+
const declaredSecret = fieldDef?.type === 'secret';
|
|
1091
|
+
if (fieldDef?.type === 'instances') {
|
|
1092
|
+
let rows: any[] = [];
|
|
1093
|
+
if (Array.isArray(rawVal)) rows = rawVal;
|
|
1094
|
+
else if (typeof rawVal === 'string') {
|
|
1095
|
+
try { rows = JSON.parse(rawVal); } catch { rows = []; }
|
|
1096
|
+
}
|
|
1097
|
+
for (const inst of rows) {
|
|
1098
|
+
if (!inst || typeof inst !== 'object') continue;
|
|
1099
|
+
const name = String((inst as any).name || '<unnamed>');
|
|
1100
|
+
for (const [k, v] of Object.entries(inst as Record<string, unknown>)) {
|
|
1101
|
+
if (k === 'name') continue;
|
|
1102
|
+
if (isPlaceholder(v)) continue;
|
|
1103
|
+
const instFieldSecret = name.toLowerCase().includes('token') || k.toLowerCase().includes('token') || k.toLowerCase().includes('password') || isMaskedSecret(v);
|
|
1104
|
+
const valueStr = isMaskedSecret(v) ? '••••••••' : previewResolve(v);
|
|
1105
|
+
defaults.push({ field: `${name}.${k}`, value: valueStr, is_secret: instFieldSecret });
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const isSecret = declaredSecret || isMaskedSecret(rawVal);
|
|
1111
|
+
const valueStr = isSecret ? '••••••••' : previewResolve(rawVal);
|
|
1112
|
+
defaults.push({ field, value: valueStr, is_secret: isSecret });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
332
1115
|
templateConnectors.push({
|
|
333
1116
|
id: connId,
|
|
334
1117
|
default_enabled: (row as any)?.enabled !== false,
|
|
335
1118
|
has_prompts: hasPrompts,
|
|
336
1119
|
already_installed: !!getInstalledConnector(connId),
|
|
1120
|
+
defaults,
|
|
337
1121
|
});
|
|
338
1122
|
}
|
|
339
1123
|
|
|
@@ -366,6 +1150,63 @@ export async function GET() {
|
|
|
366
1150
|
}
|
|
367
1151
|
}
|
|
368
1152
|
|
|
1153
|
+
// ─── Template previews — what _agents / _apiProfiles / _derive will do ──
|
|
1154
|
+
//
|
|
1155
|
+
// Lets the wizard collapse hardcoded "Chat API key" / "CLI Agent" sections
|
|
1156
|
+
// when the template provides them, and show a one-glance "this is what
|
|
1157
|
+
// will be installed" banner.
|
|
1158
|
+
const agentsPreview: Array<{ id: string; tool?: string; has_secret: boolean }> = [];
|
|
1159
|
+
if (template._agents && typeof template._agents === 'object') {
|
|
1160
|
+
for (const [id, raw] of Object.entries(template._agents as Record<string, any>)) {
|
|
1161
|
+
const env = (raw?.env ?? {}) as Record<string, unknown>;
|
|
1162
|
+
const has_secret = Object.values(env).some((v) => typeof v === 'string' && v.startsWith('ent:'));
|
|
1163
|
+
agentsPreview.push({ id, tool: raw?.tool, has_secret });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const apiProfilesPreview: Array<{ id: string; provider?: string; model?: string; has_secret: boolean }> = [];
|
|
1167
|
+
if (template._apiProfiles && typeof template._apiProfiles === 'object') {
|
|
1168
|
+
for (const [id, raw] of Object.entries(template._apiProfiles as Record<string, any>)) {
|
|
1169
|
+
const has_secret = typeof raw?.apiKey === 'string' && raw.apiKey.startsWith('ent:');
|
|
1170
|
+
apiProfilesPreview.push({ id, provider: raw?.provider, model: raw?.model, has_secret });
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const deriveKeys = template._derive ? Object.keys(template._derive as object) : [];
|
|
1174
|
+
|
|
1175
|
+
// Memory auto-provision hint — pulled exclusively from the template's
|
|
1176
|
+
// `_temperAdmin: { url, token }` block. UI uses this to render the
|
|
1177
|
+
// "memory will be auto-provisioned via Temper" banner. Admin token never
|
|
1178
|
+
// leaves the server; only `url` is exposed. `provisioned` reflects whether
|
|
1179
|
+
// the user already has a personal temperKey (= Apply is a no-op).
|
|
1180
|
+
const tplAdmin = (template._temperAdmin ?? {}) as { url?: string; token?: unknown };
|
|
1181
|
+
const adminUrl = typeof tplAdmin.url === 'string' ? tplAdmin.url : '';
|
|
1182
|
+
const adminTokenPresent = !!(tplAdmin.token && (typeof tplAdmin.token === 'string' || typeof tplAdmin.token === 'object'));
|
|
1183
|
+
const memoryAutoProvision = adminTokenPresent && !!adminUrl ? {
|
|
1184
|
+
url: adminUrl,
|
|
1185
|
+
provisioned: !!settings.temperKey,
|
|
1186
|
+
} : undefined;
|
|
1187
|
+
|
|
1188
|
+
// Wizard layout knobs the template can flip to hide noise. `minimal`
|
|
1189
|
+
// collapses everything but Identity + required prompts + preview;
|
|
1190
|
+
// useful for enterprise templates that bake nearly all defaults.
|
|
1191
|
+
const wizardKnobs = (template._wizard ?? {}) as {
|
|
1192
|
+
minimal?: boolean;
|
|
1193
|
+
hide_connectors?: boolean;
|
|
1194
|
+
hide_pipelines?: boolean;
|
|
1195
|
+
required_only?: boolean;
|
|
1196
|
+
};
|
|
1197
|
+
// `minimal` implies hiding pipelines and filtering to required-only prompts,
|
|
1198
|
+
// but NOT hiding all connector cards — required prompts still need to be
|
|
1199
|
+
// visible so the user can enter their gitlab PAT / jenkins token. The
|
|
1200
|
+
// wizard UI already drops cards that have zero user-visible prompts under
|
|
1201
|
+
// required_only, so explicit `hide_connectors` is only for the rare case
|
|
1202
|
+
// where the template literally has no required prompts.
|
|
1203
|
+
const wizardLayout = {
|
|
1204
|
+
minimal: !!wizardKnobs.minimal,
|
|
1205
|
+
hide_connectors: !!wizardKnobs.hide_connectors,
|
|
1206
|
+
hide_pipelines: !!(wizardKnobs.hide_pipelines ?? wizardKnobs.minimal),
|
|
1207
|
+
required_only: !!(wizardKnobs.required_only ?? wizardKnobs.minimal),
|
|
1208
|
+
};
|
|
1209
|
+
|
|
369
1210
|
return NextResponse.json({
|
|
370
1211
|
ok: true,
|
|
371
1212
|
onboardingCompleted: !!settings.onboardingCompleted,
|
|
@@ -382,9 +1223,31 @@ export async function GET() {
|
|
|
382
1223
|
detected_cli: detected,
|
|
383
1224
|
template_connectors: templateConnectors,
|
|
384
1225
|
template_prompts: template._prompts || {},
|
|
1226
|
+
template_agents_preview: agentsPreview.length ? agentsPreview : undefined,
|
|
1227
|
+
template_api_profiles_preview: apiProfilesPreview.length ? apiProfilesPreview : undefined,
|
|
1228
|
+
template_derive_keys: deriveKeys.length ? deriveKeys : undefined,
|
|
1229
|
+
template_enterprise_name: typeof template._enterprise_name === 'string' ? template._enterprise_name : undefined,
|
|
1230
|
+
template_wizard: wizardLayout,
|
|
1231
|
+
memory_auto_provision: memoryAutoProvision,
|
|
385
1232
|
prompt_values_set: promptValuesSet,
|
|
386
1233
|
prompt_targets: promptTargets,
|
|
387
1234
|
suggested_pipelines,
|
|
1235
|
+
// E2: tenant scope info — `available_sources` powers the wizard's
|
|
1236
|
+
// top-bar tenant selector; `resolved_source` is the source id that
|
|
1237
|
+
// actually fed the rendered template (used to highlight the active
|
|
1238
|
+
// entry).
|
|
1239
|
+
available_sources: availableSources,
|
|
1240
|
+
resolved_source: resolvedSource,
|
|
1241
|
+
requested_source_id: requestedSourceId ?? null,
|
|
1242
|
+
// E3: per-dept list for the scoped source. Wizard renders second
|
|
1243
|
+
// dropdown from this; empty = single-template source, no picker.
|
|
1244
|
+
available_departments: availableDepartments,
|
|
1245
|
+
resolved_dept: resolvedDept,
|
|
1246
|
+
requested_dept_id: requestedDeptId ?? null,
|
|
1247
|
+
// Template's self-declared dept name (when present) — used as the
|
|
1248
|
+
// {dept.name} substitution and surfaced in the UI breadcrumb.
|
|
1249
|
+
template_department_name:
|
|
1250
|
+
typeof template._department_name === 'string' ? template._department_name : undefined,
|
|
388
1251
|
});
|
|
389
1252
|
}
|
|
390
1253
|
|
|
@@ -431,12 +1294,23 @@ export async function POST(req: Request) {
|
|
|
431
1294
|
}
|
|
432
1295
|
|
|
433
1296
|
try {
|
|
434
|
-
const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors);
|
|
1297
|
+
const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors, payload.sourceId, payload.deptId);
|
|
435
1298
|
phases.push({ phase: 'connectors', ok: true, detail: r });
|
|
436
1299
|
} catch (e) {
|
|
437
1300
|
phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
438
1301
|
}
|
|
439
1302
|
|
|
1303
|
+
// Memory auto-provision — runs after applyConnectors so settings.company /
|
|
1304
|
+
// .dept are stamped. No-ops when the template ships no _temperAdmin block
|
|
1305
|
+
// or the user already has a temperKey.
|
|
1306
|
+
try {
|
|
1307
|
+
const r = await applyTemperAutoProvision(payload.sourceId, payload.deptId);
|
|
1308
|
+
const failed = typeof r === 'object' && r !== null && 'error' in r;
|
|
1309
|
+
phases.push({ phase: 'temperProvision', ok: !failed, detail: r });
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
phases.push({ phase: 'temperProvision', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1312
|
+
}
|
|
1313
|
+
|
|
440
1314
|
try {
|
|
441
1315
|
const r = await applyPipelines(payload.pipelines);
|
|
442
1316
|
phases.push({ phase: 'pipelines', ok: r.errors.length === 0, detail: r });
|