@aion0/forge 0.10.40 → 0.10.42
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 +4 -7
- 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 +920 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/app/chat/page.tsx +8 -5
- package/bin/forge-server.mjs +98 -1
- 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/llm/anthropic.ts +6 -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,266 @@ 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 + 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.
|
|
461
|
+
const profilesApplied: string[] = [];
|
|
462
|
+
const profilesDict = template._apiProfiles as Record<string, any> | undefined;
|
|
463
|
+
if (profilesDict && typeof profilesDict === 'object') {
|
|
464
|
+
const fresh = loadSettings();
|
|
465
|
+
const current = { ...(fresh.apiProfiles || {}) };
|
|
466
|
+
let changed = false;
|
|
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
|
+
}
|
|
476
|
+
for (const [profileId, raw] of Object.entries(profilesDict)) {
|
|
477
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
478
|
+
const overwrite = (raw as any)._overwrite === true;
|
|
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
|
+
}
|
|
486
|
+
const { _overwrite, _default, ...entry } = raw as Record<string, unknown>;
|
|
487
|
+
current[profileId] = entry as unknown as ApiProfile;
|
|
488
|
+
existingByBackend.set(tplKey, profileId);
|
|
489
|
+
profilesApplied.push(profileId);
|
|
490
|
+
changed = true;
|
|
491
|
+
// First entry flagged `_default: true` (or the first applied entry
|
|
492
|
+
// when no flag is set anywhere) becomes the chat backend default —
|
|
493
|
+
// but only if the user hasn't already chosen one.
|
|
494
|
+
if (_default === true && !defaultPick) defaultPick = profileId;
|
|
495
|
+
}
|
|
496
|
+
if (changed) {
|
|
497
|
+
const nextSettings = { ...fresh, apiProfiles: current };
|
|
498
|
+
// Pick a default chatAgent if user has none yet. Either honour an
|
|
499
|
+
// explicit `_default: true` flag, or fall back to the first applied
|
|
500
|
+
// profile so chat works out of the box.
|
|
501
|
+
if (!fresh.chatAgent && (defaultPick || profilesApplied[0])) {
|
|
502
|
+
nextSettings.chatAgent = defaultPick || profilesApplied[0];
|
|
503
|
+
}
|
|
504
|
+
saveSettings(nextSettings);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return { template, values, agentsApplied, profilesApplied };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Exported so /api/enterprise-keys can re-apply the template's literal
|
|
512
|
+
// defaults (new fields added in a template update, e.g. default_job_path
|
|
513
|
+
// / default_project_path) after a Reinstall without forcing the user
|
|
514
|
+
// to re-run the entire wizard. Existing non-empty values are preserved
|
|
515
|
+
// — see the merge logic inside.
|
|
516
|
+
export async function applyConnectors(
|
|
163
517
|
values: Record<string, string> | undefined,
|
|
164
518
|
selectedConnectors: string[] | undefined,
|
|
519
|
+
sourceId?: string,
|
|
520
|
+
deptId?: string,
|
|
521
|
+
opts: { forceAll?: boolean } = {},
|
|
165
522
|
): Promise<{
|
|
166
523
|
applied: string[];
|
|
167
524
|
installed_from_registry: string[];
|
|
168
525
|
skipped_missing_manifest: string[];
|
|
169
526
|
skipped_unselected: string[];
|
|
170
527
|
fields_preserved: Array<{ connector: string; field: string }>;
|
|
528
|
+
agents_applied?: string[];
|
|
529
|
+
api_profiles_applied?: string[];
|
|
171
530
|
}> {
|
|
172
|
-
const
|
|
531
|
+
const rawTemplate = resolveTemplate(sourceId, deptId);
|
|
173
532
|
// Undefined = install everything; explicit [] = install nothing.
|
|
174
533
|
const selected = selectedConnectors ? new Set(selectedConnectors) : null;
|
|
175
534
|
// Auto-inject user identity from settings so connectors (e.g. tp.username)
|
|
176
535
|
// can reference {user_name} / {user_email} without prompting again.
|
|
177
536
|
// User-supplied values still win over auto-injected ones.
|
|
178
537
|
const settings = loadSettings();
|
|
179
|
-
const
|
|
538
|
+
const initialValues: Record<string, string> = {
|
|
180
539
|
user_name: settings.displayName || '',
|
|
181
540
|
user_email: settings.displayEmail || '',
|
|
182
541
|
...(values || {}),
|
|
183
542
|
};
|
|
543
|
+
// {user.X} ident — distinct from the legacy ${user_name} prompt key.
|
|
544
|
+
// Used by substituteAll to bake displayName/login into per-connector
|
|
545
|
+
// settings (e.g. Jenkins instances[].username) so the persisted config
|
|
546
|
+
// holds a literal value, not the curly-token placeholder.
|
|
547
|
+
//
|
|
548
|
+
// Fallback chain matters: a user who only filled in Display Name in
|
|
549
|
+
// the wizard (no email yet) still gets a usable login, instead of
|
|
550
|
+
// ending up with Jenkins.username="". And vice versa.
|
|
551
|
+
const emailLocal = (settings.displayEmail || '').split('@')[0] || '';
|
|
552
|
+
const niceName = (settings.displayName && settings.displayName.trim() !== 'Forge')
|
|
553
|
+
? settings.displayName.trim() : '';
|
|
554
|
+
const userIdent = {
|
|
555
|
+
name: niceName || emailLocal,
|
|
556
|
+
email: settings.displayEmail || '',
|
|
557
|
+
login: emailLocal || niceName,
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// E3: dept identity from the template's `_department_name`. Used by
|
|
561
|
+
// {dept.name} substitution so templates can write
|
|
562
|
+
// `default_project: "{dept.name}"` instead of hardcoding "FortiNAC".
|
|
563
|
+
// E5+: template-less depts have no `_department_name` — fall back to
|
|
564
|
+
// the dept index's display_name so the user's choice still flows
|
|
565
|
+
// through to installed_dept + {dept.name}.
|
|
566
|
+
let deptName = typeof (rawTemplate as any)?._department_name === 'string'
|
|
567
|
+
? String((rawTemplate as any)._department_name).trim() : '';
|
|
568
|
+
if (!deptName && sourceId && deptId) {
|
|
569
|
+
try {
|
|
570
|
+
const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
|
|
571
|
+
const match = listSourceDepartments(sourceId).find(d => d.id === deptId);
|
|
572
|
+
if (match) deptName = match.display_name;
|
|
573
|
+
} catch {}
|
|
574
|
+
}
|
|
575
|
+
const deptIdent = deptName ? { name: deptName } : undefined;
|
|
576
|
+
|
|
577
|
+
// _derive + ent: decryption + _agents/_apiProfiles apply happens here,
|
|
578
|
+
// regardless of which connectors the user selected. (Agents and API
|
|
579
|
+
// profiles belong to the user as a whole, not to a specific connector.)
|
|
580
|
+
const { template, values: subst, agentsApplied, profilesApplied } =
|
|
581
|
+
preprocessTemplate(rawTemplate, initialValues);
|
|
184
582
|
const applied: string[] = [];
|
|
185
583
|
const installedFromRegistry: string[] = [];
|
|
186
584
|
const missing: string[] = [];
|
|
@@ -208,10 +606,58 @@ async function applyConnectors(
|
|
|
208
606
|
const merged: Record<string, unknown> = { ...existing };
|
|
209
607
|
|
|
210
608
|
for (const [field, rawVal] of Object.entries(templateConfig)) {
|
|
211
|
-
const resolved = substituteAll(rawVal, subst);
|
|
609
|
+
const resolved = substituteAll(rawVal, subst, userIdent, deptIdent);
|
|
212
610
|
const existingVal = existing[field];
|
|
611
|
+
const fieldDef = (def.settings as any)?.[field];
|
|
612
|
+
|
|
613
|
+
// `type: instances` (multi-row settings, e.g. Jenkins instances)
|
|
614
|
+
// gets row-by-row merge by `name`. Existing rows are preserved
|
|
615
|
+
// (passwords stay, user edits stay); template rows whose name
|
|
616
|
+
// isn't already present get added. This means a template can
|
|
617
|
+
// bake a recommended instance (fortinac-jenkins / zliu) without
|
|
618
|
+
// clobbering whatever the user already configured.
|
|
619
|
+
if (fieldDef?.type === 'instances') {
|
|
620
|
+
// Two force-overwrite triggers per row+field:
|
|
621
|
+
// (a) Reinstall (`opts.forceAll`): every template field is canonical —
|
|
622
|
+
// this is what "Reinstall" means semantically (make config match
|
|
623
|
+
// template exactly, drop user drift).
|
|
624
|
+
// (b) Identity-derived fields ({user.*} / {dept.*} tokens in raw
|
|
625
|
+
// template): re-track the user's current identity even on a
|
|
626
|
+
// normal Apply, so displayEmail changes refresh derived values
|
|
627
|
+
// like jenkins username.
|
|
628
|
+
const forceMap = new Map<string, Set<string>>();
|
|
629
|
+
const rawRows = Array.isArray(rawVal) ? rawVal : [];
|
|
630
|
+
for (const r of rawRows) {
|
|
631
|
+
if (!r || typeof r !== 'object') continue;
|
|
632
|
+
const rowName = String((r as any).name ?? '').trim();
|
|
633
|
+
if (!rowName) continue;
|
|
634
|
+
const flagged = new Set<string>();
|
|
635
|
+
for (const [k, v] of Object.entries(r as Record<string, unknown>)) {
|
|
636
|
+
if (k === 'name') continue;
|
|
637
|
+
if (opts.forceAll) flagged.add(k);
|
|
638
|
+
else if (typeof v === 'string' && /\{(?:user|dept)\.[a-zA-Z_]+\}/.test(v)) flagged.add(k);
|
|
639
|
+
}
|
|
640
|
+
if (flagged.size > 0) forceMap.set(rowName, flagged);
|
|
641
|
+
}
|
|
642
|
+
const mergedRows = mergeInstancesByName(existingVal, resolved, forceMap);
|
|
643
|
+
if (mergedRows.changed) merged[field] = mergedRows.value;
|
|
644
|
+
else if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
|
|
645
|
+
preserved.push({ connector: id, field });
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Existing values that still carry `${...}` placeholders are
|
|
651
|
+
// half-applied from an earlier wizard run (e.g. a template
|
|
652
|
+
// referenced ${jenkins_username} before we baked 'zliu' as the
|
|
653
|
+
// default). Treat them as empty so the new template wins —
|
|
654
|
+
// otherwise a re-run preserves the broken half instead of fixing
|
|
655
|
+
// it. `TODO_` is the legacy placeholder convention.
|
|
656
|
+
const existingHasStalePlaceholder = typeof existingVal === 'string'
|
|
657
|
+
&& /\$\{[a-zA-Z0-9_]+\}/.test(existingVal);
|
|
213
658
|
const existingNonEmpty = !isEffectivelyEmpty(existingVal)
|
|
214
|
-
&& !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'))
|
|
659
|
+
&& !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'))
|
|
660
|
+
&& !existingHasStalePlaceholder;
|
|
215
661
|
if (existingNonEmpty) {
|
|
216
662
|
if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
|
|
217
663
|
preserved.push({ connector: id, field });
|
|
@@ -224,6 +670,43 @@ async function applyConnectors(
|
|
|
224
670
|
setConnectorConfig(id, merged);
|
|
225
671
|
applied.push(id);
|
|
226
672
|
if (typeof enabledTemplate === 'boolean') setConnectorEnabled(id, enabledTemplate);
|
|
673
|
+
// E3: remember which dept's template owns this row so a future
|
|
674
|
+
// wizard re-run lands on the same dept by default. Unsetting when
|
|
675
|
+
// there's no _department_name (public/single-template tier) keeps
|
|
676
|
+
// the row honest.
|
|
677
|
+
setConnectorDept(id, deptIdent?.name);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Stamp company + dept onto the user profile so Settings → Identity
|
|
681
|
+
// and the Dashboard prefix show "Fortinet · FortiADC" without having
|
|
682
|
+
// to scan installed_dept off every connector row.
|
|
683
|
+
//
|
|
684
|
+
// Source for company:
|
|
685
|
+
// 1. The enterprise source's display_name (e.g. "Fortinet") when
|
|
686
|
+
// sourceId is known — case-correct, matches the dropdown's
|
|
687
|
+
// option values.
|
|
688
|
+
// 2. Fallback to the template's _enterprise_name (e.g. "fortinet")
|
|
689
|
+
// which may be lower-case from the template author.
|
|
690
|
+
try {
|
|
691
|
+
let companyName = '';
|
|
692
|
+
if (sourceId) {
|
|
693
|
+
try {
|
|
694
|
+
const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
|
|
695
|
+
const src = listEnterpriseSourceMetas().find(m => m.id === sourceId);
|
|
696
|
+
if (src?.display_name) companyName = src.display_name;
|
|
697
|
+
} catch {}
|
|
698
|
+
}
|
|
699
|
+
if (!companyName && typeof (rawTemplate as any)?._enterprise_name === 'string') {
|
|
700
|
+
companyName = String((rawTemplate as any)._enterprise_name).trim();
|
|
701
|
+
}
|
|
702
|
+
if (companyName || deptName) {
|
|
703
|
+
const s = loadSettings();
|
|
704
|
+
if (companyName) s.company = companyName;
|
|
705
|
+
if (deptName) s.dept = deptName;
|
|
706
|
+
saveSettings(s);
|
|
707
|
+
}
|
|
708
|
+
} catch (e) {
|
|
709
|
+
console.warn('[onboarding] profile stamp failed:', (e as Error).message);
|
|
227
710
|
}
|
|
228
711
|
|
|
229
712
|
return {
|
|
@@ -232,6 +715,126 @@ async function applyConnectors(
|
|
|
232
715
|
skipped_missing_manifest: missing,
|
|
233
716
|
skipped_unselected: skippedUnselected,
|
|
234
717
|
fields_preserved: preserved,
|
|
718
|
+
agents_applied: agentsApplied.length ? agentsApplied : undefined,
|
|
719
|
+
api_profiles_applied: profilesApplied.length ? profilesApplied : undefined,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Install the pipelines listed in the wizard template's `_pipelines: [name…]`
|
|
725
|
+
* block. Used by Reinstall so connectors and pipelines have the same default
|
|
726
|
+
* behavior — both auto-land from the template when the user re-pulls. If the
|
|
727
|
+
* template omits `_pipelines`, falls back to `suggested_pipelines` (the
|
|
728
|
+
* marketplace's fortinet-* prefix heuristic).
|
|
729
|
+
*/
|
|
730
|
+
export async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
|
|
731
|
+
installed: string[];
|
|
732
|
+
errors: Array<{ name: string; error: string }>;
|
|
733
|
+
}> {
|
|
734
|
+
const tpl = resolveTemplate(sourceId, deptId) as any;
|
|
735
|
+
let names: string[] | undefined = Array.isArray(tpl?._pipelines)
|
|
736
|
+
? (tpl._pipelines as any[]).filter((n): n is string => typeof n === 'string' && !!n)
|
|
737
|
+
: undefined;
|
|
738
|
+
if (!names || names.length === 0) {
|
|
739
|
+
try {
|
|
740
|
+
const m = listMarketplace();
|
|
741
|
+
names = (m.pipelines || [])
|
|
742
|
+
.filter((e: any) => e.name?.startsWith('fortinet-'))
|
|
743
|
+
.map((e: any) => e.name);
|
|
744
|
+
} catch { names = []; }
|
|
745
|
+
}
|
|
746
|
+
return applyPipelines(names);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Auto-provision a per-user Temper memory account.
|
|
751
|
+
*
|
|
752
|
+
* Template-driven, one-shot. The wizard template's `_temperAdmin: { url,
|
|
753
|
+
* token: 'ent:…' }` block (encrypted with the enterprise key) is decrypted
|
|
754
|
+
* here and used to call Temper's `/v1/onboarding/provision`. The admin
|
|
755
|
+
* token NEVER persists to settings — it's a wizard-only side door for
|
|
756
|
+
* initialization. Different depts can omit the block to opt out, or swap
|
|
757
|
+
* for their own provisioning hooks later.
|
|
758
|
+
*
|
|
759
|
+
* Persisted on success: `temperUrl` + `temperKey` (the user's mk_… key) +
|
|
760
|
+
* `temperNamespace` (= org_slug). `memoryBackend` stays whatever the user
|
|
761
|
+
* picked (default 'auto' Temper-when-URL+key).
|
|
762
|
+
*
|
|
763
|
+
* Re-run is a no-op when `temperKey` is already set — re-provisioning a
|
|
764
|
+
* user requires clearing the key first via Settings → Memory.
|
|
765
|
+
*/
|
|
766
|
+
async function applyTemperAutoProvision(
|
|
767
|
+
sourceId: string | undefined,
|
|
768
|
+
deptId: string | undefined,
|
|
769
|
+
): Promise<Record<string, unknown>> {
|
|
770
|
+
const rawTpl = resolveTemplate(sourceId, deptId) as any;
|
|
771
|
+
const entName = typeof rawTpl?._enterprise_name === 'string' ? rawTpl._enterprise_name : '';
|
|
772
|
+
const decryptedTpl = entName ? (decryptDeep(rawTpl, entName) as any) : rawTpl;
|
|
773
|
+
const tplAdmin = (decryptedTpl?._temperAdmin ?? {}) as { url?: string; token?: string };
|
|
774
|
+
|
|
775
|
+
const adminToken = typeof tplAdmin.token === 'string' ? tplAdmin.token : '';
|
|
776
|
+
const adminUrl = typeof tplAdmin.url === 'string' ? tplAdmin.url : '';
|
|
777
|
+
|
|
778
|
+
if (!adminToken || !adminUrl) {
|
|
779
|
+
return { skipped: 'no_admin_in_template' };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const settings = loadSettings();
|
|
783
|
+
if (settings.temperKey) {
|
|
784
|
+
return { skipped: 'already_provisioned' };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const email = (settings.displayEmail || '').trim();
|
|
788
|
+
const company = (settings.company || '').trim();
|
|
789
|
+
const dept = (settings.dept || '').trim();
|
|
790
|
+
if (!email || !company || !dept) {
|
|
791
|
+
return {
|
|
792
|
+
skipped: 'identity_incomplete',
|
|
793
|
+
missing: { email: !email, company: !company, dept: !dept },
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const username = email.split('@')[0];
|
|
798
|
+
const niceName = settings.displayName && settings.displayName.trim() !== 'Forge'
|
|
799
|
+
? settings.displayName.trim()
|
|
800
|
+
: username;
|
|
801
|
+
|
|
802
|
+
const result = await provisionTemperUser({
|
|
803
|
+
url: adminUrl,
|
|
804
|
+
adminToken,
|
|
805
|
+
username,
|
|
806
|
+
email,
|
|
807
|
+
company,
|
|
808
|
+
dept,
|
|
809
|
+
displayName: niceName,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
if (!result.ok) {
|
|
813
|
+
// 409 = user already exists in Temper. We don't have their existing
|
|
814
|
+
// mk_ key (the admin endpoint mints on first-create only), so the
|
|
815
|
+
// Forge admin has to recover it out-of-band. Don't fail the wizard
|
|
816
|
+
// — just surface the conflict.
|
|
817
|
+
return {
|
|
818
|
+
error: result.error,
|
|
819
|
+
status: result.status,
|
|
820
|
+
...(result.conflict ? { conflict: true } : {}),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Persist user-scoped creds only. Admin token stays out of settings —
|
|
825
|
+
// re-runs read it from the template again on demand.
|
|
826
|
+
const s2 = loadSettings();
|
|
827
|
+
s2.temperUrl = adminUrl;
|
|
828
|
+
s2.temperKey = result.api_key;
|
|
829
|
+
s2.temperNamespace = result.org_slug || s2.temperNamespace || '';
|
|
830
|
+
saveSettings(s2);
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
provisioned: true,
|
|
834
|
+
user_id: result.user_id,
|
|
835
|
+
username: result.username,
|
|
836
|
+
org_slug: result.org_slug,
|
|
837
|
+
group_slug: result.group_slug,
|
|
235
838
|
};
|
|
236
839
|
}
|
|
237
840
|
|
|
@@ -269,9 +872,143 @@ function applyProjectRoots(paths: string[] | undefined): string | null {
|
|
|
269
872
|
|
|
270
873
|
// ─── Routes ──────────────────────────────────────────────────
|
|
271
874
|
|
|
272
|
-
export async function GET() {
|
|
875
|
+
export async function GET(req: Request) {
|
|
273
876
|
const settings = loadSettings();
|
|
274
|
-
|
|
877
|
+
// E2: optional ?source_id=<id> scopes the wizard to a single tenant's
|
|
878
|
+
// template. Without it, the priority chain (user → enterprise → public)
|
|
879
|
+
// resolves the default. The UI passes source_id via the tenant
|
|
880
|
+
// selector at the top of the wizard, or after a fresh add-key handoff.
|
|
881
|
+
const url = new URL(req.url);
|
|
882
|
+
let requestedSourceId = (url.searchParams.get('source_id') || '').trim() || undefined;
|
|
883
|
+
// E3: dept selector scopes further into a specific department template
|
|
884
|
+
// when the source ships `wizard_templates: [...]`. Omitted = use the
|
|
885
|
+
// source's first dept as default (resolveWizardTemplate handles).
|
|
886
|
+
let requestedDeptId = (url.searchParams.get('dept') || '').trim() || undefined;
|
|
887
|
+
|
|
888
|
+
// Profile defaults: if the URL doesn't pin a source/dept, treat
|
|
889
|
+
// settings.company + settings.dept as the requested scope. Matches
|
|
890
|
+
// by display_name (case-insensitive). No match → leave undefined and
|
|
891
|
+
// the priority chain takes over (which lands on public when nothing
|
|
892
|
+
// else fits). Simple, no inference, no derivation.
|
|
893
|
+
if (!requestedSourceId && settings.company) {
|
|
894
|
+
try {
|
|
895
|
+
const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
|
|
896
|
+
const want = settings.company.trim().toLowerCase();
|
|
897
|
+
const m = listEnterpriseSourceMetas().find(s => s.display_name.toLowerCase() === want);
|
|
898
|
+
if (m) requestedSourceId = m.id;
|
|
899
|
+
} catch {}
|
|
900
|
+
}
|
|
901
|
+
if (!requestedDeptId && requestedSourceId && settings.dept) {
|
|
902
|
+
try {
|
|
903
|
+
const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
|
|
904
|
+
const want = settings.dept.trim().toLowerCase();
|
|
905
|
+
const d = listSourceDepartments(requestedSourceId).find(x => x.display_name.toLowerCase() === want);
|
|
906
|
+
if (d) requestedDeptId = d.id;
|
|
907
|
+
} catch {}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// First-launch race fix: if the user has enterprise sources configured
|
|
911
|
+
// but none of them has a wizard-template.json cached yet, sync now
|
|
912
|
+
// (synchronously) before resolving the template. Otherwise the wizard
|
|
913
|
+
// would render against the public/bundled template with all the wrong
|
|
914
|
+
// defaults — exactly the issue users hit when they boot a fresh data
|
|
915
|
+
// dir with --enterprise-key set. Best-effort: any sync failure (PAT
|
|
916
|
+
// wrong, network down) just falls through to the existing fallback
|
|
917
|
+
// chain so the wizard at least loads.
|
|
918
|
+
try {
|
|
919
|
+
const { listEnterpriseSourceMetas, syncRegistry } = await import('@/lib/connectors/sync');
|
|
920
|
+
const { existsSync } = await import('node:fs');
|
|
921
|
+
const { join } = await import('node:path');
|
|
922
|
+
const { getDataDir } = await import('@/lib/dirs');
|
|
923
|
+
const sources = listEnterpriseSourceMetas();
|
|
924
|
+
// E3: a source counts as cached if EITHER the legacy single
|
|
925
|
+
// wizard-template.json OR the multi-dept wizards/_index.json is
|
|
926
|
+
// on disk. Without this second check, fortinet (which post-E5
|
|
927
|
+
// ships only wizards/) would re-trigger sync on every onboarding
|
|
928
|
+
// GET because the legacy file never lands.
|
|
929
|
+
const anyCached = sources.some((s: any) => {
|
|
930
|
+
const base = join(getDataDir(), 'connectors', 'sources', s.id);
|
|
931
|
+
return existsSync(join(base, 'wizard-template.json'))
|
|
932
|
+
|| existsSync(join(base, 'wizards', '_index.json'));
|
|
933
|
+
});
|
|
934
|
+
if (sources.length > 0 && !anyCached) {
|
|
935
|
+
console.log('[onboarding] first-run enterprise sync (pulling templates)…');
|
|
936
|
+
await syncRegistry({ refreshInstalled: false });
|
|
937
|
+
}
|
|
938
|
+
} catch (e) {
|
|
939
|
+
console.warn('[onboarding] pre-render sync skipped:', (e as Error).message);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const template = resolveTemplate(requestedSourceId, requestedDeptId);
|
|
943
|
+
|
|
944
|
+
// Expose the list of source ids the UI can switch between, so the
|
|
945
|
+
// wizard's tenant selector knows what's available. 'public' is always
|
|
946
|
+
// present implicitly; user override (config-template.json) shows only
|
|
947
|
+
// when present on disk.
|
|
948
|
+
const availableSources: Array<{ id: string; display_name: string; has_template: boolean }> = [];
|
|
949
|
+
try {
|
|
950
|
+
const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
|
|
951
|
+
const { existsSync } = await import('node:fs');
|
|
952
|
+
const { join } = await import('node:path');
|
|
953
|
+
const { getDataDir } = await import('@/lib/dirs');
|
|
954
|
+
const dd = getDataDir();
|
|
955
|
+
// Either shape counts as cached — legacy single file OR the
|
|
956
|
+
// multi-dept index (E3). Without this, sources that ship only
|
|
957
|
+
// wizards/ get marked has_template:false and disappear from the
|
|
958
|
+
// wizard's tenant selector.
|
|
959
|
+
const hasCache = (id: string) => {
|
|
960
|
+
const base = join(dd, 'connectors', 'sources', id);
|
|
961
|
+
return existsSync(join(base, 'wizard-template.json'))
|
|
962
|
+
|| existsSync(join(base, 'wizards', '_index.json'));
|
|
963
|
+
};
|
|
964
|
+
for (const s of listEnterpriseSourceMetas()) {
|
|
965
|
+
availableSources.push({ id: s.id, display_name: s.display_name, has_template: hasCache(s.id) });
|
|
966
|
+
}
|
|
967
|
+
availableSources.push({ id: 'public', display_name: 'Public', has_template: hasCache('public') });
|
|
968
|
+
if (existsSync(join(dd, 'config-template.json'))) {
|
|
969
|
+
availableSources.push({ id: 'user', display_name: 'User override', has_template: true });
|
|
970
|
+
}
|
|
971
|
+
} catch (e) {
|
|
972
|
+
console.warn('[onboarding] listing sources failed:', (e as Error).message);
|
|
973
|
+
}
|
|
974
|
+
// Which source actually fed the rendered template — null when neither
|
|
975
|
+
// the priority chain nor the requested id resolved (caller showed the
|
|
976
|
+
// bundled fallback).
|
|
977
|
+
const resolvedSource = resolveWizardTemplate(requestedSourceId, requestedDeptId)?.source ?? null;
|
|
978
|
+
|
|
979
|
+
// E3: dept list for the scoped source (when the user picked one or
|
|
980
|
+
// the chain resolved to one). The wizard renders the second-tier
|
|
981
|
+
// dropdown from this. Empty list = source has only a single template,
|
|
982
|
+
// no dept picker shown.
|
|
983
|
+
let availableDepartments: Array<{ id: string; display_name: string }> = [];
|
|
984
|
+
let resolvedDept: string | null = null;
|
|
985
|
+
try {
|
|
986
|
+
const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
|
|
987
|
+
const scopedSource = requestedSourceId || resolvedSource;
|
|
988
|
+
if (scopedSource && scopedSource !== 'user') {
|
|
989
|
+
availableDepartments = listSourceDepartments(scopedSource);
|
|
990
|
+
if (availableDepartments.length > 0) {
|
|
991
|
+
if (requestedDeptId && availableDepartments.some(d => d.id === requestedDeptId)) {
|
|
992
|
+
resolvedDept = requestedDeptId;
|
|
993
|
+
} else {
|
|
994
|
+
// Default to the user's profile dept (settings.dept) so the
|
|
995
|
+
// wizard re-opens on the last template they picked. Falls
|
|
996
|
+
// through to installed_dept stamp, then to entry #0.
|
|
997
|
+
const profileDept = (settings.dept || '').trim();
|
|
998
|
+
const stampDept = listInstalledConnectors()
|
|
999
|
+
.map(c => c.installed_dept)
|
|
1000
|
+
.find((d): d is string => typeof d === 'string' && !!d);
|
|
1001
|
+
const lookup = profileDept || stampDept;
|
|
1002
|
+
const matched = lookup
|
|
1003
|
+
? availableDepartments.find(d => d.display_name === lookup || d.id === lookup.toLowerCase())
|
|
1004
|
+
: undefined;
|
|
1005
|
+
resolvedDept = matched ? matched.id : availableDepartments[0].id;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
console.warn('[onboarding] listing depts failed:', (e as Error).message);
|
|
1011
|
+
}
|
|
275
1012
|
|
|
276
1013
|
// Suggest fortinet-* pipelines from the marketplace as default-selected.
|
|
277
1014
|
let suggested_pipelines: string[] = [];
|
|
@@ -317,23 +1054,93 @@ export async function GET() {
|
|
|
317
1054
|
// in template order. UI renders this as checkboxes — default all
|
|
318
1055
|
// selected, user can opt out before Apply. `default_enabled` mirrors
|
|
319
1056
|
// the template's `enabled:` (e.g. fortincm is opt-in).
|
|
1057
|
+
//
|
|
1058
|
+
// `defaults` exposes the template's baked field values so the wizard
|
|
1059
|
+
// can show them before Apply (otherwise users only see what the
|
|
1060
|
+
// template ships *after* installation in Settings). Rules:
|
|
1061
|
+
// - skip ${...} placeholder values (those go through the prompt
|
|
1062
|
+
// inputs in section 3, no point listing them twice)
|
|
1063
|
+
// - mask secrets: field type=secret OR value starts with ent:/enc:
|
|
1064
|
+
// - resolve {user.*} + {dept.*} so users see the literal that will
|
|
1065
|
+
// land, e.g. "username: zliu" not "username: {user.login}"
|
|
1066
|
+
// - flatten `type: instances` rows into "<name>.<field>" entries
|
|
1067
|
+
const previewUserIdent = (() => {
|
|
1068
|
+
const emailLocal = (settings.displayEmail || '').split('@')[0] || '';
|
|
1069
|
+
const niceName = (settings.displayName && settings.displayName.trim() !== 'Forge')
|
|
1070
|
+
? settings.displayName.trim() : '';
|
|
1071
|
+
return {
|
|
1072
|
+
name: niceName || emailLocal,
|
|
1073
|
+
email: settings.displayEmail || '',
|
|
1074
|
+
login: emailLocal || niceName,
|
|
1075
|
+
};
|
|
1076
|
+
})();
|
|
1077
|
+
const previewDeptIdent = (() => {
|
|
1078
|
+
const n = typeof (template as any)?._department_name === 'string'
|
|
1079
|
+
? String((template as any)._department_name).trim() : '';
|
|
1080
|
+
return n ? { name: n } : undefined;
|
|
1081
|
+
})();
|
|
1082
|
+
function previewResolve(raw: unknown): string {
|
|
1083
|
+
if (typeof raw !== 'string') return String(raw);
|
|
1084
|
+
let out = raw.replace(/\$\{[a-zA-Z0-9_]+\}/g, ''); // strip leftover prompts
|
|
1085
|
+
out = out.replace(/\{user\.(name|email|login)\}/g, (_, f) => (previewUserIdent as any)[f] || '');
|
|
1086
|
+
if (previewDeptIdent) out = out.replace(/\{dept\.name\}/g, previewDeptIdent.name);
|
|
1087
|
+
return out;
|
|
1088
|
+
}
|
|
1089
|
+
function isPlaceholder(v: unknown): boolean {
|
|
1090
|
+
return typeof v === 'string' && /\$\{[a-zA-Z0-9_]+\}/.test(v);
|
|
1091
|
+
}
|
|
1092
|
+
function isMaskedSecret(v: unknown): boolean {
|
|
1093
|
+
return typeof v === 'string' && (v.startsWith('ent:') || v.startsWith('enc:'));
|
|
1094
|
+
}
|
|
1095
|
+
|
|
320
1096
|
const templateConnectors: Array<{
|
|
321
1097
|
id: string;
|
|
322
1098
|
default_enabled: boolean;
|
|
323
1099
|
has_prompts: boolean;
|
|
324
1100
|
already_installed: boolean;
|
|
1101
|
+
defaults: Array<{ field: string; value: string; is_secret: boolean }>;
|
|
325
1102
|
}> = [];
|
|
326
1103
|
for (const [connId, row] of Object.entries(template)) {
|
|
327
1104
|
if (connId.startsWith('_')) continue;
|
|
328
1105
|
const cfg = (row as any)?.config ?? {};
|
|
329
|
-
const hasPrompts = Object.values(cfg).some(
|
|
330
|
-
|
|
331
|
-
);
|
|
1106
|
+
const hasPrompts = Object.values(cfg).some(v => isPlaceholder(v));
|
|
1107
|
+
|
|
1108
|
+
const def = getConnector(connId);
|
|
1109
|
+
const defaults: Array<{ field: string; value: string; is_secret: boolean }> = [];
|
|
1110
|
+
for (const [field, rawVal] of Object.entries(cfg)) {
|
|
1111
|
+
if (isPlaceholder(rawVal)) continue;
|
|
1112
|
+
const fieldDef = (def?.settings as any)?.[field];
|
|
1113
|
+
const declaredSecret = fieldDef?.type === 'secret';
|
|
1114
|
+
if (fieldDef?.type === 'instances') {
|
|
1115
|
+
let rows: any[] = [];
|
|
1116
|
+
if (Array.isArray(rawVal)) rows = rawVal;
|
|
1117
|
+
else if (typeof rawVal === 'string') {
|
|
1118
|
+
try { rows = JSON.parse(rawVal); } catch { rows = []; }
|
|
1119
|
+
}
|
|
1120
|
+
for (const inst of rows) {
|
|
1121
|
+
if (!inst || typeof inst !== 'object') continue;
|
|
1122
|
+
const name = String((inst as any).name || '<unnamed>');
|
|
1123
|
+
for (const [k, v] of Object.entries(inst as Record<string, unknown>)) {
|
|
1124
|
+
if (k === 'name') continue;
|
|
1125
|
+
if (isPlaceholder(v)) continue;
|
|
1126
|
+
const instFieldSecret = name.toLowerCase().includes('token') || k.toLowerCase().includes('token') || k.toLowerCase().includes('password') || isMaskedSecret(v);
|
|
1127
|
+
const valueStr = isMaskedSecret(v) ? '••••••••' : previewResolve(v);
|
|
1128
|
+
defaults.push({ field: `${name}.${k}`, value: valueStr, is_secret: instFieldSecret });
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const isSecret = declaredSecret || isMaskedSecret(rawVal);
|
|
1134
|
+
const valueStr = isSecret ? '••••••••' : previewResolve(rawVal);
|
|
1135
|
+
defaults.push({ field, value: valueStr, is_secret: isSecret });
|
|
1136
|
+
}
|
|
1137
|
+
|
|
332
1138
|
templateConnectors.push({
|
|
333
1139
|
id: connId,
|
|
334
1140
|
default_enabled: (row as any)?.enabled !== false,
|
|
335
1141
|
has_prompts: hasPrompts,
|
|
336
1142
|
already_installed: !!getInstalledConnector(connId),
|
|
1143
|
+
defaults,
|
|
337
1144
|
});
|
|
338
1145
|
}
|
|
339
1146
|
|
|
@@ -366,6 +1173,63 @@ export async function GET() {
|
|
|
366
1173
|
}
|
|
367
1174
|
}
|
|
368
1175
|
|
|
1176
|
+
// ─── Template previews — what _agents / _apiProfiles / _derive will do ──
|
|
1177
|
+
//
|
|
1178
|
+
// Lets the wizard collapse hardcoded "Chat API key" / "CLI Agent" sections
|
|
1179
|
+
// when the template provides them, and show a one-glance "this is what
|
|
1180
|
+
// will be installed" banner.
|
|
1181
|
+
const agentsPreview: Array<{ id: string; tool?: string; has_secret: boolean }> = [];
|
|
1182
|
+
if (template._agents && typeof template._agents === 'object') {
|
|
1183
|
+
for (const [id, raw] of Object.entries(template._agents as Record<string, any>)) {
|
|
1184
|
+
const env = (raw?.env ?? {}) as Record<string, unknown>;
|
|
1185
|
+
const has_secret = Object.values(env).some((v) => typeof v === 'string' && v.startsWith('ent:'));
|
|
1186
|
+
agentsPreview.push({ id, tool: raw?.tool, has_secret });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const apiProfilesPreview: Array<{ id: string; provider?: string; model?: string; has_secret: boolean }> = [];
|
|
1190
|
+
if (template._apiProfiles && typeof template._apiProfiles === 'object') {
|
|
1191
|
+
for (const [id, raw] of Object.entries(template._apiProfiles as Record<string, any>)) {
|
|
1192
|
+
const has_secret = typeof raw?.apiKey === 'string' && raw.apiKey.startsWith('ent:');
|
|
1193
|
+
apiProfilesPreview.push({ id, provider: raw?.provider, model: raw?.model, has_secret });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const deriveKeys = template._derive ? Object.keys(template._derive as object) : [];
|
|
1197
|
+
|
|
1198
|
+
// Memory auto-provision hint — pulled exclusively from the template's
|
|
1199
|
+
// `_temperAdmin: { url, token }` block. UI uses this to render the
|
|
1200
|
+
// "memory will be auto-provisioned via Temper" banner. Admin token never
|
|
1201
|
+
// leaves the server; only `url` is exposed. `provisioned` reflects whether
|
|
1202
|
+
// the user already has a personal temperKey (= Apply is a no-op).
|
|
1203
|
+
const tplAdmin = (template._temperAdmin ?? {}) as { url?: string; token?: unknown };
|
|
1204
|
+
const adminUrl = typeof tplAdmin.url === 'string' ? tplAdmin.url : '';
|
|
1205
|
+
const adminTokenPresent = !!(tplAdmin.token && (typeof tplAdmin.token === 'string' || typeof tplAdmin.token === 'object'));
|
|
1206
|
+
const memoryAutoProvision = adminTokenPresent && !!adminUrl ? {
|
|
1207
|
+
url: adminUrl,
|
|
1208
|
+
provisioned: !!settings.temperKey,
|
|
1209
|
+
} : undefined;
|
|
1210
|
+
|
|
1211
|
+
// Wizard layout knobs the template can flip to hide noise. `minimal`
|
|
1212
|
+
// collapses everything but Identity + required prompts + preview;
|
|
1213
|
+
// useful for enterprise templates that bake nearly all defaults.
|
|
1214
|
+
const wizardKnobs = (template._wizard ?? {}) as {
|
|
1215
|
+
minimal?: boolean;
|
|
1216
|
+
hide_connectors?: boolean;
|
|
1217
|
+
hide_pipelines?: boolean;
|
|
1218
|
+
required_only?: boolean;
|
|
1219
|
+
};
|
|
1220
|
+
// `minimal` implies hiding pipelines and filtering to required-only prompts,
|
|
1221
|
+
// but NOT hiding all connector cards — required prompts still need to be
|
|
1222
|
+
// visible so the user can enter their gitlab PAT / jenkins token. The
|
|
1223
|
+
// wizard UI already drops cards that have zero user-visible prompts under
|
|
1224
|
+
// required_only, so explicit `hide_connectors` is only for the rare case
|
|
1225
|
+
// where the template literally has no required prompts.
|
|
1226
|
+
const wizardLayout = {
|
|
1227
|
+
minimal: !!wizardKnobs.minimal,
|
|
1228
|
+
hide_connectors: !!wizardKnobs.hide_connectors,
|
|
1229
|
+
hide_pipelines: !!(wizardKnobs.hide_pipelines ?? wizardKnobs.minimal),
|
|
1230
|
+
required_only: !!(wizardKnobs.required_only ?? wizardKnobs.minimal),
|
|
1231
|
+
};
|
|
1232
|
+
|
|
369
1233
|
return NextResponse.json({
|
|
370
1234
|
ok: true,
|
|
371
1235
|
onboardingCompleted: !!settings.onboardingCompleted,
|
|
@@ -382,9 +1246,31 @@ export async function GET() {
|
|
|
382
1246
|
detected_cli: detected,
|
|
383
1247
|
template_connectors: templateConnectors,
|
|
384
1248
|
template_prompts: template._prompts || {},
|
|
1249
|
+
template_agents_preview: agentsPreview.length ? agentsPreview : undefined,
|
|
1250
|
+
template_api_profiles_preview: apiProfilesPreview.length ? apiProfilesPreview : undefined,
|
|
1251
|
+
template_derive_keys: deriveKeys.length ? deriveKeys : undefined,
|
|
1252
|
+
template_enterprise_name: typeof template._enterprise_name === 'string' ? template._enterprise_name : undefined,
|
|
1253
|
+
template_wizard: wizardLayout,
|
|
1254
|
+
memory_auto_provision: memoryAutoProvision,
|
|
385
1255
|
prompt_values_set: promptValuesSet,
|
|
386
1256
|
prompt_targets: promptTargets,
|
|
387
1257
|
suggested_pipelines,
|
|
1258
|
+
// E2: tenant scope info — `available_sources` powers the wizard's
|
|
1259
|
+
// top-bar tenant selector; `resolved_source` is the source id that
|
|
1260
|
+
// actually fed the rendered template (used to highlight the active
|
|
1261
|
+
// entry).
|
|
1262
|
+
available_sources: availableSources,
|
|
1263
|
+
resolved_source: resolvedSource,
|
|
1264
|
+
requested_source_id: requestedSourceId ?? null,
|
|
1265
|
+
// E3: per-dept list for the scoped source. Wizard renders second
|
|
1266
|
+
// dropdown from this; empty = single-template source, no picker.
|
|
1267
|
+
available_departments: availableDepartments,
|
|
1268
|
+
resolved_dept: resolvedDept,
|
|
1269
|
+
requested_dept_id: requestedDeptId ?? null,
|
|
1270
|
+
// Template's self-declared dept name (when present) — used as the
|
|
1271
|
+
// {dept.name} substitution and surfaced in the UI breadcrumb.
|
|
1272
|
+
template_department_name:
|
|
1273
|
+
typeof template._department_name === 'string' ? template._department_name : undefined,
|
|
388
1274
|
});
|
|
389
1275
|
}
|
|
390
1276
|
|
|
@@ -431,12 +1317,23 @@ export async function POST(req: Request) {
|
|
|
431
1317
|
}
|
|
432
1318
|
|
|
433
1319
|
try {
|
|
434
|
-
const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors);
|
|
1320
|
+
const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors, payload.sourceId, payload.deptId);
|
|
435
1321
|
phases.push({ phase: 'connectors', ok: true, detail: r });
|
|
436
1322
|
} catch (e) {
|
|
437
1323
|
phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
438
1324
|
}
|
|
439
1325
|
|
|
1326
|
+
// Memory auto-provision — runs after applyConnectors so settings.company /
|
|
1327
|
+
// .dept are stamped. No-ops when the template ships no _temperAdmin block
|
|
1328
|
+
// or the user already has a temperKey.
|
|
1329
|
+
try {
|
|
1330
|
+
const r = await applyTemperAutoProvision(payload.sourceId, payload.deptId);
|
|
1331
|
+
const failed = typeof r === 'object' && r !== null && 'error' in r;
|
|
1332
|
+
phases.push({ phase: 'temperProvision', ok: !failed, detail: r });
|
|
1333
|
+
} catch (e) {
|
|
1334
|
+
phases.push({ phase: 'temperProvision', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1335
|
+
}
|
|
1336
|
+
|
|
440
1337
|
try {
|
|
441
1338
|
const r = await applyPipelines(payload.pipelines);
|
|
442
1339
|
phases.push({ phase: 'pipelines', ok: r.errors.length === 0, detail: r });
|