@aion0/forge 0.10.40 → 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.
Files changed (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -5
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +98 -1
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. 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
- function substituteAll(value: unknown, values: Record<string, string>): unknown {
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
- return value.replace(PLACEHOLDER_RE, (_, key) => values[key] ?? '');
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
- const userPath = join(getDataDir(), 'config-template.json');
96
- if (existsSync(userPath)) {
97
- try { return JSON.parse(readFileSync(userPath, 'utf-8')); }
98
- catch { /* fall through */ }
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
- async function applyConnectors(
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 template = resolveTemplate();
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 subst: Record<string, string> = {
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
- const template = resolveTemplate();
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
- v => typeof v === 'string' && /\$\{[a-zA-Z0-9_]+\}/.test(v),
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 });