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