@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.
- package/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +4 -7
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +920 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/app/chat/page.tsx +8 -5
- package/bin/forge-server.mjs +98 -1
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/llm/anthropic.ts +6 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- 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 `
|
|
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.
|
|
1
|
+
# Forge v0.10.42
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-07
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
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.
|
|
42
|
-
//
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
package/app/api/monitor/route.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
//
|
|
21
|
-
const nextjs =
|
|
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
|
-
|
|
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 } = {
|