@aion0/forge 0.10.40 → 0.10.41

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 (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -5
  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 +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +98 -1
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. 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,89 @@
1
- # Forge v0.10.40
1
+ # Forge v0.10.41
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.40
6
+
7
+ ### Features
8
+ - fix: agent path resolution + wizard writes self-contained settings
9
+ - feat: pipelines auto-install on Reinstall + monitor self-detect
10
+
11
+ ### Bug Fixes
12
+ - fix: agent path resolution + wizard writes self-contained settings
6
13
 
7
14
  ### Other
8
- - fix(server): portable port-pid lookup so Linux without lsof can stop (#33)
15
+ - chat: guard against silent project guessing for ambiguous MR / bug ids
16
+ - feat(marketplace): single unified Sync all — covers every type + every source
17
+ - ui: restore Re-sync registry button in Enterprise section
18
+ - ui: consolidate sync into Marketplace Connectors → Refresh
19
+ - ui(settings): per-source ↻ Sync button on each enterprise row
20
+ - ui(settings): re-run wizard moves next to Re-sync, shorter copy
21
+ - fix(settings): remove "Reinstall all" button — too many footguns
22
+ - fix(login-status,reinstall): badge polls, Reinstall force-overwrites instance fields
23
+ - fix(login-status): panel re-probes on mount, drops stale-cache UX
24
+ - fix(login-status): Test button writes result to login-status cache
25
+ - feat(wizard,pipeline): temper auto-provision + scratch-first project resolution + login-status cache invalidate
26
+ - fix(wizard): sequential section numbers + always show Pipelines
27
+ - feat(wizard): show template-baked defaults under each connector
28
+ - fix(wizard): profile-as-defaults — open with user's company+dept
29
+ - revert(wizard): drop settings.company override on source default
30
+ - fix(wizard): tenant selector defaults to settings.company
31
+ - fix(wizard): revert force-apply complexity, dept default reads settings.dept
32
+ - fix(wizard): re-run with different dept actually rotates defaults
33
+ - ui(profile): dept edit just updates label, no template re-apply
34
+ - feat(profile): company + dept as user-profile fields
35
+ - ui(enterprise): dept chip is display-only, picker lives in wizard
36
+ - feat(enterprise): dept chip becomes a one-click selector
37
+ - feat(wizard): support template-less department entries
38
+ - ui(enterprise): show single active dept, not the full list
39
+ - feat(enterprise): highlight currently-active dept chip
40
+ - feat(enterprise): surface dept list under each tenant
41
+ - ui(settings): merge Onboarding into Enterprise section at top
42
+ - fix(wizard): default dept picker to last-applied dept
43
+ - fix(wizard): restore auto-open + add cold-boot tenant splash
44
+ - fix(wizard): hasCache check accepts multi-dept index too
45
+ - fix(wizard): priority chain reads multi-dept cache, not just legacy file
46
+ - feat(wizard): add-key → wizard handoff + cold-boot auto-open gate (E4)
47
+ - feat(wizard): multi-department templates per source (E3)
48
+ - feat(wizard): tenant-scoped wizard via ?source_id (E2)
49
+ - feat(enterprise): editable keys (E1 of wizard redesign)
50
+ - feat(chat): surface connector defaults + stem-match arg fallback
51
+ - fix(ui): anchor enterprise popover to left edge
52
+ - fix(ui): drop displayName suffix from top-left title
53
+ - feat(ui): enterprise badge popover next to Forge title + settings reorder
54
+ - ui(settings): split 'Add tenant' from sync/reinstall actions
55
+ - feat(marketplace): Reinstall also applies template defaults to connector configs
56
+ - feat(dispatcher): auto-fill missing tool args from settings.default_<name>
57
+ - fix(http): expand tokens in body_form_inject_from values + keys
58
+ - fix(onboarding): fall back to displayName when email is empty for {user.login}
59
+ - feat(api): /api/bridge-info — expose browser-bridge port for the extension
60
+ - feat(auth): first-time admin password setup — no current-password required
61
+ - cli: forge --reset-password forwards trailing args (so --dir picks target instance)
62
+ - fix(onboarding): synchronously sync enterprise template on first launch
63
+ - fix(db): silence expected 'no such table' on fresh DB init
64
+ - onboarding: orphan instance rows drop unconditionally — template is canonical
65
+ - fix(connectors): cross-ref regex must also accept single-token refs like {base_url}
66
+ - fix(connectors): jenkins template — preserve cross-refs, refill empty, drop orphans
67
+ - feat(chat): preflight required connector settings with wizard prompt URL
68
+ - template: drop hardcoded default-jenkins from bundled fallback
69
+ - fix(onboarding): bake {user.X} tokens into persisted connector config
70
+ - marketplace: surface enterprise availability even when versions match
71
+ - fix(marketplace): surface update_source_id so Update button shows where it pulls from
72
+ - feat(marketplace): Reinstall-all button — force-flip installed connectors to enterprise
73
+ - feat(connectors): {user.*} global identity tokens + instances merge by name
74
+ - fix(wizard): minimal mode no longer hides required-prompt connector cards
75
+ - feat(marketplace): per-source sync status + PAT-scope diagnostic
76
+ - feat(onboarding): _wizard.minimal flag — hide non-required steps
77
+ - fix(enterprise): dedupe sources, scope monitor, tighten browser probe, default chat agent
78
+ - feat(wizard): data-driven UI — collapse hardcoded sections when template fills them
79
+ - feat(templates): {connector:id.field} cross-ref + _derive in wizard apply
80
+ - refactor(enterprise): unify ftnt → fortinet + dev-test passthrough + secret-scan guard
81
+ - feat(enterprise): obfuscated secrets + _agents preset in wizard template
82
+ - docs(help): enterprise marketplace + multi-source connector flow
83
+ - feat(wizard): per-enterprise onboarding template via resolver chain
84
+ - feat(enterprise): Dashboard badge + add_enterprise_key chat tool
85
+ - feat(ui): enterprise sources in marketplace + Settings panel
86
+ - feat(marketplace): multi-source connector + workflow registries (enterprise keys)
9
87
 
10
88
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.39...v0.10.40
89
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.40...v0.10.41
@@ -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 } = {