@aion0/forge 0.8.1 → 0.8.2

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 (45) hide show
  1. package/RELEASE_NOTES.md +25 -6
  2. package/app/api/connectors/[id]/settings/route.ts +31 -37
  3. package/app/api/connectors/[id]/test/route.ts +260 -0
  4. package/app/api/connectors/install-local/route.ts +211 -0
  5. package/app/api/connectors/marketplace/route.ts +79 -0
  6. package/app/api/connectors/route.ts +41 -46
  7. package/app/api/jobs/route.ts +1 -0
  8. package/app/api/skills/install-local/route.ts +282 -0
  9. package/components/ConnectorsPanel.tsx +526 -211
  10. package/components/SettingsModal.tsx +1 -0
  11. package/components/SkillsPanel.tsx +42 -1
  12. package/lib/agents/claude-adapter.ts +4 -0
  13. package/lib/agents/types.ts +6 -0
  14. package/lib/chat/agent-loop.ts +13 -22
  15. package/lib/chat/protocols/http.ts +1 -1
  16. package/lib/chat/protocols/shell.ts +1 -1
  17. package/lib/chat/tool-dispatcher.ts +20 -20
  18. package/lib/connectors/migration.ts +110 -0
  19. package/lib/connectors/registry.ts +328 -0
  20. package/lib/connectors/sync.ts +305 -0
  21. package/lib/connectors/types.ts +253 -0
  22. package/lib/help-docs/00-overview.md +1 -0
  23. package/lib/help-docs/17-connectors.md +241 -189
  24. package/lib/help-docs/21-build-connector.md +314 -0
  25. package/lib/help-docs/CLAUDE.md +4 -2
  26. package/lib/init.ts +25 -0
  27. package/lib/jobs/dispatcher.ts +28 -8
  28. package/lib/jobs/scheduler.ts +21 -3
  29. package/lib/jobs/store.ts +11 -2
  30. package/lib/jobs/types.ts +12 -0
  31. package/lib/pipeline-scheduler.ts +3 -2
  32. package/lib/pipeline.ts +135 -13
  33. package/lib/plugins/registry.ts +9 -42
  34. package/lib/plugins/types.ts +4 -129
  35. package/lib/settings.ts +7 -0
  36. package/lib/skills.ts +27 -1
  37. package/lib/task-manager.ts +62 -2
  38. package/package.json +3 -1
  39. package/src/core/db/database.ts +4 -0
  40. package/lib/builtin-plugins/github-api.yaml +0 -93
  41. package/lib/builtin-plugins/gitlab.yaml +0 -860
  42. package/lib/builtin-plugins/mantis.probe.js +0 -176
  43. package/lib/builtin-plugins/mantis.yaml +0 -964
  44. package/lib/builtin-plugins/pmdb.yaml +0 -178
  45. package/lib/builtin-plugins/teams.yaml +0 -913
package/RELEASE_NOTES.md CHANGED
@@ -1,12 +1,31 @@
1
- # Forge v0.8.1
1
+ # Forge v0.8.2
2
2
 
3
- Released: 2026-05-19
3
+ Released: 2026-05-20
4
4
 
5
- ## Changes since v0.8.0
5
+ ## Changes since v0.8.1
6
6
 
7
7
  ### Other
8
- - feat(monitor): show Chat Server + Browser Bridge in Monitor panel
9
- - fix(chat): use relative imports in chat-standalone graph
8
+ - feat(skills): local skill upload (.md / .zip), parallel to connector install-local
9
+ - fix(pipeline): auto-sync gitlab connector PAT into glab auth + env
10
+ - feat(jobs): Job.skills + thread through to claude --append-system-prompt
11
+ - fix(connectors): friendlier error when browser-probe handler missing
12
+ - fix(pipeline): allow retry on running nodes + reap orphans on boot
13
+ - feat(connectors): browser-side test probe via extension bridge
14
+ - feat(connectors): manifest-driven Test button
15
+ - docs(connectors): 21-build-connector.md + AI routing for authoring
16
+ - feat(connectors): Upload button + drag-and-drop in marketplace
17
+ - feat(connectors): install-local API — accept YAML or zip
18
+ - refactor(connectors): move marketplace from Settings to SkillsPanel tab
19
+ - fix(connectors): surface fetch root cause + show installed in marketplace
20
+ - docs(connectors): rewrite 17-connectors.md for marketplace model
21
+ - feat(connectors): drop builtin yamls + purge connector code from plugin/
22
+ - feat(connectors): one-shot migration from plugin-configs.json
23
+ - feat(connectors): Settings Marketplace panel
24
+ - feat(connectors): marketplace API /api/connectors/marketplace
25
+ - feat(connectors): route /api/connectors + chat through new registry
26
+ - feat(connectors): sync — pull registry + manifests from forge-connectors
27
+ - feat(connectors): registry — load manifests from <dataDir>/connectors/
28
+ - feat(connectors): extract independent types in lib/connectors/
10
29
 
11
30
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.0...v0.8.1
31
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.1...v0.8.2
@@ -1,28 +1,31 @@
1
1
  /**
2
- * Per-user settings for a connector plugin.
2
+ * Per-user settings for an installed connector.
3
3
  *
4
- * Browser-side connectors don't have a Forge-side executor, so "install" is
5
- * really just "save your host URL + preferences". We map connector `settings`
6
- * onto the existing plugin config storage to avoid a second store.
4
+ * GET → masked settings + schema + installed flag
5
+ * POST save settings (preserves stored secrets if client returns
6
+ * the bullet placeholder unchanged)
7
+ *
8
+ * Secrets (fields with type === 'secret') are encrypted at rest by
9
+ * lib/connectors/registry.ts (AES-256-GCM via lib/crypto.ts) and
10
+ * masked here so the plaintext never crosses the wire on GET.
7
11
  */
8
12
 
9
13
  import { NextResponse } from 'next/server';
10
14
  import {
11
- getPlugin,
12
- getInstalledPlugin,
13
- installPlugin,
14
- updatePluginConfig,
15
- getConnectorsForPlugin,
16
- } from '@/lib/plugins/registry';
17
- import type { PluginFieldSchema } from '@/lib/plugins/types';
15
+ getConnector,
16
+ getInstalledConnector,
17
+ setConnectorConfig,
18
+ } from '@/lib/connectors/registry';
19
+ import type { ConnectorFieldSchema } from '@/lib/connectors/types';
18
20
 
19
- function settingsSchemaFor(id: string): Record<string, PluginFieldSchema> {
20
- const def = getPlugin(id);
21
+ function settingsSchemaFor(id: string): Record<string, ConnectorFieldSchema> {
22
+ const def = getConnector(id);
21
23
  if (!def) return {};
22
- // For 1:N suites, merge settings across all entries (caller can scope by entry id).
23
- const merged: Record<string, PluginFieldSchema> = {};
24
- for (const c of getConnectorsForPlugin(def)) {
25
- if (c.settings) Object.assign(merged, c.settings);
24
+ const merged: Record<string, ConnectorFieldSchema> = {};
25
+ if (def.settings) Object.assign(merged, def.settings);
26
+ // 1:N suites: union of each entry's settings
27
+ for (const entry of def.connectors || []) {
28
+ if (entry.settings) Object.assign(merged, entry.settings);
26
29
  }
27
30
  return merged;
28
31
  }
@@ -38,12 +41,12 @@ function defaultsFor(id: string): Record<string, any> {
38
41
 
39
42
  const SECRET_MASK = '••••••••';
40
43
 
41
- function isSecretField(schema: PluginFieldSchema | undefined): boolean {
44
+ function isSecretField(schema: ConnectorFieldSchema | undefined): boolean {
42
45
  if (!schema) return false;
43
46
  return schema.type === 'secret' || (schema.type as string) === 'password';
44
47
  }
45
48
 
46
- function maskSecrets(settings: Record<string, any>, schema: Record<string, PluginFieldSchema>): Record<string, any> {
49
+ function maskSecrets(settings: Record<string, any>, schema: Record<string, ConnectorFieldSchema>): Record<string, any> {
47
50
  const out: Record<string, any> = { ...settings };
48
51
  for (const [k, v] of Object.entries(schema)) {
49
52
  if (isSecretField(v) && typeof out[k] === 'string' && out[k]) {
@@ -56,7 +59,7 @@ function maskSecrets(settings: Record<string, any>, schema: Record<string, Plugi
56
59
  function restoreSecrets(
57
60
  incoming: Record<string, any>,
58
61
  existing: Record<string, any>,
59
- schema: Record<string, PluginFieldSchema>,
62
+ schema: Record<string, ConnectorFieldSchema>,
60
63
  ): Record<string, any> {
61
64
  const out: Record<string, any> = { ...incoming };
62
65
  for (const [k, v] of Object.entries(schema)) {
@@ -70,11 +73,9 @@ function restoreSecrets(
70
73
 
71
74
  export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
72
75
  const { id } = await params;
73
- const def = getPlugin(id);
74
- if (!def || def.category !== 'connector') {
75
- return NextResponse.json({ error: 'connector not found' }, { status: 404 });
76
- }
77
- const inst = getInstalledPlugin(id);
76
+ const def = getConnector(id);
77
+ if (!def) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
78
+ const inst = getInstalledConnector(id);
78
79
  const stored = inst?.config || {};
79
80
  const schema = settingsSchemaFor(id);
80
81
  const merged = { ...defaultsFor(id), ...stored };
@@ -87,10 +88,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
87
88
 
88
89
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
89
90
  const { id } = await params;
90
- const def = getPlugin(id);
91
- if (!def || def.category !== 'connector') {
92
- return NextResponse.json({ error: 'connector not found' }, { status: 404 });
93
- }
91
+ const def = getConnector(id);
92
+ if (!def) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
94
93
 
95
94
  const body = await req.json().catch(() => ({}));
96
95
  const settings = body?.settings ?? body;
@@ -98,15 +97,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
98
97
  return NextResponse.json({ error: 'settings must be an object' }, { status: 400 });
99
98
  }
100
99
 
101
- const existing = getInstalledPlugin(id);
100
+ const existing = getInstalledConnector(id);
102
101
  const schema = settingsSchemaFor(id);
103
102
  const merged = restoreSecrets(settings, existing?.config || {}, schema);
104
- const ok = existing
105
- ? updatePluginConfig(id, merged)
106
- : installPlugin(id, merged);
107
-
108
- if (!ok) {
109
- return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
110
- }
103
+ const ok = setConnectorConfig(id, merged);
104
+ if (!ok) return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
111
105
  return NextResponse.json({ ok: true, settings: maskSecrets(merged, schema) });
112
106
  }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * POST /api/connectors/[id]/test
3
+ *
4
+ * Reachability probe for a connector. Runs the manifest's `test:`
5
+ * block (an HTTP request) against the user's saved settings, returns
6
+ * a structured result for the Settings → Connectors UI to render.
7
+ *
8
+ * {
9
+ * ok: true,
10
+ * status: 200,
11
+ * message: "Authenticated as zliu (Zhen Liu)",
12
+ * duration_ms: 152
13
+ * }
14
+ *
15
+ * On failure:
16
+ *
17
+ * {
18
+ * ok: false,
19
+ * status: 401,
20
+ * error: "Token was revoked. You have to re-authorize from the user.",
21
+ * body_preview: "{...}"
22
+ * }
23
+ *
24
+ * The probe is HTTP-only — connectors that need a different mechanism
25
+ * (browser DOM, shell exec) simply omit `test:` and the UI hides the
26
+ * button.
27
+ */
28
+
29
+ import { NextResponse } from 'next/server';
30
+ import {
31
+ getConnector,
32
+ getConnectorEntries,
33
+ getInstalledConnector,
34
+ } from '@/lib/connectors/registry';
35
+ import { expandSettingsTokens, expandAllTokens } from '@/lib/plugins/templates';
36
+ import { bridgeRpc } from '@/lib/chat/bridge-client';
37
+ import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from '@/lib/connectors/types';
38
+
39
+ const DEFAULT_TIMEOUT_MS = 15_000;
40
+ const MAX_BODY_PREVIEW = 1024;
41
+
42
+ function expandString(s: string, settings: Record<string, unknown>): string {
43
+ return expandAllTokens(s, settings as Record<string, any>, {});
44
+ }
45
+
46
+ function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
47
+ let url = expandString(spec.url, settings);
48
+ if (spec.query) {
49
+ const u = new URL(url);
50
+ for (const [k, raw] of Object.entries(spec.query)) {
51
+ u.searchParams.set(k, expandString(String(raw), settings));
52
+ }
53
+ url = u.toString();
54
+ }
55
+ return url;
56
+ }
57
+
58
+ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, unknown>): Headers {
59
+ const h = new Headers();
60
+ if (spec.headers) {
61
+ for (const [k, raw] of Object.entries(spec.headers)) {
62
+ h.set(k, expandString(String(raw), settings));
63
+ }
64
+ }
65
+ return h;
66
+ }
67
+
68
+ function buildBody(
69
+ spec: HttpRequestSpec,
70
+ settings: Record<string, unknown>,
71
+ ): { body?: string; contentType?: string } {
72
+ if (spec.body == null) return {};
73
+ if (typeof spec.body === 'string') {
74
+ return { body: expandString(spec.body, settings) };
75
+ }
76
+ const out: Record<string, unknown> = {};
77
+ for (const [k, v] of Object.entries(spec.body)) {
78
+ out[k] = typeof v === 'string' ? expandString(v, settings) : v;
79
+ }
80
+ return { body: JSON.stringify(out), contentType: 'application/json' };
81
+ }
82
+
83
+ /**
84
+ * Render `{{path.to.value}}` placeholders against a parsed JSON body.
85
+ * Missing paths render as "?", non-string scalars get stringified.
86
+ */
87
+ function renderTemplate(template: string, body: unknown): string {
88
+ return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
89
+ const path = String(expr).trim().split('.').filter(Boolean);
90
+ let cur: any = body;
91
+ for (const p of path) {
92
+ if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
93
+ else return '?';
94
+ }
95
+ if (cur == null) return '?';
96
+ return typeof cur === 'string' ? cur : JSON.stringify(cur);
97
+ });
98
+ }
99
+
100
+ interface TestResult {
101
+ ok: boolean;
102
+ status?: number;
103
+ message?: string;
104
+ error?: string;
105
+ duration_ms?: number;
106
+ body_preview?: string;
107
+ }
108
+
109
+ async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>): Promise<TestResult> {
110
+ const spec = test.request;
111
+ if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
112
+
113
+ const method = (spec.method || 'GET').toUpperCase();
114
+ const url = buildUrl(spec, settings);
115
+ const headers = buildHeaders(spec, settings);
116
+ const { body, contentType } = buildBody(spec, settings);
117
+ if (body != null && contentType && !headers.has('content-type')) {
118
+ headers.set('content-type', contentType);
119
+ }
120
+
121
+ const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
122
+ const okStatus = test.ok_status?.length ? test.ok_status : [200];
123
+
124
+ const ctrl = new AbortController();
125
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
126
+ const t0 = Date.now();
127
+ let res: Response;
128
+ try {
129
+ res = await fetch(url, { method, headers, body, signal: ctrl.signal });
130
+ } catch (e) {
131
+ clearTimeout(timer);
132
+ const err = e as Error & { cause?: unknown };
133
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
134
+ return {
135
+ ok: false,
136
+ error: `request failed: ${err.message}${cause}`,
137
+ duration_ms: Date.now() - t0,
138
+ };
139
+ }
140
+ clearTimeout(timer);
141
+
142
+ const duration = Date.now() - t0;
143
+ const text = await res.text().catch(() => '');
144
+ const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
145
+
146
+ if (!okStatus.includes(res.status)) {
147
+ let errMsg = `HTTP ${res.status} ${res.statusText}`;
148
+ try {
149
+ const j = JSON.parse(text);
150
+ if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
151
+ else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
152
+ else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
153
+ } catch {}
154
+ return {
155
+ ok: false,
156
+ status: res.status,
157
+ error: errMsg,
158
+ duration_ms: duration,
159
+ body_preview: preview,
160
+ };
161
+ }
162
+
163
+ let parsedBody: unknown = null;
164
+ try { parsedBody = JSON.parse(text); } catch {}
165
+ const message = test.ok_template
166
+ ? renderTemplate(test.ok_template, parsedBody)
167
+ : `OK (HTTP ${res.status})`;
168
+ return {
169
+ ok: true,
170
+ status: res.status,
171
+ message,
172
+ duration_ms: duration,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Browser probe: ask the paired extension to land on the connector's
178
+ * host_match URL and report whether `login_redirect` was hit.
179
+ *
180
+ * The extension implements `connector.probe` (see
181
+ * lib/help-docs/21-build-connector.md for the wire contract). On
182
+ * sites the user is logged in to, it returns
183
+ * { ok: true, url: '<final tab URL>' }
184
+ * On a redirect to the login page it returns
185
+ * { ok: false, error: 'login required', url: '<login url>' }
186
+ * On extension absence we throw, caller surfaces it.
187
+ */
188
+ async function runBrowserProbe(
189
+ def: ConnectorDefinition,
190
+ settings: Record<string, unknown>,
191
+ ): Promise<TestResult> {
192
+ const entries = getConnectorEntries(def);
193
+ const entry = entries[0];
194
+ const hostMatch = def.host_match || entry?.host_match;
195
+ const loginRedirect = def.login_redirect || entry?.login_redirect;
196
+ if (!hostMatch) {
197
+ return { ok: false, error: 'browser probe requires host_match on the manifest' };
198
+ }
199
+ const expandedHost = expandSettingsTokens(hostMatch, settings as any);
200
+ const expandedLoginRedirect = loginRedirect
201
+ ? expandSettingsTokens(loginRedirect, settings as any)
202
+ : undefined;
203
+
204
+ const t0 = Date.now();
205
+ let value: unknown;
206
+ try {
207
+ value = await bridgeRpc('connector.probe', {
208
+ pluginId: def.id,
209
+ host_match: expandedHost,
210
+ login_redirect: expandedLoginRedirect,
211
+ runner: def.runner || entry?.runner || 'main',
212
+ timeout_ms: def.test?.timeout_ms || 30_000,
213
+ });
214
+ } catch (e) {
215
+ const raw = (e as Error).message || String(e);
216
+ let friendly = raw;
217
+ if (raw.includes('unknown method: connector.probe')) {
218
+ friendly =
219
+ 'Your Forge browser extension is out of date — it doesn\'t know how to run browser probes yet. ' +
220
+ 'Rebuild the extension (pnpm ext in forge-browser-extension), then chrome://extensions → Reload, and try Test again.';
221
+ } else if (raw.includes('bridge') && raw.includes('unreachable')) {
222
+ friendly =
223
+ 'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
224
+ } else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
225
+ friendly =
226
+ 'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
227
+ }
228
+ return {
229
+ ok: false,
230
+ error: friendly,
231
+ duration_ms: Date.now() - t0,
232
+ };
233
+ }
234
+ const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
235
+ return {
236
+ ok: !!r.ok,
237
+ message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
238
+ error: r.ok ? undefined : (r.error || 'login required'),
239
+ duration_ms: Date.now() - t0,
240
+ };
241
+ }
242
+
243
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
244
+ const { id } = await params;
245
+ const def = getConnector(id);
246
+ if (!def) return NextResponse.json({ ok: false, error: 'connector not found' }, { status: 404 });
247
+ if (!def.test) {
248
+ return NextResponse.json({ ok: false, error: 'connector has no test block' }, { status: 400 });
249
+ }
250
+ const inst = getInstalledConnector(id);
251
+ if (!inst) {
252
+ return NextResponse.json({ ok: false, error: 'connector not installed' }, { status: 400 });
253
+ }
254
+
255
+ const probe = def.test.probe || 'http';
256
+ const r = probe === 'browser'
257
+ ? await runBrowserProbe(def, inst.config)
258
+ : await runHttpProbe(def.test, inst.config);
259
+ return NextResponse.json(r);
260
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * POST /api/connectors/install-local
3
+ *
4
+ * Install a connector from a user-supplied manifest. Two flavours:
5
+ *
6
+ * multipart/form-data with field `file`:
7
+ * - .yaml / .yml → single-file manifest
8
+ * - .zip → multi-file bundle (must contain manifest.yaml
9
+ * at the root; may add README.md, icon.svg,
10
+ * tools/*.js, etc.)
11
+ *
12
+ * application/json body `{ yaml: "...", id?: "..." }`:
13
+ * - convenience path for callers that already have the YAML in
14
+ * memory (Forge Help AI, internal scripts). `id` is optional
15
+ * — if missing, parsed from the manifest.
16
+ *
17
+ * Behaviour:
18
+ * 1. Validate the manifest has `id`, `name`, and at least `tools` or
19
+ * `connectors[]`.
20
+ * 2. Write the manifest (and any zip siblings) to
21
+ * <dataDir>/connectors/<id>/.
22
+ * 3. Register/upsert the row in connector-configs.json with the
23
+ * manifest's version as installed_version, preserving any prior
24
+ * user-supplied settings (so re-installing a tweaked version
25
+ * doesn't blow away a PAT).
26
+ *
27
+ * Marketplace UI distinguishes "local" connectors from "registry"
28
+ * connectors by membership in the registry cache — no extra source
29
+ * field on disk.
30
+ */
31
+
32
+ import { NextResponse } from 'next/server';
33
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
34
+ import { join, normalize, sep } from 'node:path';
35
+ import AdmZip from 'adm-zip';
36
+ import YAML from 'yaml';
37
+ import { getDataDir } from '@/lib/dirs';
38
+ import { installConnector } from '@/lib/connectors/registry';
39
+
40
+ const MAX_ZIP_BYTES = 5 * 1024 * 1024; // 5 MB
41
+ const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB per file inside the zip
42
+ const MAX_FILES_IN_ZIP = 50;
43
+
44
+ interface ParsedManifest {
45
+ id: string;
46
+ name: string;
47
+ version?: string;
48
+ }
49
+
50
+ function parseManifest(yaml: string): ParsedManifest {
51
+ let def: any;
52
+ try {
53
+ def = YAML.parse(yaml);
54
+ } catch (e) {
55
+ throw new Error(`invalid YAML: ${(e as Error).message}`);
56
+ }
57
+ if (!def?.id || typeof def.id !== 'string') {
58
+ throw new Error('manifest.id is required');
59
+ }
60
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(def.id)) {
61
+ throw new Error(`manifest.id "${def.id}" must be lowercase alphanumerics + hyphens/underscores`);
62
+ }
63
+ if (!def.name || typeof def.name !== 'string') {
64
+ throw new Error('manifest.name is required');
65
+ }
66
+ if (!def.tools && !(Array.isArray(def.connectors) && def.connectors.length)) {
67
+ throw new Error('manifest must declare `tools` or `connectors[]`');
68
+ }
69
+ return { id: def.id, name: def.name, version: def.version };
70
+ }
71
+
72
+ function safeEntryPath(name: string): string | null {
73
+ // Reject absolute paths, parent-dir escapes, leading slashes.
74
+ if (!name || name.startsWith('/') || name.includes('..')) return null;
75
+ const normalized = normalize(name).replace(/^[/\\]+/, '');
76
+ if (normalized.split(sep).some((seg) => seg === '..' || seg === '')) return null;
77
+ return normalized;
78
+ }
79
+
80
+ function ensureDir(p: string): void {
81
+ if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
82
+ }
83
+
84
+ interface InstallResult {
85
+ ok: boolean;
86
+ id?: string;
87
+ version?: string;
88
+ files_written?: number;
89
+ error?: string;
90
+ }
91
+
92
+ function installFromFiles(
93
+ manifestYaml: string,
94
+ extraFiles: Array<{ path: string; data: Buffer }>,
95
+ ): InstallResult {
96
+ let parsed: ParsedManifest;
97
+ try { parsed = parseManifest(manifestYaml); }
98
+ catch (e) { return { ok: false, error: (e as Error).message }; }
99
+
100
+ const dir = join(getDataDir(), 'connectors', parsed.id);
101
+ ensureDir(dir);
102
+
103
+ // Wipe stale siblings from a prior install of the same id (but
104
+ // preserve nothing — local install replaces the directory).
105
+ // For safety, we only write inside `dir`; safeEntryPath gated it.
106
+
107
+ writeFileSync(join(dir, 'manifest.yaml'), manifestYaml, { mode: 0o600 });
108
+ let written = 1;
109
+ for (const f of extraFiles) {
110
+ const target = join(dir, f.path);
111
+ ensureDir(join(target, '..'));
112
+ writeFileSync(target, f.data, { mode: 0o600 });
113
+ written += 1;
114
+ }
115
+
116
+ const ok = installConnector(parsed.id, manifestYaml);
117
+ if (!ok) {
118
+ return { ok: false, error: 'installConnector rejected the manifest (re-parse failed)' };
119
+ }
120
+ return {
121
+ ok: true,
122
+ id: parsed.id,
123
+ version: parsed.version || '0.0.0',
124
+ files_written: written,
125
+ };
126
+ }
127
+
128
+ export async function POST(req: Request) {
129
+ const ct = req.headers.get('content-type') || '';
130
+
131
+ // ── JSON path: { yaml: "...", id?: "..." } ─────────────────
132
+ if (ct.includes('application/json')) {
133
+ let body: any = {};
134
+ try { body = await req.json(); } catch {}
135
+ const yaml = typeof body?.yaml === 'string' ? body.yaml : '';
136
+ if (!yaml) {
137
+ return NextResponse.json({ ok: false, error: 'yaml field is required' }, { status: 400 });
138
+ }
139
+ const r = installFromFiles(yaml, []);
140
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
141
+ }
142
+
143
+ // ── multipart: file = .yaml | .yml | .zip ──────────────────
144
+ if (ct.includes('multipart/form-data')) {
145
+ let form: FormData;
146
+ try { form = await req.formData(); }
147
+ catch (e) {
148
+ return NextResponse.json({ ok: false, error: 'invalid multipart body' }, { status: 400 });
149
+ }
150
+ const file = form.get('file');
151
+ if (!(file instanceof File)) {
152
+ return NextResponse.json({ ok: false, error: 'file field missing' }, { status: 400 });
153
+ }
154
+ if (file.size > MAX_ZIP_BYTES) {
155
+ return NextResponse.json({ ok: false, error: `file too large (max ${MAX_ZIP_BYTES} bytes)` }, { status: 413 });
156
+ }
157
+
158
+ const name = file.name.toLowerCase();
159
+ const buf = Buffer.from(await file.arrayBuffer());
160
+
161
+ if (name.endsWith('.yaml') || name.endsWith('.yml')) {
162
+ const r = installFromFiles(buf.toString('utf-8'), []);
163
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
164
+ }
165
+
166
+ if (name.endsWith('.zip')) {
167
+ let zip: AdmZip;
168
+ try { zip = new AdmZip(buf); }
169
+ catch (e) {
170
+ return NextResponse.json({ ok: false, error: `invalid zip: ${(e as Error).message}` }, { status: 400 });
171
+ }
172
+ const entries = zip.getEntries().filter((e) => !e.isDirectory);
173
+ if (entries.length === 0) {
174
+ return NextResponse.json({ ok: false, error: 'zip is empty' }, { status: 400 });
175
+ }
176
+ if (entries.length > MAX_FILES_IN_ZIP) {
177
+ return NextResponse.json({ ok: false, error: `zip has too many files (max ${MAX_FILES_IN_ZIP})` }, { status: 400 });
178
+ }
179
+
180
+ // Locate manifest.yaml at the root.
181
+ const manifestEntry = entries.find((e) => {
182
+ const safe = safeEntryPath(e.entryName);
183
+ return safe === 'manifest.yaml' || safe === 'manifest.yml';
184
+ });
185
+ if (!manifestEntry) {
186
+ return NextResponse.json({ ok: false, error: 'zip must contain manifest.yaml at the root' }, { status: 400 });
187
+ }
188
+ const manifestYaml = manifestEntry.getData().toString('utf-8');
189
+
190
+ const extras: Array<{ path: string; data: Buffer }> = [];
191
+ for (const e of entries) {
192
+ if (e === manifestEntry) continue;
193
+ const safe = safeEntryPath(e.entryName);
194
+ if (!safe) {
195
+ return NextResponse.json({ ok: false, error: `unsafe path in zip: ${e.entryName}` }, { status: 400 });
196
+ }
197
+ const data = e.getData();
198
+ if (data.length > MAX_FILE_BYTES) {
199
+ return NextResponse.json({ ok: false, error: `entry too large: ${safe}` }, { status: 413 });
200
+ }
201
+ extras.push({ path: safe, data });
202
+ }
203
+ const r = installFromFiles(manifestYaml, extras);
204
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
205
+ }
206
+
207
+ return NextResponse.json({ ok: false, error: 'unsupported file extension (expected .yaml/.yml/.zip)' }, { status: 400 });
208
+ }
209
+
210
+ return NextResponse.json({ ok: false, error: 'expected JSON or multipart/form-data' }, { status: 400 });
211
+ }