@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.
- package/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +83 -5
- 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 +897 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- 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/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,89 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.41
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-07
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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 } = {
|