@aion0/forge 0.10.36 → 0.10.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +4 -12
- package/app/api/connectors/import-config-template/route.ts +358 -0
- package/app/api/onboarding/detect-cli/route.ts +46 -0
- package/app/api/onboarding/route.ts +461 -0
- package/components/ConnectorsPanel.tsx +326 -0
- package/components/Dashboard.tsx +29 -1
- package/components/OnboardingWizard.tsx +1002 -0
- package/components/SettingsModal.tsx +42 -0
- package/components/WebTerminal.tsx +16 -1
- package/lib/chat/agent-loop.ts +87 -30
- package/lib/chat/llm/openai.ts +5 -1
- package/lib/chat/session-store.ts +22 -2
- package/lib/chat/tool-dispatcher.ts +6 -1
- package/lib/help-docs/17-connectors.md +51 -0
- package/lib/settings.ts +16 -0
- package/package.json +1 -1
- package/templates/connector-config-template.json +131 -0
|
@@ -0,0 +1,461 @@
|
|
|
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
|
+
/** Subset of template connector ids to install. Missing/empty = install
|
|
62
|
+
* everything in the template. Unselected ids are skipped entirely —
|
|
63
|
+
* no installFromRegistry, no setConnectorConfig. Lets users opt out of
|
|
64
|
+
* Jenkins/NAC/FortiNCM etc. on first run. */
|
|
65
|
+
selectedConnectors?: string[];
|
|
66
|
+
pipelines?: string[]; // marketplace names to install
|
|
67
|
+
projectRoots?: string[]; // paths to append
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Helpers (mirrors import-config-template/route.ts) ───────
|
|
71
|
+
|
|
72
|
+
const PLACEHOLDER_RE = /\$\{([a-zA-Z0-9_]+)\}/g;
|
|
73
|
+
function substituteAll(value: unknown, values: Record<string, string>): unknown {
|
|
74
|
+
if (typeof value === 'string') {
|
|
75
|
+
return value.replace(PLACEHOLDER_RE, (_, key) => values[key] ?? '');
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) return value.map((v) => substituteAll(v, values));
|
|
78
|
+
if (value && typeof value === 'object') {
|
|
79
|
+
const out: Record<string, unknown> = {};
|
|
80
|
+
for (const [k, v] of Object.entries(value)) out[k] = substituteAll(v, values);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isEffectivelyEmpty(v: unknown): boolean {
|
|
87
|
+
if (v == null) return true;
|
|
88
|
+
if (typeof v === 'string') return v.trim() === '';
|
|
89
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
90
|
+
if (typeof v === 'object') return Object.keys(v as object).length === 0;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
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 */ }
|
|
99
|
+
}
|
|
100
|
+
return bundledTemplate;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Phase appliers ──────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function applyIdentity(identity: OnboardingPayload['identity']): string | null {
|
|
106
|
+
if (!identity) return null;
|
|
107
|
+
const settings = loadSettings();
|
|
108
|
+
if (identity.displayName != null) settings.displayName = identity.displayName;
|
|
109
|
+
if (identity.displayEmail != null) settings.displayEmail = identity.displayEmail;
|
|
110
|
+
saveSettings(settings);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function applyApiProfile(p: OnboardingPayload['apiProfile']): string | null {
|
|
115
|
+
if (!p) return null;
|
|
116
|
+
if (!p.id) return 'api profile id required';
|
|
117
|
+
const settings = loadSettings();
|
|
118
|
+
settings.apiProfiles = settings.apiProfiles || {};
|
|
119
|
+
const existing = settings.apiProfiles[p.id];
|
|
120
|
+
// Empty/omitted apiKey → keep the existing one (lets users re-confirm
|
|
121
|
+
// a profile through the wizard without re-typing the secret).
|
|
122
|
+
const apiKey = (p.apiKey && p.apiKey.trim()) ? p.apiKey.trim() : (existing?.apiKey || '');
|
|
123
|
+
if (!apiKey) return `api profile ${p.id}: apiKey required (no existing key to preserve)`;
|
|
124
|
+
settings.apiProfiles[p.id] = {
|
|
125
|
+
name: p.name || existing?.name || p.id,
|
|
126
|
+
enabled: true,
|
|
127
|
+
provider: p.provider,
|
|
128
|
+
model: p.model || existing?.model || '',
|
|
129
|
+
apiKey,
|
|
130
|
+
...(p.baseUrl ? { baseUrl: p.baseUrl } : (existing?.baseUrl ? { baseUrl: existing.baseUrl } : {})),
|
|
131
|
+
};
|
|
132
|
+
if (p.setAsDefault) settings.chatAgent = p.id;
|
|
133
|
+
saveSettings(settings);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
|
|
138
|
+
if (!a) return null;
|
|
139
|
+
if (!a.id || !a.tool) return 'cli agent id and tool required';
|
|
140
|
+
const settings = loadSettings();
|
|
141
|
+
settings.agents = settings.agents || {};
|
|
142
|
+
const existing = settings.agents[a.id] || {};
|
|
143
|
+
settings.agents[a.id] = {
|
|
144
|
+
...existing,
|
|
145
|
+
tool: a.tool,
|
|
146
|
+
name: existing.name || a.id,
|
|
147
|
+
enabled: true,
|
|
148
|
+
...(a.path ? { path: a.path } : {}),
|
|
149
|
+
};
|
|
150
|
+
if (a.setAsDefault) settings.defaultAgent = a.id;
|
|
151
|
+
// Terminal launch (lib/claude-process.ts) reads settings.claudePath,
|
|
152
|
+
// NOT agent.path. Set it explicitly when the user picked a claude
|
|
153
|
+
// tool with an absolute path — otherwise Forge falls back to PATH
|
|
154
|
+
// lookup and may pick up a different (e.g. conda-base) claude binary.
|
|
155
|
+
if (a.tool === 'claude' && a.path && a.path.startsWith('/')) {
|
|
156
|
+
settings.claudePath = a.path;
|
|
157
|
+
}
|
|
158
|
+
saveSettings(settings);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function applyConnectors(
|
|
163
|
+
values: Record<string, string> | undefined,
|
|
164
|
+
selectedConnectors: string[] | undefined,
|
|
165
|
+
): Promise<{
|
|
166
|
+
applied: string[];
|
|
167
|
+
installed_from_registry: string[];
|
|
168
|
+
skipped_missing_manifest: string[];
|
|
169
|
+
skipped_unselected: string[];
|
|
170
|
+
fields_preserved: Array<{ connector: string; field: string }>;
|
|
171
|
+
}> {
|
|
172
|
+
const template = resolveTemplate();
|
|
173
|
+
// Undefined = install everything; explicit [] = install nothing.
|
|
174
|
+
const selected = selectedConnectors ? new Set(selectedConnectors) : null;
|
|
175
|
+
// Auto-inject user identity from settings so connectors (e.g. tp.username)
|
|
176
|
+
// can reference {user_name} / {user_email} without prompting again.
|
|
177
|
+
// User-supplied values still win over auto-injected ones.
|
|
178
|
+
const settings = loadSettings();
|
|
179
|
+
const subst: Record<string, string> = {
|
|
180
|
+
user_name: settings.displayName || '',
|
|
181
|
+
user_email: settings.displayEmail || '',
|
|
182
|
+
...(values || {}),
|
|
183
|
+
};
|
|
184
|
+
const applied: string[] = [];
|
|
185
|
+
const installedFromRegistry: string[] = [];
|
|
186
|
+
const missing: string[] = [];
|
|
187
|
+
const skippedUnselected: string[] = [];
|
|
188
|
+
const preserved: Array<{ connector: string; field: string }> = [];
|
|
189
|
+
|
|
190
|
+
for (const [id, row] of Object.entries(template)) {
|
|
191
|
+
if (id.startsWith('_')) continue; // metadata keys
|
|
192
|
+
if (selected && !selected.has(id)) { skippedUnselected.push(id); continue; }
|
|
193
|
+
let def = getConnector(id);
|
|
194
|
+
if (!def) {
|
|
195
|
+
// Manifest not on disk yet — fetch from forge-connectors registry
|
|
196
|
+
// and install. installFromRegistry auto-syncs if cache is missing.
|
|
197
|
+
const r = await installFromRegistry(id);
|
|
198
|
+
if (r.ok) {
|
|
199
|
+
installedFromRegistry.push(id);
|
|
200
|
+
def = getConnector(id);
|
|
201
|
+
}
|
|
202
|
+
if (!def) { missing.push(id); continue; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const templateConfig = (row as any)?.config ?? {};
|
|
206
|
+
const enabledTemplate = (row as any)?.enabled;
|
|
207
|
+
const existing = getInstalledConnector(id)?.config ?? {};
|
|
208
|
+
const merged: Record<string, unknown> = { ...existing };
|
|
209
|
+
|
|
210
|
+
for (const [field, rawVal] of Object.entries(templateConfig)) {
|
|
211
|
+
const resolved = substituteAll(rawVal, subst);
|
|
212
|
+
const existingVal = existing[field];
|
|
213
|
+
const existingNonEmpty = !isEffectivelyEmpty(existingVal)
|
|
214
|
+
&& !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'));
|
|
215
|
+
if (existingNonEmpty) {
|
|
216
|
+
if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
|
|
217
|
+
preserved.push({ connector: id, field });
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
merged[field] = resolved;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
setConnectorConfig(id, merged);
|
|
225
|
+
applied.push(id);
|
|
226
|
+
if (typeof enabledTemplate === 'boolean') setConnectorEnabled(id, enabledTemplate);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
applied,
|
|
231
|
+
installed_from_registry: installedFromRegistry,
|
|
232
|
+
skipped_missing_manifest: missing,
|
|
233
|
+
skipped_unselected: skippedUnselected,
|
|
234
|
+
fields_preserved: preserved,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function applyPipelines(names: string[] | undefined): Promise<{
|
|
239
|
+
installed: string[];
|
|
240
|
+
errors: Array<{ name: string; error: string }>;
|
|
241
|
+
}> {
|
|
242
|
+
if (!names || names.length === 0) return { installed: [], errors: [] };
|
|
243
|
+
const installed: string[] = [];
|
|
244
|
+
const errors: Array<{ name: string; error: string }> = [];
|
|
245
|
+
for (const name of names) {
|
|
246
|
+
try {
|
|
247
|
+
const r = await installFromMarketplace('pipeline', name, { overwrite: false });
|
|
248
|
+
if (r.ok) installed.push(name);
|
|
249
|
+
else errors.push({ name, error: r.error || 'unknown' });
|
|
250
|
+
} catch (e) {
|
|
251
|
+
errors.push({ name, error: e instanceof Error ? e.message : String(e) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { installed, errors };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function applyProjectRoots(paths: string[] | undefined): string | null {
|
|
258
|
+
if (!paths || paths.length === 0) return null;
|
|
259
|
+
const settings = loadSettings();
|
|
260
|
+
const existing = new Set(settings.projectRoots || []);
|
|
261
|
+
for (const p of paths) {
|
|
262
|
+
const trimmed = p.trim();
|
|
263
|
+
if (trimmed) existing.add(trimmed);
|
|
264
|
+
}
|
|
265
|
+
settings.projectRoots = Array.from(existing);
|
|
266
|
+
saveSettings(settings);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── Routes ──────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
export async function GET() {
|
|
273
|
+
const settings = loadSettings();
|
|
274
|
+
const template = resolveTemplate();
|
|
275
|
+
|
|
276
|
+
// Suggest fortinet-* pipelines from the marketplace as default-selected.
|
|
277
|
+
let suggested_pipelines: string[] = [];
|
|
278
|
+
try {
|
|
279
|
+
const m = listMarketplace();
|
|
280
|
+
suggested_pipelines = (m.pipelines || [])
|
|
281
|
+
.filter((e: any) => e.name?.startsWith('fortinet-'))
|
|
282
|
+
.map((e: any) => e.name);
|
|
283
|
+
} catch { /* marketplace may be unsynced — ok */ }
|
|
284
|
+
|
|
285
|
+
// Surface the current chat API profile for prefill — key never leaves
|
|
286
|
+
// the server (we just signal whether one is set so the UI can show
|
|
287
|
+
// 'leave blank to keep current').
|
|
288
|
+
const profiles = settings.apiProfiles || {};
|
|
289
|
+
const currentChatId = settings.chatAgent || Object.keys(profiles)[0] || '';
|
|
290
|
+
const currentChat = profiles[currentChatId];
|
|
291
|
+
const apiProfileDetail = currentChat ? {
|
|
292
|
+
id: currentChatId,
|
|
293
|
+
name: currentChat.name || currentChatId,
|
|
294
|
+
provider: currentChat.provider,
|
|
295
|
+
model: currentChat.model || '',
|
|
296
|
+
baseUrl: currentChat.baseUrl || '',
|
|
297
|
+
apiKeySet: !!currentChat.apiKey,
|
|
298
|
+
} : null;
|
|
299
|
+
|
|
300
|
+
// Existing CLI agents (settings.agents — CLI subprocess wrappers).
|
|
301
|
+
const agents = settings.agents || {};
|
|
302
|
+
const agentList = Object.entries(agents).map(([id, a]) => ({
|
|
303
|
+
id,
|
|
304
|
+
name: a.name || id,
|
|
305
|
+
tool: a.tool || '',
|
|
306
|
+
path: a.path || '',
|
|
307
|
+
enabled: a.enabled !== false,
|
|
308
|
+
}));
|
|
309
|
+
|
|
310
|
+
// CLI detection moved to /api/onboarding/detect-cli (async, called by
|
|
311
|
+
// the wizard when the drawer opens). Doing 3 × execSync('which …')
|
|
312
|
+
// here was blocking the Node event loop for up to 15s on misses,
|
|
313
|
+
// which froze Dashboard mount + every other API on the same worker.
|
|
314
|
+
const detected: Array<{ name: string; path: string; version: string }> = [];
|
|
315
|
+
|
|
316
|
+
// Connector list from the template (excluding `_*` metadata keys),
|
|
317
|
+
// in template order. UI renders this as checkboxes — default all
|
|
318
|
+
// selected, user can opt out before Apply. `default_enabled` mirrors
|
|
319
|
+
// the template's `enabled:` (e.g. fortincm is opt-in).
|
|
320
|
+
const templateConnectors: Array<{
|
|
321
|
+
id: string;
|
|
322
|
+
default_enabled: boolean;
|
|
323
|
+
has_prompts: boolean;
|
|
324
|
+
already_installed: boolean;
|
|
325
|
+
}> = [];
|
|
326
|
+
for (const [connId, row] of Object.entries(template)) {
|
|
327
|
+
if (connId.startsWith('_')) continue;
|
|
328
|
+
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
|
+
);
|
|
332
|
+
templateConnectors.push({
|
|
333
|
+
id: connId,
|
|
334
|
+
default_enabled: (row as any)?.enabled !== false,
|
|
335
|
+
has_prompts: hasPrompts,
|
|
336
|
+
already_installed: !!getInstalledConnector(connId),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Per-prompt "already set" probe — for each ${key} the template uses,
|
|
341
|
+
// find which connector field references it and check if that field
|
|
342
|
+
// is already non-empty. Lets the wizard show "•••• (keep existing)"
|
|
343
|
+
// instead of asking the user to re-type tokens.
|
|
344
|
+
const promptValuesSet: Record<string, boolean> = {};
|
|
345
|
+
const promptTargets: Record<string, Array<{ connector: string; field: string }>> = {};
|
|
346
|
+
const placeholderRe = /\$\{([a-zA-Z0-9_]+)\}/g;
|
|
347
|
+
for (const [connId, row] of Object.entries(template)) {
|
|
348
|
+
if (connId.startsWith('_')) continue;
|
|
349
|
+
const cfg = (row as any)?.config ?? {};
|
|
350
|
+
const inst = getInstalledConnector(connId);
|
|
351
|
+
const existingCfg = inst?.config || {};
|
|
352
|
+
for (const [field, rawVal] of Object.entries(cfg)) {
|
|
353
|
+
if (typeof rawVal !== 'string') continue;
|
|
354
|
+
const matches = [...rawVal.matchAll(placeholderRe)];
|
|
355
|
+
for (const m of matches) {
|
|
356
|
+
const key = m[1];
|
|
357
|
+
if (!promptTargets[key]) promptTargets[key] = [];
|
|
358
|
+
promptTargets[key].push({ connector: connId, field });
|
|
359
|
+
// "set" = the target field already has a real (non-empty, non-TODO_) value.
|
|
360
|
+
const existing = existingCfg[field];
|
|
361
|
+
const isSet = typeof existing === 'string'
|
|
362
|
+
? (existing.trim() !== '' && !existing.startsWith('TODO_') && !existing.includes('${'))
|
|
363
|
+
: (existing != null && !isEffectivelyEmpty(existing));
|
|
364
|
+
if (isSet) promptValuesSet[key] = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return NextResponse.json({
|
|
370
|
+
ok: true,
|
|
371
|
+
onboardingCompleted: !!settings.onboardingCompleted,
|
|
372
|
+
current: {
|
|
373
|
+
displayName: settings.displayName || '',
|
|
374
|
+
displayEmail: settings.displayEmail || '',
|
|
375
|
+
apiProfileIds: Object.keys(profiles),
|
|
376
|
+
apiProfile: apiProfileDetail,
|
|
377
|
+
chatAgent: settings.chatAgent || '',
|
|
378
|
+
defaultAgent: settings.defaultAgent || '',
|
|
379
|
+
agents: agentList,
|
|
380
|
+
projectRoots: settings.projectRoots || [],
|
|
381
|
+
},
|
|
382
|
+
detected_cli: detected,
|
|
383
|
+
template_connectors: templateConnectors,
|
|
384
|
+
template_prompts: template._prompts || {},
|
|
385
|
+
prompt_values_set: promptValuesSet,
|
|
386
|
+
prompt_targets: promptTargets,
|
|
387
|
+
suggested_pipelines,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function POST(req: Request) {
|
|
392
|
+
let body: any;
|
|
393
|
+
try { body = await req.json(); }
|
|
394
|
+
catch { return NextResponse.json({ ok: false, error: 'invalid JSON' }, { status: 400 }); }
|
|
395
|
+
|
|
396
|
+
const action = body?.action;
|
|
397
|
+
|
|
398
|
+
if (action === 'reset') {
|
|
399
|
+
const settings = loadSettings();
|
|
400
|
+
settings.onboardingCompleted = false;
|
|
401
|
+
saveSettings(settings);
|
|
402
|
+
return NextResponse.json({ ok: true });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (action !== 'apply') {
|
|
406
|
+
return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const payload = (body?.payload || {}) as OnboardingPayload;
|
|
410
|
+
const phases: Array<{ phase: string; ok: boolean; error?: string; detail?: any }> = [];
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const e1 = applyIdentity(payload.identity);
|
|
414
|
+
phases.push({ phase: 'identity', ok: !e1, ...(e1 ? { error: e1 } : {}) });
|
|
415
|
+
} catch (e) {
|
|
416
|
+
phases.push({ phase: 'identity', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const e2 = applyApiProfile(payload.apiProfile);
|
|
421
|
+
phases.push({ phase: 'apiProfile', ok: !e2, ...(e2 ? { error: e2 } : {}) });
|
|
422
|
+
} catch (e) {
|
|
423
|
+
phases.push({ phase: 'apiProfile', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const e2b = applyCliAgent(payload.cliAgent);
|
|
428
|
+
phases.push({ phase: 'cliAgent', ok: !e2b, ...(e2b ? { error: e2b } : {}) });
|
|
429
|
+
} catch (e) {
|
|
430
|
+
phases.push({ phase: 'cliAgent', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors);
|
|
435
|
+
phases.push({ phase: 'connectors', ok: true, detail: r });
|
|
436
|
+
} catch (e) {
|
|
437
|
+
phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
const r = await applyPipelines(payload.pipelines);
|
|
442
|
+
phases.push({ phase: 'pipelines', ok: r.errors.length === 0, detail: r });
|
|
443
|
+
} catch (e) {
|
|
444
|
+
phases.push({ phase: 'pipelines', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const e5 = applyProjectRoots(payload.projectRoots);
|
|
449
|
+
phases.push({ phase: 'projects', ok: !e5, ...(e5 ? { error: e5 } : {}) });
|
|
450
|
+
} catch (e) {
|
|
451
|
+
phases.push({ phase: 'projects', ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Flip the gate regardless — partial setups still count as "user has
|
|
455
|
+
// seen and acted on the wizard". They can re-run from Settings to fix.
|
|
456
|
+
const settings = loadSettings();
|
|
457
|
+
settings.onboardingCompleted = true;
|
|
458
|
+
saveSettings(settings);
|
|
459
|
+
|
|
460
|
+
return NextResponse.json({ ok: true, phases });
|
|
461
|
+
}
|