@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.
package/RELEASE_NOTES.md CHANGED
@@ -1,16 +1,8 @@
1
- # Forge v0.10.36
1
+ # Forge v0.10.37
2
2
 
3
3
  Released: 2026-06-04
4
4
 
5
- ## Changes since v0.10.35
5
+ ## Changes since v0.10.36
6
6
 
7
- ### Documentation
8
- - docs: README install section gets one-liner + optional deps line
9
7
 
10
- ### Other
11
- - scripts: add install-deps.sh cross-platform installer
12
- - feat(chat): add 5 schedule builtin tools (create/list/delete/run/update)
13
- - feat(vscode-ext): add Chat + Schedules views + session switcher
14
-
15
-
16
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.35...v0.10.36
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.36...v0.10.37
@@ -0,0 +1,358 @@
1
+ /**
2
+ * POST /api/connectors/import-config-template
3
+ *
4
+ * Two phases driven by the request body:
5
+ *
6
+ * multipart/form-data with `file` (JSON) → analyze:
7
+ * Parse template, scan for ${key} placeholders, return a deduplicated
8
+ * list of prompts the user still needs to answer. Same key referenced
9
+ * by multiple connectors (e.g. gitlab_pat in both gitlab.token and
10
+ * jenkins.instances[0].gitlab_pat) is asked once.
11
+ *
12
+ * application/json `{ template, values }` → apply:
13
+ * Substitute ${key} with the user-supplied values, then merge each
14
+ * connector row into connector-configs.json. Existing non-empty
15
+ * fields are preserved — the template never overwrites a real token.
16
+ *
17
+ * Template shape (see ~/MyDocuments/obsidian-project/30-Resources/forge/
18
+ * connector-configs.template.json):
19
+ * {
20
+ * "_README": "...",
21
+ * "_prompts": { "gitlab_pat": {label, hint, secret, required}, ... },
22
+ * "<connector_id>": { "config": {...}, "enabled": bool },
23
+ * ...
24
+ * }
25
+ * Keys starting with "_" are metadata, not connectors.
26
+ */
27
+
28
+ import { NextResponse } from 'next/server';
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { getDataDir } from '@/lib/dirs';
32
+ import { loadSettings } from '@/lib/settings';
33
+ import {
34
+ getConnector,
35
+ getInstalledConnector,
36
+ setConnectorConfig,
37
+ setConnectorEnabled,
38
+ } from '@/lib/connectors/registry';
39
+ import { installFromRegistry } from '@/lib/connectors/sync';
40
+ // Bundled fallback — shipped with Forge so the "Import Template" button
41
+ // works out of the box without any extra setup. Users can override by
42
+ // dropping their own at <dataDir>/config-template.json.
43
+ import bundledTemplate from '@/templates/connector-config-template.json';
44
+
45
+ const LOCAL_TEMPLATE_FILE = 'config-template.json';
46
+
47
+ /** Resolve template source: user override in dataDir wins, else bundled. */
48
+ function resolveTemplate(): { source: 'local' | 'bundled'; template: any; path: string | null } {
49
+ const userPath = join(getDataDir(), LOCAL_TEMPLATE_FILE);
50
+ if (existsSync(userPath)) {
51
+ try {
52
+ return { source: 'local', template: JSON.parse(readFileSync(userPath, 'utf-8')), path: userPath };
53
+ } catch {
54
+ // Fall through to bundled if user file is malformed.
55
+ }
56
+ }
57
+ return { source: 'bundled', template: bundledTemplate, path: null };
58
+ }
59
+
60
+ interface PromptDef {
61
+ label: string;
62
+ hint?: string;
63
+ /** Optional link to the page where the user creates/finds this value. */
64
+ url?: string;
65
+ /** Display label for the link; defaults to "Open token page" in the UI. */
66
+ url_label?: string;
67
+ secret?: boolean;
68
+ required?: boolean;
69
+ /** UI grouping override. Defaults to the first-target connector id when
70
+ * unset. Use this when a prompt is semantically tied to connector A
71
+ * (e.g. gitlab token-name) but only USED inside connector B's config
72
+ * (e.g. jenkins.instances[0].username) — wizard groups it under A. */
73
+ group?: string;
74
+ /** Pre-filled value the wizard shows in the input by default. User can
75
+ * still edit. Useful for "instance name = default-jenkins" type fields. */
76
+ default?: string;
77
+ }
78
+
79
+ interface PendingPrompt extends PromptDef {
80
+ key: string;
81
+ targets: Array<{ connector: string; field_path: string }>;
82
+ }
83
+
84
+ const PLACEHOLDER_RE = /\$\{([a-zA-Z0-9_]+)\}/g;
85
+
86
+ // ─── Helpers ──────────────────────────────────────────────
87
+
88
+ function isMetaKey(k: string): boolean {
89
+ return k.startsWith('_');
90
+ }
91
+
92
+ /** Walk a value, collect every ${key} usage with its source path. */
93
+ function scanPlaceholders(
94
+ value: unknown,
95
+ path: string,
96
+ out: Map<string, Set<string>>,
97
+ ): void {
98
+ if (typeof value === 'string') {
99
+ const matches = value.matchAll(PLACEHOLDER_RE);
100
+ for (const m of matches) {
101
+ const key = m[1];
102
+ if (!out.has(key)) out.set(key, new Set());
103
+ out.get(key)!.add(path);
104
+ }
105
+ return;
106
+ }
107
+ if (Array.isArray(value)) {
108
+ value.forEach((v, i) => scanPlaceholders(v, `${path}[${i}]`, out));
109
+ return;
110
+ }
111
+ if (value && typeof value === 'object') {
112
+ for (const [k, v] of Object.entries(value)) {
113
+ scanPlaceholders(v, path ? `${path}.${k}` : k, out);
114
+ }
115
+ }
116
+ }
117
+
118
+ /** Replace every ${key} in a string with values[key]. Unmapped keys → empty string. */
119
+ function substituteString(s: string, values: Record<string, string>): string {
120
+ return s.replace(PLACEHOLDER_RE, (_, key) => values[key] ?? '');
121
+ }
122
+
123
+ function substituteAll(value: unknown, values: Record<string, string>): unknown {
124
+ if (typeof value === 'string') return substituteString(value, values);
125
+ if (Array.isArray(value)) return value.map(v => substituteAll(v, values));
126
+ if (value && typeof value === 'object') {
127
+ const out: Record<string, unknown> = {};
128
+ for (const [k, v] of Object.entries(value)) out[k] = substituteAll(v, values);
129
+ return out;
130
+ }
131
+ return value;
132
+ }
133
+
134
+ function isEffectivelyEmpty(v: unknown): boolean {
135
+ if (v == null) return true;
136
+ if (typeof v === 'string') return v.trim() === '';
137
+ if (Array.isArray(v)) return v.length === 0;
138
+ if (typeof v === 'object') return Object.keys(v as object).length === 0;
139
+ return false;
140
+ }
141
+
142
+ // ─── Analyze ──────────────────────────────────────────────
143
+
144
+ function analyze(template: any): {
145
+ pending: PendingPrompt[];
146
+ static_apply_targets: string[];
147
+ missing_manifests: string[];
148
+ } {
149
+ const promptsDict: Record<string, PromptDef> = template._prompts || {};
150
+
151
+ // Map: placeholder_key → Set of "connector.field_path"
152
+ const usage = new Map<string, Set<string>>();
153
+ const staticTargets: string[] = [];
154
+ const missing: string[] = [];
155
+
156
+ for (const [id, row] of Object.entries(template)) {
157
+ if (isMetaKey(id)) continue;
158
+ if (!getConnector(id)) { missing.push(id); continue; }
159
+ staticTargets.push(id);
160
+ scanPlaceholders((row as any).config, id, usage);
161
+ }
162
+
163
+ const pending: PendingPrompt[] = [];
164
+ for (const [key, paths] of usage.entries()) {
165
+ const def = promptsDict[key] || { label: key };
166
+ pending.push({
167
+ key,
168
+ label: def.label || key,
169
+ hint: def.hint,
170
+ url: def.url,
171
+ url_label: def.url_label,
172
+ secret: def.secret,
173
+ required: def.required,
174
+ targets: Array.from(paths).map(p => {
175
+ const dot = p.indexOf('.');
176
+ return dot < 0
177
+ ? { connector: p, field_path: '' }
178
+ : { connector: p.slice(0, dot), field_path: p.slice(dot + 1) };
179
+ }),
180
+ });
181
+ }
182
+ // Sort required first, then by label
183
+ pending.sort((a, b) =>
184
+ (b.required ? 1 : 0) - (a.required ? 1 : 0)
185
+ || a.label.localeCompare(b.label),
186
+ );
187
+
188
+ return { pending, static_apply_targets: staticTargets, missing_manifests: missing };
189
+ }
190
+
191
+ // ─── Apply ────────────────────────────────────────────────
192
+
193
+ async function apply(template: any, values: Record<string, string>): Promise<{
194
+ applied: string[];
195
+ installed_from_registry: string[];
196
+ enabled_changed: string[];
197
+ skipped_missing_manifest: string[];
198
+ fields_preserved: Array<{ connector: string; field: string; reason: string }>;
199
+ fields_left_empty: Array<{ connector: string; field: string }>;
200
+ }> {
201
+ // Auto-inject user identity from settings so connectors (e.g. tp.username)
202
+ // can use {user_name} / {user_email} without prompting the user again.
203
+ // User-supplied values still win over auto-injected ones.
204
+ const settings = loadSettings();
205
+ values = {
206
+ user_name: settings.displayName || '',
207
+ user_email: settings.displayEmail || '',
208
+ ...values,
209
+ };
210
+
211
+ const applied: string[] = [];
212
+ const installedFromRegistry: string[] = [];
213
+ const enabledChanged: string[] = [];
214
+ const missing: string[] = [];
215
+ const preserved: Array<{ connector: string; field: string; reason: string }> = [];
216
+ const leftEmpty: Array<{ connector: string; field: string }> = [];
217
+
218
+ for (const [id, row] of Object.entries(template)) {
219
+ if (isMetaKey(id)) continue;
220
+ let def = getConnector(id);
221
+ if (!def) {
222
+ // Manifest not installed yet — pull from forge-connectors registry
223
+ // and install. installFromRegistry auto-syncs cache if empty.
224
+ const r = await installFromRegistry(id);
225
+ if (r.ok) {
226
+ installedFromRegistry.push(id);
227
+ def = getConnector(id);
228
+ }
229
+ if (!def) { missing.push(id); continue; }
230
+ }
231
+
232
+ const templateConfig = (row as any)?.config ?? {};
233
+ const enabledTemplate = (row as any)?.enabled;
234
+
235
+ const existing = getInstalledConnector(id)?.config ?? {};
236
+ const merged: Record<string, unknown> = { ...existing };
237
+
238
+ for (const [field, rawVal] of Object.entries(templateConfig)) {
239
+ const resolved = substituteAll(rawVal, values);
240
+
241
+ // If still contains unresolved ${...} (user left blank for non-required),
242
+ // keep resolved as-is (empty strings collapsed already by substituteString).
243
+ const existingVal = existing[field];
244
+ const existingNonEmpty = !isEffectivelyEmpty(existingVal)
245
+ && !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'));
246
+
247
+ if (existingNonEmpty) {
248
+ // Compare — if template would have set the same thing (after substitution),
249
+ // no-op silently; otherwise note as preserved.
250
+ if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
251
+ preserved.push({ connector: id, field, reason: 'existing value kept' });
252
+ }
253
+ continue;
254
+ }
255
+
256
+ if (isEffectivelyEmpty(resolved)) {
257
+ leftEmpty.push({ connector: id, field });
258
+ // still write empty so the field shape exists (e.g. instances=[] is valid)
259
+ // — but for plain string fields, an empty string is fine too.
260
+ }
261
+ merged[field] = resolved;
262
+ }
263
+
264
+ setConnectorConfig(id, merged);
265
+ applied.push(id);
266
+
267
+ if (typeof enabledTemplate === 'boolean') {
268
+ setConnectorEnabled(id, enabledTemplate);
269
+ enabledChanged.push(id);
270
+ }
271
+ }
272
+
273
+ return {
274
+ applied,
275
+ installed_from_registry: installedFromRegistry,
276
+ enabled_changed: enabledChanged,
277
+ skipped_missing_manifest: missing,
278
+ fields_preserved: preserved,
279
+ fields_left_empty: leftEmpty,
280
+ };
281
+ }
282
+
283
+ // ─── Route ────────────────────────────────────────────────
284
+
285
+ /**
286
+ * HEAD — existence probe. Always 200 because Forge ships a bundled
287
+ * template. Kept HEAD-only for the UI mount probe (no body cost).
288
+ */
289
+ export async function HEAD() {
290
+ return new NextResponse(null, { status: 200 });
291
+ }
292
+
293
+ /**
294
+ * GET — one-click flow. Returns an analyzed template:
295
+ * 1. user override at <dataDir>/config-template.json, OR
296
+ * 2. bundled fallback (templates/connector-config-template.json).
297
+ * The UI uses this to pop the prompt modal without a file picker.
298
+ */
299
+ export async function GET() {
300
+ try {
301
+ const { source, template, path } = resolveTemplate();
302
+ const result = analyze(template);
303
+ return NextResponse.json({ ok: true, source, path, template, ...result });
304
+ } catch (e) {
305
+ return NextResponse.json(
306
+ { ok: false, error: e instanceof Error ? e.message : String(e) },
307
+ { status: 500 },
308
+ );
309
+ }
310
+ }
311
+
312
+ export async function POST(req: Request) {
313
+ const ct = req.headers.get('content-type') || '';
314
+
315
+ try {
316
+ // Phase 1: analyze (multipart upload)
317
+ if (ct.startsWith('multipart/form-data')) {
318
+ const fd = await req.formData();
319
+ const file = fd.get('file');
320
+ if (!(file instanceof File)) {
321
+ return NextResponse.json({ ok: false, error: 'file field required' }, { status: 400 });
322
+ }
323
+ const text = await file.text();
324
+ let template: any;
325
+ try { template = JSON.parse(text); }
326
+ catch (e) {
327
+ return NextResponse.json(
328
+ { ok: false, error: `invalid JSON: ${(e as Error).message}` },
329
+ { status: 400 },
330
+ );
331
+ }
332
+ const result = analyze(template);
333
+ return NextResponse.json({ ok: true, template, ...result });
334
+ }
335
+
336
+ // Phase 2: apply (JSON body)
337
+ if (ct.includes('application/json')) {
338
+ const body = await req.json();
339
+ const template = body?.template;
340
+ const values = (body?.values ?? {}) as Record<string, string>;
341
+ if (!template || typeof template !== 'object') {
342
+ return NextResponse.json({ ok: false, error: 'template required' }, { status: 400 });
343
+ }
344
+ const result = await apply(template, values);
345
+ return NextResponse.json({ ok: true, ...result });
346
+ }
347
+
348
+ return NextResponse.json(
349
+ { ok: false, error: 'send multipart with `file` (analyze) or JSON {template,values} (apply)' },
350
+ { status: 400 },
351
+ );
352
+ } catch (e) {
353
+ return NextResponse.json(
354
+ { ok: false, error: e instanceof Error ? e.message : String(e) },
355
+ { status: 500 },
356
+ );
357
+ }
358
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * GET /api/onboarding/detect-cli
3
+ *
4
+ * Async, parallel `which <name> && <name> --version` for claude/codex/aider.
5
+ * Returns whichever ones are present. Fast — each probe capped at ~1.5s
6
+ * with explicit abort, then all 3 race in parallel (Promise.all).
7
+ *
8
+ * Split out from the main onboarding GET so Dashboard mount stays
9
+ * instant (3 × execSync was blocking the Node event loop up to 15s on
10
+ * misses, which froze every other API on the same worker).
11
+ *
12
+ * Wizard calls this when the drawer opens — by which time the user
13
+ * has already seen the rest of their config load.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+ import { exec } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+
20
+ const execAsync = promisify(exec);
21
+
22
+ async function probe(name: string): Promise<{ name: string; path: string; version: string } | null> {
23
+ try {
24
+ const which = await execAsync(`which ${name}`, { timeout: 800 });
25
+ const path = which.stdout.trim();
26
+ if (!path) return null;
27
+ let version = '';
28
+ try {
29
+ const v = await execAsync(`${path} --version`, { timeout: 1500 });
30
+ const out = v.stdout.trim();
31
+ const m = out.match(/v?(\d+\.\d+\.\d+)/);
32
+ version = m ? m[1] : out.slice(0, 30);
33
+ } catch { /* optional */ }
34
+ return { name, path, version };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function GET() {
41
+ const probes = await Promise.all(['claude', 'codex', 'aider'].map(probe));
42
+ return NextResponse.json({
43
+ ok: true,
44
+ detected: probes.filter((x): x is { name: string; path: string; version: string } => x != null),
45
+ });
46
+ }