@aion0/forge 0.10.36 → 0.10.37

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.
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Onboarding wizard API — first-run setup.
3
+ *
4
+ * GET → returns current onboarding state + suggested defaults
5
+ * (so the UI can pre-populate from existing settings on re-run).
6
+ *
7
+ * POST { action: 'apply', payload } → atomic apply:
8
+ * 1. identity (settings.displayName / .displayEmail)
9
+ * 2. API profile (add/upsert apiProfiles[id] + set chatAgent)
10
+ * 3. connectors (run the template apply path with the user-supplied values)
11
+ * 4. pipelines (install selected pipelines from workflow marketplace)
12
+ * 5. projects (settings.projectRoots add unique)
13
+ * 6. flip settings.onboardingCompleted = true
14
+ *
15
+ * POST { action: 'reset' } → flips onboardingCompleted=false so the
16
+ * wizard pops again. Doesn't undo anything else.
17
+ *
18
+ * Failure mode: best-effort. Each phase logs+collects its own errors and
19
+ * keeps going so a missing API profile doesn't prevent connectors from
20
+ * being configured. The response surfaces a per-phase status array.
21
+ */
22
+
23
+ import { NextResponse } from 'next/server';
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { loadSettings, saveSettings } from '@/lib/settings';
27
+ import { getDataDir } from '@/lib/dirs';
28
+ import {
29
+ getConnector,
30
+ getInstalledConnector,
31
+ setConnectorConfig,
32
+ setConnectorEnabled,
33
+ } from '@/lib/connectors/registry';
34
+ import { installFromMarketplace, listMarketplace } from '@/lib/workflow-marketplace';
35
+ import { installFromRegistry } from '@/lib/connectors/sync';
36
+ import bundledTemplate from '@/templates/connector-config-template.json';
37
+
38
+ interface OnboardingPayload {
39
+ identity?: {
40
+ displayName?: string;
41
+ displayEmail?: string;
42
+ };
43
+ apiProfile?: {
44
+ id: string; // e.g. "deepseek"
45
+ name?: string;
46
+ provider: 'anthropic' | 'openai-compatible';
47
+ model: string;
48
+ /** Empty/omitted = keep the existing apiKey on the profile if any. */
49
+ apiKey?: string;
50
+ baseUrl?: string;
51
+ /** If true, set as default chat profile. */
52
+ setAsDefault?: boolean;
53
+ };
54
+ cliAgent?: {
55
+ id: string; // e.g. "claude"
56
+ tool: 'claude' | 'codex' | 'aider' | 'opencode';
57
+ path?: string;
58
+ setAsDefault?: boolean;
59
+ };
60
+ connectorValues?: Record<string, string>; // template ${key} → value
61
+ pipelines?: string[]; // marketplace names to install
62
+ projectRoots?: string[]; // paths to append
63
+ }
64
+
65
+ // ─── Helpers (mirrors import-config-template/route.ts) ───────
66
+
67
+ const PLACEHOLDER_RE = /\$\{([a-zA-Z0-9_]+)\}/g;
68
+ function substituteAll(value: unknown, values: Record<string, string>): unknown {
69
+ if (typeof value === 'string') {
70
+ return value.replace(PLACEHOLDER_RE, (_, key) => values[key] ?? '');
71
+ }
72
+ if (Array.isArray(value)) return value.map((v) => substituteAll(v, values));
73
+ if (value && typeof value === 'object') {
74
+ const out: Record<string, unknown> = {};
75
+ for (const [k, v] of Object.entries(value)) out[k] = substituteAll(v, values);
76
+ return out;
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function isEffectivelyEmpty(v: unknown): boolean {
82
+ if (v == null) return true;
83
+ if (typeof v === 'string') return v.trim() === '';
84
+ if (Array.isArray(v)) return v.length === 0;
85
+ if (typeof v === 'object') return Object.keys(v as object).length === 0;
86
+ return false;
87
+ }
88
+
89
+ function resolveTemplate(): any {
90
+ const userPath = join(getDataDir(), 'config-template.json');
91
+ if (existsSync(userPath)) {
92
+ try { return JSON.parse(readFileSync(userPath, 'utf-8')); }
93
+ catch { /* fall through */ }
94
+ }
95
+ return bundledTemplate;
96
+ }
97
+
98
+ // ─── Phase appliers ──────────────────────────────────────────
99
+
100
+ function applyIdentity(identity: OnboardingPayload['identity']): string | null {
101
+ if (!identity) return null;
102
+ const settings = loadSettings();
103
+ if (identity.displayName != null) settings.displayName = identity.displayName;
104
+ if (identity.displayEmail != null) settings.displayEmail = identity.displayEmail;
105
+ saveSettings(settings);
106
+ return null;
107
+ }
108
+
109
+ function applyApiProfile(p: OnboardingPayload['apiProfile']): string | null {
110
+ if (!p) return null;
111
+ if (!p.id) return 'api profile id required';
112
+ const settings = loadSettings();
113
+ settings.apiProfiles = settings.apiProfiles || {};
114
+ const existing = settings.apiProfiles[p.id];
115
+ // Empty/omitted apiKey → keep the existing one (lets users re-confirm
116
+ // a profile through the wizard without re-typing the secret).
117
+ const apiKey = (p.apiKey && p.apiKey.trim()) ? p.apiKey.trim() : (existing?.apiKey || '');
118
+ if (!apiKey) return `api profile ${p.id}: apiKey required (no existing key to preserve)`;
119
+ settings.apiProfiles[p.id] = {
120
+ name: p.name || existing?.name || p.id,
121
+ enabled: true,
122
+ provider: p.provider,
123
+ model: p.model || existing?.model || '',
124
+ apiKey,
125
+ ...(p.baseUrl ? { baseUrl: p.baseUrl } : (existing?.baseUrl ? { baseUrl: existing.baseUrl } : {})),
126
+ };
127
+ if (p.setAsDefault) settings.chatAgent = p.id;
128
+ saveSettings(settings);
129
+ return null;
130
+ }
131
+
132
+ function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
133
+ if (!a) return null;
134
+ if (!a.id || !a.tool) return 'cli agent id and tool required';
135
+ const settings = loadSettings();
136
+ settings.agents = settings.agents || {};
137
+ const existing = settings.agents[a.id] || {};
138
+ settings.agents[a.id] = {
139
+ ...existing,
140
+ tool: a.tool,
141
+ name: existing.name || a.id,
142
+ enabled: true,
143
+ ...(a.path ? { path: a.path } : {}),
144
+ };
145
+ if (a.setAsDefault) settings.defaultAgent = a.id;
146
+ // Terminal launch (lib/claude-process.ts) reads settings.claudePath,
147
+ // NOT agent.path. Set it explicitly when the user picked a claude
148
+ // tool with an absolute path — otherwise Forge falls back to PATH
149
+ // lookup and may pick up a different (e.g. conda-base) claude binary.
150
+ if (a.tool === 'claude' && a.path && a.path.startsWith('/')) {
151
+ settings.claudePath = a.path;
152
+ }
153
+ saveSettings(settings);
154
+ return null;
155
+ }
156
+
157
+ async function applyConnectors(values: Record<string, string> | undefined): Promise<{
158
+ applied: string[];
159
+ installed_from_registry: string[];
160
+ skipped_missing_manifest: string[];
161
+ fields_preserved: Array<{ connector: string; field: string }>;
162
+ }> {
163
+ const template = resolveTemplate();
164
+ // Auto-inject user identity from settings so connectors (e.g. tp.username)
165
+ // can reference {user_name} / {user_email} without prompting again.
166
+ // User-supplied values still win over auto-injected ones.
167
+ const settings = loadSettings();
168
+ const subst: Record<string, string> = {
169
+ user_name: settings.displayName || '',
170
+ user_email: settings.displayEmail || '',
171
+ ...(values || {}),
172
+ };
173
+ const applied: string[] = [];
174
+ const installedFromRegistry: string[] = [];
175
+ const missing: string[] = [];
176
+ const preserved: Array<{ connector: string; field: string }> = [];
177
+
178
+ for (const [id, row] of Object.entries(template)) {
179
+ if (id.startsWith('_')) continue; // metadata keys
180
+ let def = getConnector(id);
181
+ if (!def) {
182
+ // Manifest not on disk yet — fetch from forge-connectors registry
183
+ // and install. installFromRegistry auto-syncs if cache is missing.
184
+ const r = await installFromRegistry(id);
185
+ if (r.ok) {
186
+ installedFromRegistry.push(id);
187
+ def = getConnector(id);
188
+ }
189
+ if (!def) { missing.push(id); continue; }
190
+ }
191
+
192
+ const templateConfig = (row as any)?.config ?? {};
193
+ const enabledTemplate = (row as any)?.enabled;
194
+ const existing = getInstalledConnector(id)?.config ?? {};
195
+ const merged: Record<string, unknown> = { ...existing };
196
+
197
+ for (const [field, rawVal] of Object.entries(templateConfig)) {
198
+ const resolved = substituteAll(rawVal, subst);
199
+ const existingVal = existing[field];
200
+ const existingNonEmpty = !isEffectivelyEmpty(existingVal)
201
+ && !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'));
202
+ if (existingNonEmpty) {
203
+ if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
204
+ preserved.push({ connector: id, field });
205
+ }
206
+ continue;
207
+ }
208
+ merged[field] = resolved;
209
+ }
210
+
211
+ setConnectorConfig(id, merged);
212
+ applied.push(id);
213
+ if (typeof enabledTemplate === 'boolean') setConnectorEnabled(id, enabledTemplate);
214
+ }
215
+
216
+ return {
217
+ applied,
218
+ installed_from_registry: installedFromRegistry,
219
+ skipped_missing_manifest: missing,
220
+ fields_preserved: preserved,
221
+ };
222
+ }
223
+
224
+ async function applyPipelines(names: string[] | undefined): Promise<{
225
+ installed: string[];
226
+ errors: Array<{ name: string; error: string }>;
227
+ }> {
228
+ if (!names || names.length === 0) return { installed: [], errors: [] };
229
+ const installed: string[] = [];
230
+ const errors: Array<{ name: string; error: string }> = [];
231
+ for (const name of names) {
232
+ try {
233
+ const r = await installFromMarketplace('pipeline', name, { overwrite: false });
234
+ if (r.ok) installed.push(name);
235
+ else errors.push({ name, error: r.error || 'unknown' });
236
+ } catch (e) {
237
+ errors.push({ name, error: e instanceof Error ? e.message : String(e) });
238
+ }
239
+ }
240
+ return { installed, errors };
241
+ }
242
+
243
+ function applyProjectRoots(paths: string[] | undefined): string | null {
244
+ if (!paths || paths.length === 0) return null;
245
+ const settings = loadSettings();
246
+ const existing = new Set(settings.projectRoots || []);
247
+ for (const p of paths) {
248
+ const trimmed = p.trim();
249
+ if (trimmed) existing.add(trimmed);
250
+ }
251
+ settings.projectRoots = Array.from(existing);
252
+ saveSettings(settings);
253
+ return null;
254
+ }
255
+
256
+ // ─── Routes ──────────────────────────────────────────────────
257
+
258
+ export async function GET() {
259
+ const settings = loadSettings();
260
+ const template = resolveTemplate();
261
+
262
+ // Suggest fortinet-* pipelines from the marketplace as default-selected.
263
+ let suggested_pipelines: string[] = [];
264
+ try {
265
+ const m = listMarketplace();
266
+ suggested_pipelines = (m.pipelines || [])
267
+ .filter((e: any) => e.name?.startsWith('fortinet-'))
268
+ .map((e: any) => e.name);
269
+ } catch { /* marketplace may be unsynced — ok */ }
270
+
271
+ // Surface the current chat API profile for prefill — key never leaves
272
+ // the server (we just signal whether one is set so the UI can show
273
+ // 'leave blank to keep current').
274
+ const profiles = settings.apiProfiles || {};
275
+ const currentChatId = settings.chatAgent || Object.keys(profiles)[0] || '';
276
+ const currentChat = profiles[currentChatId];
277
+ const apiProfileDetail = currentChat ? {
278
+ id: currentChatId,
279
+ name: currentChat.name || currentChatId,
280
+ provider: currentChat.provider,
281
+ model: currentChat.model || '',
282
+ baseUrl: currentChat.baseUrl || '',
283
+ apiKeySet: !!currentChat.apiKey,
284
+ } : null;
285
+
286
+ // Existing CLI agents (settings.agents — CLI subprocess wrappers).
287
+ const agents = settings.agents || {};
288
+ const agentList = Object.entries(agents).map(([id, a]) => ({
289
+ id,
290
+ name: a.name || id,
291
+ tool: a.tool || '',
292
+ path: a.path || '',
293
+ enabled: a.enabled !== false,
294
+ }));
295
+
296
+ // CLI detection moved to /api/onboarding/detect-cli (async, called by
297
+ // the wizard when the drawer opens). Doing 3 × execSync('which …')
298
+ // here was blocking the Node event loop for up to 15s on misses,
299
+ // which froze Dashboard mount + every other API on the same worker.
300
+ const detected: Array<{ name: string; path: string; version: string }> = [];
301
+
302
+ // Per-prompt "already set" probe — for each ${key} the template uses,
303
+ // find which connector field references it and check if that field
304
+ // is already non-empty. Lets the wizard show "•••• (keep existing)"
305
+ // instead of asking the user to re-type tokens.
306
+ const promptValuesSet: Record<string, boolean> = {};
307
+ const promptTargets: Record<string, Array<{ connector: string; field: string }>> = {};
308
+ const placeholderRe = /\$\{([a-zA-Z0-9_]+)\}/g;
309
+ for (const [connId, row] of Object.entries(template)) {
310
+ if (connId.startsWith('_')) continue;
311
+ const cfg = (row as any)?.config ?? {};
312
+ const inst = getInstalledConnector(connId);
313
+ const existingCfg = inst?.config || {};
314
+ for (const [field, rawVal] of Object.entries(cfg)) {
315
+ if (typeof rawVal !== 'string') continue;
316
+ const matches = [...rawVal.matchAll(placeholderRe)];
317
+ for (const m of matches) {
318
+ const key = m[1];
319
+ if (!promptTargets[key]) promptTargets[key] = [];
320
+ promptTargets[key].push({ connector: connId, field });
321
+ // "set" = the target field already has a real (non-empty, non-TODO_) value.
322
+ const existing = existingCfg[field];
323
+ const isSet = typeof existing === 'string'
324
+ ? (existing.trim() !== '' && !existing.startsWith('TODO_') && !existing.includes('${'))
325
+ : (existing != null && !isEffectivelyEmpty(existing));
326
+ if (isSet) promptValuesSet[key] = true;
327
+ }
328
+ }
329
+ }
330
+
331
+ return NextResponse.json({
332
+ ok: true,
333
+ onboardingCompleted: !!settings.onboardingCompleted,
334
+ current: {
335
+ displayName: settings.displayName || '',
336
+ displayEmail: settings.displayEmail || '',
337
+ apiProfileIds: Object.keys(profiles),
338
+ apiProfile: apiProfileDetail,
339
+ chatAgent: settings.chatAgent || '',
340
+ defaultAgent: settings.defaultAgent || '',
341
+ agents: agentList,
342
+ projectRoots: settings.projectRoots || [],
343
+ },
344
+ detected_cli: detected,
345
+ template_prompts: template._prompts || {},
346
+ prompt_values_set: promptValuesSet,
347
+ prompt_targets: promptTargets,
348
+ suggested_pipelines,
349
+ });
350
+ }
351
+
352
+ export async function POST(req: Request) {
353
+ let body: any;
354
+ try { body = await req.json(); }
355
+ catch { return NextResponse.json({ ok: false, error: 'invalid JSON' }, { status: 400 }); }
356
+
357
+ const action = body?.action;
358
+
359
+ if (action === 'reset') {
360
+ const settings = loadSettings();
361
+ settings.onboardingCompleted = false;
362
+ saveSettings(settings);
363
+ return NextResponse.json({ ok: true });
364
+ }
365
+
366
+ if (action !== 'apply') {
367
+ return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
368
+ }
369
+
370
+ const payload = (body?.payload || {}) as OnboardingPayload;
371
+ const phases: Array<{ phase: string; ok: boolean; error?: string; detail?: any }> = [];
372
+
373
+ try {
374
+ const e1 = applyIdentity(payload.identity);
375
+ phases.push({ phase: 'identity', ok: !e1, ...(e1 ? { error: e1 } : {}) });
376
+ } catch (e) {
377
+ phases.push({ phase: 'identity', ok: false, error: e instanceof Error ? e.message : String(e) });
378
+ }
379
+
380
+ try {
381
+ const e2 = applyApiProfile(payload.apiProfile);
382
+ phases.push({ phase: 'apiProfile', ok: !e2, ...(e2 ? { error: e2 } : {}) });
383
+ } catch (e) {
384
+ phases.push({ phase: 'apiProfile', ok: false, error: e instanceof Error ? e.message : String(e) });
385
+ }
386
+
387
+ try {
388
+ const e2b = applyCliAgent(payload.cliAgent);
389
+ phases.push({ phase: 'cliAgent', ok: !e2b, ...(e2b ? { error: e2b } : {}) });
390
+ } catch (e) {
391
+ phases.push({ phase: 'cliAgent', ok: false, error: e instanceof Error ? e.message : String(e) });
392
+ }
393
+
394
+ try {
395
+ const r = await applyConnectors(payload.connectorValues);
396
+ phases.push({ phase: 'connectors', ok: true, detail: r });
397
+ } catch (e) {
398
+ phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
399
+ }
400
+
401
+ try {
402
+ const r = await applyPipelines(payload.pipelines);
403
+ phases.push({ phase: 'pipelines', ok: r.errors.length === 0, detail: r });
404
+ } catch (e) {
405
+ phases.push({ phase: 'pipelines', ok: false, error: e instanceof Error ? e.message : String(e) });
406
+ }
407
+
408
+ try {
409
+ const e5 = applyProjectRoots(payload.projectRoots);
410
+ phases.push({ phase: 'projects', ok: !e5, ...(e5 ? { error: e5 } : {}) });
411
+ } catch (e) {
412
+ phases.push({ phase: 'projects', ok: false, error: e instanceof Error ? e.message : String(e) });
413
+ }
414
+
415
+ // Flip the gate regardless — partial setups still count as "user has
416
+ // seen and acted on the wizard". They can re-run from Settings to fix.
417
+ const settings = loadSettings();
418
+ settings.onboardingCompleted = true;
419
+ saveSettings(settings);
420
+
421
+ return NextResponse.json({ ok: true, phases });
422
+ }