@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
package/CLAUDE.md CHANGED
@@ -70,7 +70,7 @@ Each forked service is spawned with a `--forge-port=<webPort>` instance tag. `fo
70
70
  ### Dev mode vs production mode
71
71
  - `FORGE_EXTERNAL_SERVICES=1` tells Next.js (`lib/init.ts`) **not** to spawn terminal/telegram/workspace — `forge-server.mjs` manages them. This is set automatically by `forge-server.mjs` in prod/background/dev.
72
72
  - Plain `pnpm dev` (or `./dev-test.sh`) sets it to `0` — `lib/init.ts` becomes the process supervisor and spawns the standalones itself. Use this when iterating on Next.js only.
73
- - `FORGE_DEV=1` (set by `./start.sh dev`) **bypasses login in `middleware.ts`** — never set in production.
73
+ - `FORGE_DEV=1` (set by `./start.sh dev`) **bypasses login in `proxy.ts`** (Next.js 16+ convention; the file was `middleware.ts` pre-16) — never set in production.
74
74
 
75
75
  ### Data layout (`lib/dirs.ts`)
76
76
  - `getConfigDir()` → `~/.forge/` — shared across instances, holds `bin/` (cloudflared).
package/RELEASE_NOTES.md CHANGED
@@ -1,11 +1,8 @@
1
- # Forge v0.10.40
1
+ # Forge v0.10.42
2
2
 
3
- Released: 2026-06-05
3
+ Released: 2026-06-07
4
4
 
5
- ## Changes since v0.10.39
5
+ ## Changes since v0.10.41
6
6
 
7
- ### Other
8
- - fix(server): portable port-pid lookup so Linux without lsof can stop (#33)
9
7
 
10
-
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.39...v0.10.40
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.41...v0.10.42
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Bridge-info — exposes the browser-bridge port so the Forge extension
3
+ * can derive the right WebSocket URL when connecting to a non-default
4
+ * Forge instance.
5
+ *
6
+ * The default prod port is 8407, but dev-test (4007) or any custom
7
+ * deployment (--port + offset) lives on a different one. The extension
8
+ * used to hardcode 8407, which silently routed all WS traffic to a
9
+ * neighbouring Forge instance whenever the user pointed at port 4000.
10
+ *
11
+ * Public read endpoint — no auth required. The bridge port is not a
12
+ * secret; the bridge itself still requires a valid Forge token on the
13
+ * WS hello frame before serving traffic.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+
18
+ export const runtime = 'nodejs';
19
+ export const dynamic = 'force-dynamic';
20
+
21
+ export function GET(req: Request) {
22
+ const url = new URL(req.url);
23
+ const port = Number(process.env.BRIDGE_PORT) || 8407;
24
+ // Caller-visible host: prefer the host header so extensions get the
25
+ // right address whether they reached Forge via localhost, IP, or
26
+ // tunnel hostname. Strip the original port — the WS host stays the
27
+ // same, only the port changes.
28
+ const host = (req.headers.get('host') || url.host).split(':')[0];
29
+ const proto = url.protocol === 'https:' ? 'wss:' : 'ws:';
30
+ return NextResponse.json({
31
+ bridge_port: port,
32
+ ws_url: `${proto}//${host}:${port}/ws`,
33
+ });
34
+ }
@@ -8,12 +8,26 @@
8
8
 
9
9
  import { NextResponse } from 'next/server';
10
10
  import { runConnectorTest } from '@/lib/connectors/test-runner';
11
+ import { setCachedResult } from '@/lib/auth/login-status';
11
12
 
12
13
  export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
13
14
  const { id } = await params;
14
15
  try {
15
16
  const r = await runConnectorTest(id);
16
17
  const { code, ...body } = r;
18
+ // Mirror the fresh probe into the login-status cache so the panel
19
+ // doesn't keep showing a stale 401 after the user fixed the token.
20
+ // Test button = manual user-triggered probe of the same auth surface
21
+ // Login Status renders, so they should agree.
22
+ try {
23
+ setCachedResult(`connector:${id}`, {
24
+ ok: !!body.ok,
25
+ message: body.ok ? (body.message || 'ok') : (body.error || `HTTP ${body.status || '?'}`),
26
+ landed_url: body.url,
27
+ checked_at: Date.now(),
28
+ duration_ms: body.duration_ms ?? 0,
29
+ });
30
+ } catch { /* best-effort; cache write failure shouldn't fail the Test response */ }
17
31
  return NextResponse.json(body, code ? { status: code } : undefined);
18
32
  } catch (e) {
19
33
  // Belt-and-suspenders: never let an uncaught exception bubble to
@@ -26,7 +26,6 @@
26
26
  */
27
27
 
28
28
  import { NextResponse } from 'next/server';
29
- import { existsSync, readFileSync } from 'node:fs';
30
29
  import { join } from 'node:path';
31
30
  import { getDataDir } from '@/lib/dirs';
32
31
  import { loadSettings } from '@/lib/settings';
@@ -37,22 +36,33 @@ import {
37
36
  setConnectorEnabled,
38
37
  } from '@/lib/connectors/registry';
39
38
  import { installFromRegistry } from '@/lib/connectors/sync';
39
+ import { resolveWizardTemplate } from '@/lib/connectors/wizard-template';
40
+ import { decryptDeep } from '@/lib/enterprise-secret';
41
+ import type { AgentEntry } from '@/lib/settings';
42
+ import { saveSettings } from '@/lib/settings';
40
43
  // 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.
44
+ // works out of the box without any extra setup. The shared resolver
45
+ // handles the user-override + enterprise + public tiers; this stays as
46
+ // the final safety net when none of those have a cached template.
43
47
  import bundledTemplate from '@/templates/connector-config-template.json';
44
48
 
45
49
  const LOCAL_TEMPLATE_FILE = 'config-template.json';
46
50
 
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
- }
51
+ /**
52
+ * Resolve template source through the cross-marketplace resolver, then
53
+ * fall back to the bundled JSON. The legacy `source` tag (`local | bundled`)
54
+ * is broadened to also surface `enterprise-<tenant>` / `public` so the
55
+ * UI can tell users which source provided the wizard.
56
+ */
57
+ function resolveTemplate(): { source: string; template: any; path: string | null } {
58
+ const resolved = resolveWizardTemplate();
59
+ if (resolved) {
60
+ // Map the resolver's 'user' tag back to the legacy 'local' name so
61
+ // existing UI that switches on it keeps working. Enterprise / public
62
+ // pass through unchanged.
63
+ const sourceTag = resolved.source === 'user' ? 'local' : resolved.source;
64
+ const path = resolved.source === 'user' ? join(getDataDir(), LOCAL_TEMPLATE_FILE) : null;
65
+ return { source: sourceTag, template: resolved.template, path };
56
66
  }
57
67
  return { source: 'bundled', template: bundledTemplate, path: null };
58
68
  }
@@ -145,9 +155,20 @@ function analyze(template: any): {
145
155
  pending: PendingPrompt[];
146
156
  static_apply_targets: string[];
147
157
  missing_manifests: string[];
158
+ agents_preview?: Array<{ id: string; tool?: string; has_secret: boolean }>;
148
159
  } {
149
160
  const promptsDict: Record<string, PromptDef> = template._prompts || {};
150
161
 
162
+ // Agents preview — what the import will add to settings.agents
163
+ const agentsPreview: Array<{ id: string; tool?: string; has_secret: boolean }> = [];
164
+ if (template._agents && typeof template._agents === 'object') {
165
+ for (const [agentId, raw] of Object.entries(template._agents as Record<string, any>)) {
166
+ const env = (raw?.env ?? {}) as Record<string, unknown>;
167
+ const has_secret = Object.values(env).some((v) => typeof v === 'string' && v.startsWith('ent:'));
168
+ agentsPreview.push({ id: agentId, tool: raw?.tool, has_secret });
169
+ }
170
+ }
171
+
151
172
  // Map: placeholder_key → Set of "connector.field_path"
152
173
  const usage = new Map<string, Set<string>>();
153
174
  const staticTargets: string[] = [];
@@ -185,7 +206,7 @@ function analyze(template: any): {
185
206
  || a.label.localeCompare(b.label),
186
207
  );
187
208
 
188
- return { pending, static_apply_targets: staticTargets, missing_manifests: missing };
209
+ return { pending, static_apply_targets: staticTargets, missing_manifests: missing, agents_preview: agentsPreview.length ? agentsPreview : undefined };
189
210
  }
190
211
 
191
212
  // ─── Apply ────────────────────────────────────────────────
@@ -197,6 +218,7 @@ async function apply(template: any, values: Record<string, string>): Promise<{
197
218
  skipped_missing_manifest: string[];
198
219
  fields_preserved: Array<{ connector: string; field: string; reason: string }>;
199
220
  fields_left_empty: Array<{ connector: string; field: string }>;
221
+ agents_applied?: string[];
200
222
  }> {
201
223
  // Auto-inject user identity from settings so connectors (e.g. tp.username)
202
224
  // can use {user_name} / {user_email} without prompting the user again.
@@ -208,6 +230,45 @@ async function apply(template: any, values: Record<string, string>): Promise<{
208
230
  ...values,
209
231
  };
210
232
 
233
+ // _derive — compute extra values from existing ones, so the template can
234
+ // reference them like any other ${placeholder}. Each entry maps:
235
+ // "derived_key": "source_key" → derive from values[source_key]
236
+ // "derived_key": { from: "x", op: "email_local" } → fancier
237
+ //
238
+ // Built-in op: 'email_local' = split @, take first half. Default op
239
+ // (when value is a bare string) is also 'email_local' if the source
240
+ // contains '@', otherwise a verbatim copy. Keeps template authors
241
+ // honest — no full eval, just a couple of recipes.
242
+ const deriveDict = (template?._derive ?? {}) as Record<string, unknown>;
243
+ for (const [derivedKey, spec] of Object.entries(deriveDict)) {
244
+ if (values[derivedKey]) continue; // user already provided → respect it
245
+ let sourceKey: string;
246
+ let op = 'auto';
247
+ if (typeof spec === 'string') {
248
+ sourceKey = spec;
249
+ } else if (spec && typeof spec === 'object' && typeof (spec as any).from === 'string') {
250
+ sourceKey = (spec as any).from;
251
+ op = String((spec as any).op || 'auto');
252
+ } else {
253
+ continue;
254
+ }
255
+ const raw = values[sourceKey] ?? '';
256
+ if (!raw) continue;
257
+ let derived = raw;
258
+ if (op === 'email_local' || (op === 'auto' && raw.includes('@'))) {
259
+ derived = raw.split('@')[0];
260
+ }
261
+ values[derivedKey] = derived;
262
+ }
263
+
264
+ // Decrypt any `ent:…` blobs anywhere in the template up front. Enterprise
265
+ // templates declare their key name via `_enterprise_name` (e.g. "fortinet");
266
+ // anything below uses the plaintext as if it had been there all along.
267
+ const enterpriseName = (template?._enterprise_name as string | undefined) || '';
268
+ if (enterpriseName) {
269
+ template = decryptDeep(template, enterpriseName);
270
+ }
271
+
211
272
  const applied: string[] = [];
212
273
  const installedFromRegistry: string[] = [];
213
274
  const enabledChanged: string[] = [];
@@ -270,6 +331,34 @@ async function apply(template: any, values: Record<string, string>): Promise<{
270
331
  }
271
332
  }
272
333
 
334
+ // ─── _agents block — pre-baked CLI agent presets ───────────
335
+ //
336
+ // Enterprise templates can declare claude-code / codex / aider
337
+ // agent entries (with env vars, custom flags) that get merged into
338
+ // `settings.agents`. Existing entries with the same id are kept
339
+ // unless the template forces an overwrite by setting `_overwrite: true`
340
+ // on that agent — by default we preserve user customisation.
341
+ const agentsApplied: string[] = [];
342
+ const agentsDict = template._agents as Record<string, any> | undefined;
343
+ if (agentsDict && typeof agentsDict === 'object') {
344
+ const freshSettings = loadSettings();
345
+ const currentAgents = { ...(freshSettings.agents || {}) };
346
+ let changed = false;
347
+ for (const [agentId, raw] of Object.entries(agentsDict)) {
348
+ if (!raw || typeof raw !== 'object') continue;
349
+ const existing = currentAgents[agentId];
350
+ const overwrite = (raw as any)._overwrite === true;
351
+ if (existing && !overwrite) continue; // preserve user's tweaks
352
+ const { _overwrite, ...entry } = raw as Record<string, unknown>;
353
+ currentAgents[agentId] = entry as AgentEntry;
354
+ agentsApplied.push(agentId);
355
+ changed = true;
356
+ }
357
+ if (changed) {
358
+ saveSettings({ ...freshSettings, agents: currentAgents });
359
+ }
360
+ }
361
+
273
362
  return {
274
363
  applied,
275
364
  installed_from_registry: installedFromRegistry,
@@ -277,6 +366,7 @@ async function apply(template: any, values: Record<string, string>): Promise<{
277
366
  skipped_missing_manifest: missing,
278
367
  fields_preserved: preserved,
279
368
  fields_left_empty: leftEmpty,
369
+ agents_applied: agentsApplied,
280
370
  };
281
371
  }
282
372
 
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Enterprise Keys API — manage marketplace source overlays.
3
+ *
4
+ * GET /api/enterprise-keys
5
+ * → { sources: [{ tenant_id, display_name, repo_url, priority, pat_preview }] }
6
+ * Read-only view. PATs are masked.
7
+ *
8
+ * POST /api/enterprise-keys
9
+ * body: { key: string } ← short `<tenant>:<pat>` or long `<pat>@<repo>`
10
+ * → { ok: true, tenant_id } or { ok: false, error }
11
+ * Persists the new key and triggers a one-shot connector + workflow sync
12
+ * so the marketplace immediately reflects the new source.
13
+ *
14
+ * DELETE /api/enterprise-keys?tenant_id=<id>
15
+ * → { ok: true } or { ok: false, error }
16
+ * Removes the key. Installed connectors/workflows STAY on disk (with
17
+ * their last-known manifests) — the user still has them, just no more
18
+ * updates from this source.
19
+ *
20
+ * Auth handled by middleware. All operations are local; the key value is
21
+ * encrypted at rest with .encrypt-key. Never log raw keys.
22
+ */
23
+
24
+ import { NextResponse } from 'next/server';
25
+ import {
26
+ listEnterpriseSources,
27
+ addEnterpriseKey,
28
+ removeEnterpriseKey,
29
+ updateEnterpriseKey,
30
+ } from '@/lib/enterprise';
31
+ import { syncRegistry, getLastSync } from '@/lib/connectors/sync';
32
+ import { syncMarketplace as syncWorkflows } from '@/lib/workflow-marketplace';
33
+ import { syncSkills } from '@/lib/skills';
34
+
35
+ function patPreview(pat: string): string {
36
+ if (!pat) return '';
37
+ if (pat.length <= 12) return pat.slice(0, 4) + '…';
38
+ return pat.slice(0, 7) + '…' + pat.slice(-4);
39
+ }
40
+
41
+ // Enterprise source id is `enterprise-<tenant_id>` per lib/enterprise.ts.
42
+ // Keep this lookup in sync with that convention.
43
+ function sourceIdFor(tenantId: string): string {
44
+ return `enterprise-${tenantId}`;
45
+ }
46
+
47
+ export async function GET() {
48
+ // E3: per-tenant dept list so the Settings UI can show
49
+ // "fortinet · FortiNAC, FortiADC" instead of just the tenant name.
50
+ // listSourceDepartments reads <sourceId>/wizards/_index.json which
51
+ // sync writes when the registry advertises wizard_templates.
52
+ const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
53
+ const { listInstalledConnectors } = await import('@/lib/connectors/registry');
54
+ // Active dept = the dept stamped on any connector installed from
55
+ // this tenant (setConnectorDept writes _department_name during
56
+ // applyConnectors). All connectors from one apply share the same
57
+ // dept; using the first hit per tenant is fine.
58
+ const installed = listInstalledConnectors();
59
+ const activeDeptByTenant: Record<string, string> = {};
60
+ for (const c of installed) {
61
+ if (!c.installed_source_id || !c.installed_dept) continue;
62
+ if (!activeDeptByTenant[c.installed_source_id]) {
63
+ activeDeptByTenant[c.installed_source_id] = c.installed_dept;
64
+ }
65
+ }
66
+ const sources = listEnterpriseSources().map((s) => {
67
+ const sourceId = sourceIdFor(s.tenant_id);
68
+ const last = getLastSync(sourceId);
69
+ const departments = listSourceDepartments(sourceId);
70
+ const activeDeptName = activeDeptByTenant[sourceId] || null;
71
+ const active_dept = activeDeptName
72
+ ? departments.find(d => d.display_name === activeDeptName || d.id === activeDeptName.toLowerCase())?.id ?? null
73
+ : null;
74
+ return {
75
+ tenant_id: s.tenant_id,
76
+ display_name: s.display_name,
77
+ repo_url: s.repo_url,
78
+ priority: s.priority,
79
+ pat_preview: patPreview(s.github_pat),
80
+ last_sync: last,
81
+ departments,
82
+ active_dept,
83
+ };
84
+ });
85
+ return NextResponse.json({ sources });
86
+ }
87
+
88
+ export async function POST(req: Request) {
89
+ let body: any = {};
90
+ try { body = await req.json(); } catch {}
91
+
92
+ // action=resync — re-run syncRegistry against all sources and return the
93
+ // per-source result so the panel can flag stuck sources without waiting
94
+ // for the next marketplace fetch.
95
+ if (body?.action === 'resync') {
96
+ // refreshInstalled=true → also re-pull each installed connector's
97
+ // manifest from the highest-priority source. force=true → re-pull
98
+ // even when the cached registry version matches the installed one
99
+ // (the only way to flip a connector from public→enterprise when
100
+ // both sources publish the same version string).
101
+ const refreshInstalled = !!body?.refreshInstalled;
102
+ const force = !!body?.force;
103
+ try {
104
+ // Re-sync covers ALL data kinds from every source so the user
105
+ // doesn't have to chase per-tab sync buttons:
106
+ // - connectors: registry.json + (when refreshInstalled) manifests
107
+ // - workflows: pipelines + recipes
108
+ // - skills: registry + per-skill metadata
109
+ // syncWorkflows / syncSkills are best-effort — a flaky upstream
110
+ // shouldn't fail the whole resync.
111
+ const result = await syncRegistry({ refreshInstalled, force });
112
+ try { await syncWorkflows(); } catch (e) { console.warn('[enterprise-keys] workflows sync after resync failed:', e); }
113
+ try { await syncSkills(); } catch (e) { console.warn('[enterprise-keys] skills sync after resync failed:', e); }
114
+
115
+ return NextResponse.json({ ok: true, result });
116
+ } catch (e) {
117
+ return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : String(e) }, { status: 500 });
118
+ }
119
+ }
120
+
121
+ const key = String(body?.key || '').trim();
122
+ if (!key) return NextResponse.json({ ok: false, error: 'key is required' }, { status: 400 });
123
+
124
+ const r = addEnterpriseKey(key);
125
+ if (!r.ok) return NextResponse.json(r, { status: 400 });
126
+
127
+ // Trigger sync so the newly-added source is reflected in the marketplace
128
+ // without the user having to click Refresh. Best-effort: ignore errors —
129
+ // a network blip shouldn't fail the key-add itself.
130
+ try {
131
+ await syncRegistry({ refreshInstalled: false });
132
+ await syncWorkflows();
133
+ } catch (e) {
134
+ console.warn('[enterprise-keys] post-add sync failed (best-effort):', e);
135
+ }
136
+
137
+ // E4: detect whether the newly-added source landed a wizard template
138
+ // on disk so the frontend can hand off to /onboarding scoped to this
139
+ // tenant. existsSync against both the legacy single-template file and
140
+ // the multi-dept index covers the E3 split.
141
+ const sourceId = `enterprise-${r.tenant_id}`;
142
+ let hasWizardTemplate = false;
143
+ try {
144
+ const { existsSync } = await import('node:fs');
145
+ const { join } = await import('node:path');
146
+ const { getDataDir } = await import('@/lib/dirs');
147
+ const base = join(getDataDir(), 'connectors', 'sources', sourceId);
148
+ hasWizardTemplate = existsSync(join(base, 'wizard-template.json'))
149
+ || existsSync(join(base, 'wizards', '_index.json'));
150
+ } catch {}
151
+
152
+ return NextResponse.json({
153
+ ok: true,
154
+ tenant_id: r.tenant_id,
155
+ source_id: sourceId,
156
+ has_wizard_template: hasWizardTemplate,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * PATCH /api/enterprise-keys?tenant_id=<id>
162
+ * body: { key: string }
163
+ * → { ok: true } or { ok: false, error }
164
+ * Replaces the key for `tenant_id` in place (the new key's parsed
165
+ * tenant_id must match). Useful for PAT rotation without losing
166
+ * priority/order. Re-syncs after success so the new auth is used
167
+ * immediately.
168
+ */
169
+ export async function PATCH(req: Request) {
170
+ const url = new URL(req.url);
171
+ const tenant_id = (url.searchParams.get('tenant_id') || '').trim();
172
+ if (!tenant_id) return NextResponse.json({ ok: false, error: 'tenant_id is required' }, { status: 400 });
173
+
174
+ let body: any = {};
175
+ try { body = await req.json(); } catch {}
176
+ const key = String(body?.key || '').trim();
177
+ if (!key) return NextResponse.json({ ok: false, error: 'key is required' }, { status: 400 });
178
+
179
+ const r = updateEnterpriseKey(tenant_id, key);
180
+ if (!r.ok) return NextResponse.json(r, { status: 400 });
181
+
182
+ try {
183
+ await syncRegistry({ refreshInstalled: false });
184
+ await syncWorkflows();
185
+ } catch (e) {
186
+ console.warn('[enterprise-keys] post-update sync failed (best-effort):', e);
187
+ }
188
+
189
+ return NextResponse.json({ ok: true });
190
+ }
191
+
192
+ export async function DELETE(req: Request) {
193
+ const url = new URL(req.url);
194
+ const tenant_id = (url.searchParams.get('tenant_id') || '').trim();
195
+ if (!tenant_id) return NextResponse.json({ ok: false, error: 'tenant_id is required' }, { status: 400 });
196
+ const removed = removeEnterpriseKey(tenant_id);
197
+ if (!removed) return NextResponse.json({ ok: false, error: `no key for tenant '${tenant_id}'` }, { status: 404 });
198
+
199
+ // No re-sync on delete — installed manifests stay valid; UI's next refresh
200
+ // will repopulate the cache list. Skipping the network call keeps Remove
201
+ // snappy.
202
+
203
+ return NextResponse.json({ ok: true });
204
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * POST /api/marketplace/sync-all
3
+ *
4
+ * Single entry point that refreshes every kind of marketplace data
5
+ * from every configured source (public + each enterprise tenant):
6
+ * - connector registry + manifests + wizard templates
7
+ * - workflows (pipelines + recipes)
8
+ * - skills
9
+ *
10
+ * Replaces the per-tab Sync buttons that each only refreshed one type
11
+ * (and silently skipped enterprise sources for skills). Settings →
12
+ * Re-sync registry calls this same endpoint.
13
+ */
14
+
15
+ import { NextResponse } from 'next/server';
16
+ import { syncAll } from '@/lib/marketplace-sync';
17
+
18
+ export async function POST() {
19
+ try {
20
+ const r = await syncAll();
21
+ return NextResponse.json(r, { status: r.ok ? 200 : 207 });
22
+ } catch (e) {
23
+ return NextResponse.json(
24
+ { ok: false, error: (e as Error).message },
25
+ { status: 500 },
26
+ );
27
+ }
28
+ }
@@ -7,25 +7,48 @@ function run(cmd: string): string {
7
7
  } catch { return ''; }
8
8
  }
9
9
 
10
+ // Scope every ps grep to THIS instance — standalones spawned by init.ts
11
+ // (or forge-server.mjs) carry `--forge-port=<webPort>` so dev-test on
12
+ // port 4000 doesn't surface the production Forge processes from 8403
13
+ // and vice versa.
14
+ const INSTANCE_TAG = `--forge-port=${process.env.PORT || 8403}`;
15
+
10
16
  function countProcess(pattern: string): { count: number; pid: string; startedAt: string } {
11
- const out = run(`ps aux | grep '${pattern}' | grep -v grep | head -1`);
17
+ // grep BOTH the binary pattern AND the per-instance tag. Excludes the
18
+ // grep processes themselves via `grep -v grep`.
19
+ const filter = `ps aux | grep '${pattern}' | grep -F -- '${INSTANCE_TAG}' | grep -v grep`;
20
+ const out = run(`${filter} | head -1`);
21
+ const pid = out ? out.split(/\s+/)[1] || '' : '';
22
+ const count = out ? run(`${filter} | wc -l`).trim() : '0';
23
+ const startedAt = pid ? run(`ps -o lstart= -p ${pid} 2>/dev/null`).trim() : '';
24
+ return { count: parseInt(count), pid, startedAt };
25
+ }
26
+
27
+ // Processes that don't carry --forge-port in their command line:
28
+ // - next-server: bare "next-server (vX.Y.Z)" — we ARE next-server when
29
+ // this handler runs, so reporting it as down would always be a lie.
30
+ // - cloudflared: third-party binary, no tag.
31
+ // For these, fall back to a tag-less ps grep so the panel reflects reality.
32
+ function countProcessUntagged(pattern: string): { count: number; pid: string; startedAt: string } {
33
+ const filter = `ps aux | grep '${pattern}' | grep -v grep`;
34
+ const out = run(`${filter} | head -1`);
12
35
  const pid = out ? out.split(/\s+/)[1] || '' : '';
13
- const count = out ? run(`ps aux | grep '${pattern}' | grep -v grep | wc -l`).trim() : '0';
14
- // Get start time from ps
36
+ const count = out ? run(`${filter} | wc -l`).trim() : '0';
15
37
  const startedAt = pid ? run(`ps -o lstart= -p ${pid} 2>/dev/null`).trim() : '';
16
38
  return { count: parseInt(count), pid, startedAt };
17
39
  }
18
40
 
19
41
  export async function GET() {
20
- // Processes
21
- const nextjs = countProcess('next-server');
42
+ // next-server: we ARE next-server when this runs — report self.
43
+ const nextjs = { count: 1, pid: String(process.pid), startedAt: '' };
22
44
  const terminal = countProcess('terminal-standalone');
23
45
  const telegram = countProcess('telegram-standalone');
24
46
  const workspace = countProcess('workspace-standalone');
25
47
  const chat = countProcess('chat-standalone');
26
48
  const browserBridge = countProcess('browser-bridge-standalone');
27
49
  const memoryWorker = countProcess('memory-standalone');
28
- const tunnel = countProcess('cloudflared tunnel');
50
+ // cloudflared has no --forge-port tag — fall back to global grep.
51
+ const tunnel = countProcessUntagged('cloudflared tunnel');
29
52
 
30
53
  // Chat backend health (port 8408 — process can be alive but crashed)
31
54
  let chatStatus: { running: boolean; sessions: number; port: number } = {