@aion0/forge 0.6.1 → 0.8.0

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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Browser bridge HTTP API — thin proxy from Next.js into the standalone.
3
+ *
4
+ * The standalone (lib/browser-bridge-standalone.ts) holds the WebSocket
5
+ * server and per-extension token state. Next.js exposes a small surface
6
+ * for in-Forge code paths (chat backend, settings UI):
7
+ *
8
+ * GET /api/browser-bridge?action=status
9
+ * POST /api/browser-bridge { action: "rpc", method, params }
10
+ * POST /api/browser-bridge { action: "push", topic, payload }
11
+ *
12
+ * Pairing is gone — the extension authenticates with the user's existing
13
+ * Forge token over the WS hello frame.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+
18
+ const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 8407;
19
+ const BRIDGE_URL = `http://localhost:${BRIDGE_PORT}`;
20
+
21
+ async function forward(path: string, init?: RequestInit): Promise<NextResponse> {
22
+ try {
23
+ const r = await fetch(`${BRIDGE_URL}${path}`, init);
24
+ const text = await r.text();
25
+ // Pass through JSON if possible
26
+ try {
27
+ return NextResponse.json(JSON.parse(text), { status: r.status });
28
+ } catch {
29
+ return new NextResponse(text, { status: r.status });
30
+ }
31
+ } catch (e) {
32
+ return NextResponse.json(
33
+ { error: 'bridge_unreachable', detail: (e as Error).message },
34
+ { status: 502 },
35
+ );
36
+ }
37
+ }
38
+
39
+ export async function GET(req: Request) {
40
+ const url = new URL(req.url);
41
+ const action = url.searchParams.get('action') || 'status';
42
+ if (action === 'status') {
43
+ return forward('/api/status');
44
+ }
45
+ return NextResponse.json({ error: 'unknown action' }, { status: 400 });
46
+ }
47
+
48
+ export async function POST(req: Request) {
49
+ const body = await req.json().catch(() => ({}));
50
+ const action = body?.action;
51
+
52
+ switch (action) {
53
+ case 'rpc':
54
+ return forward('/api/rpc', {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json' },
57
+ body: JSON.stringify({ method: body.method, params: body.params }),
58
+ });
59
+
60
+ case 'push':
61
+ return forward('/api/push', {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify({ topic: body.topic, payload: body.payload }),
65
+ });
66
+
67
+ default:
68
+ return NextResponse.json({ error: 'unknown action' }, { status: 400 });
69
+ }
70
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * GET /api/chat/sessions/<id>/events — SSE stream of agent events.
3
+ *
4
+ * Proxied to chat-standalone (port 8408). Used by surfaces that don't
5
+ * subscribe via the browser bridge (web /chat tab, future server-side
6
+ * consumers). The extension still receives events through its persistent
7
+ * WS to the bridge — both paths emit the same AgentEvent payloads.
8
+ */
9
+
10
+ import { proxyToChat } from '@/lib/chat/proxy';
11
+
12
+ export const dynamic = 'force-dynamic';
13
+
14
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
15
+ const { id } = await params;
16
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}/events`);
17
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * POST /api/chat/sessions/<id>/fork { title?, up_to_ts? } → { session }
3
+ *
4
+ * Copy a session's messages into a brand-new (temp) session and return
5
+ * the fork. Used by the chat header "⑂ Fork" button to branch the main
6
+ * conversation without losing it. The new session is meta.kind = 'temp'
7
+ * so the user can delete it later from the picker.
8
+ */
9
+
10
+ import { proxyToChat } from '@/lib/chat/proxy';
11
+
12
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
13
+ const { id } = await params;
14
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}/fork`);
15
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * POST /api/chat/sessions/<id>/messages → 202 { topic }
3
+ *
4
+ * Proxied to chat-standalone (port 8408), which runs the agent loop
5
+ * asynchronously and fanouts events to both the browser bridge
6
+ * (`chat:<session_id>`) and local SSE subscribers.
7
+ */
8
+
9
+ import { proxyToChat } from '@/lib/chat/proxy';
10
+
11
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
12
+ const { id } = await params;
13
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}/messages`);
14
+ }
15
+
16
+ // DELETE drops all messages but keeps the session row (used by the main
17
+ // chat's "clear" button — main session can't be deleted, only cleared).
18
+ export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
+ const { id } = await params;
20
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}/messages`);
21
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Per-session proxy to chat-standalone:
3
+ * GET /api/chat/sessions/<id> { session, messages }
4
+ * PATCH /api/chat/sessions/<id> partial update
5
+ * DELETE /api/chat/sessions/<id> cascade delete
6
+ */
7
+
8
+ import { proxyToChat } from '@/lib/chat/proxy';
9
+
10
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params;
12
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}`);
13
+ }
14
+
15
+ export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
16
+ const { id } = await params;
17
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}`);
18
+ }
19
+
20
+ export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
21
+ const { id } = await params;
22
+ return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}`);
23
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Thin proxy to chat-standalone (port 8408):
3
+ * GET /api/chat/sessions → list
4
+ * POST /api/chat/sessions → create
5
+ *
6
+ * Auth handled by middleware (session cookie OR X-Forge-Token).
7
+ */
8
+
9
+ import { proxyToChat } from '@/lib/chat/proxy';
10
+
11
+ export async function GET(req: Request) { return proxyToChat(req, '/api/sessions'); }
12
+ export async function POST(req: Request) { return proxyToChat(req, '/api/sessions'); }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * POST /api/chat/temper-ping
3
+ *
4
+ * Reachability probe for the chat memory backend. If Temper URL+key are
5
+ * configured, pings Temper at /v1/memory/blocks?pinned=true. Otherwise
6
+ * pings the LocalMemoryStore (SQLite). Returns { ok, message, pinned,
7
+ * backend } — `backend` is 'temper' | 'local' so the settings UI can
8
+ * label which one is active.
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import { getMemoryStore } from '@/lib/chat/memory-store';
13
+
14
+ export async function POST() {
15
+ const store = getMemoryStore();
16
+ const r = await store.ping();
17
+ return NextResponse.json({ ...r, backend: store.kind });
18
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Catch-all proxy from the Forge web app to the chat-standalone server
3
+ * (127.0.0.1:$CHAT_PORT, default 8408). Lets the in-app /chat page hit
4
+ * the same routes the browser extension uses, without exposing 8408
5
+ * publicly or re-implementing auth.
6
+ *
7
+ * The middleware on this server (port 8403) gates access via cookie/
8
+ * token; once a request reaches here it's authenticated, so we can
9
+ * forward to loopback without further checks.
10
+ *
11
+ * SSE pass-through: when upstream returns text/event-stream we stream
12
+ * the body verbatim so the /chat page gets live tokens.
13
+ */
14
+
15
+ import { NextResponse } from 'next/server';
16
+
17
+ const CHAT_BASE = `http://127.0.0.1:${process.env.CHAT_PORT || 8408}`;
18
+
19
+ async function forward(
20
+ req: Request,
21
+ ctx: { params: Promise<{ path: string[] }> },
22
+ ): Promise<Response> {
23
+ const { path } = await ctx.params;
24
+ const segs = (path || []).map(encodeURIComponent).join('/');
25
+ const search = new URL(req.url).search;
26
+ const target = `${CHAT_BASE}/api/${segs}${search}`;
27
+
28
+ const headers: Record<string, string> = {};
29
+ const ct = req.headers.get('content-type');
30
+ if (ct) headers['content-type'] = ct;
31
+ const accept = req.headers.get('accept');
32
+ if (accept) headers['accept'] = accept;
33
+
34
+ const init: RequestInit = { method: req.method, headers };
35
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
36
+ init.body = await req.text();
37
+ }
38
+
39
+ let upstream: Response;
40
+ try {
41
+ upstream = await fetch(target, init);
42
+ } catch (err) {
43
+ return NextResponse.json(
44
+ {
45
+ error: 'chat backend unreachable',
46
+ detail: err instanceof Error ? err.message : String(err),
47
+ target,
48
+ },
49
+ { status: 502 },
50
+ );
51
+ }
52
+
53
+ const upstreamCt = upstream.headers.get('content-type') || '';
54
+ if (upstreamCt.includes('text/event-stream')) {
55
+ return new Response(upstream.body, {
56
+ status: upstream.status,
57
+ headers: {
58
+ 'content-type': 'text/event-stream',
59
+ 'cache-control': 'no-cache, no-transform',
60
+ connection: 'keep-alive',
61
+ 'x-accel-buffering': 'no',
62
+ },
63
+ });
64
+ }
65
+
66
+ return new Response(upstream.body, {
67
+ status: upstream.status,
68
+ headers: { 'content-type': upstreamCt || 'application/json' },
69
+ });
70
+ }
71
+
72
+ export async function GET(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
73
+ return forward(req, ctx);
74
+ }
75
+ export async function POST(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
76
+ return forward(req, ctx);
77
+ }
78
+ export async function PATCH(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
79
+ return forward(req, ctx);
80
+ }
81
+ export async function DELETE(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
82
+ return forward(req, ctx);
83
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * POST /api/connector-tool
3
+ *
4
+ * Single-tool dispatch for internal callers (pipelines, jobs, CLI). Wraps
5
+ * lib/chat/tool-dispatcher.dispatchTool so http/shell/browser connectors
6
+ * are all callable through one shape:
7
+ *
8
+ * { "plugin_id": "teams", "tool": "send_message",
9
+ * "input": { "name": "Alice", "text": "MR ready: https://..." } }
10
+ *
11
+ * Returns the tool_result shape: { content: string, is_error?: boolean }.
12
+ *
13
+ * Loopback-only — middleware skips auth for this path on 127.0.0.1 /
14
+ * localhost; outside-host requests fall back to the normal X-Forge-Token
15
+ * check.
16
+ */
17
+
18
+ import { NextResponse } from 'next/server';
19
+ import { dispatchTool } from '@/lib/chat/tool-dispatcher';
20
+
21
+ export async function POST(req: Request) {
22
+ let body: any = {};
23
+ try { body = await req.json(); } catch {}
24
+ const pluginId = String(body?.plugin_id || '').trim();
25
+ const tool = String(body?.tool || '').trim();
26
+ if (!pluginId || !tool) {
27
+ return NextResponse.json({ error: 'plugin_id and tool are required' }, { status: 400 });
28
+ }
29
+ const name = `${pluginId}.${tool}`;
30
+ const result = await dispatchTool({
31
+ id: `internal-${Date.now()}`,
32
+ name,
33
+ input: body?.input ?? {},
34
+ });
35
+ // Always 200 — the result carries is_error if the dispatched tool failed.
36
+ // (Network / wiring errors throw inside dispatchTool and bubble as 500.)
37
+ return NextResponse.json(result);
38
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Per-user settings for a connector plugin.
3
+ *
4
+ * Browser-side connectors don't have a Forge-side executor, so "install" is
5
+ * really just "save your host URL + preferences". We map connector `settings`
6
+ * onto the existing plugin config storage to avoid a second store.
7
+ */
8
+
9
+ import { NextResponse } from 'next/server';
10
+ import {
11
+ getPlugin,
12
+ getInstalledPlugin,
13
+ installPlugin,
14
+ updatePluginConfig,
15
+ getConnectorsForPlugin,
16
+ } from '@/lib/plugins/registry';
17
+ import type { PluginFieldSchema } from '@/lib/plugins/types';
18
+
19
+ function settingsSchemaFor(id: string): Record<string, PluginFieldSchema> {
20
+ const def = getPlugin(id);
21
+ if (!def) return {};
22
+ // For 1:N suites, merge settings across all entries (caller can scope by entry id).
23
+ const merged: Record<string, PluginFieldSchema> = {};
24
+ for (const c of getConnectorsForPlugin(def)) {
25
+ if (c.settings) Object.assign(merged, c.settings);
26
+ }
27
+ return merged;
28
+ }
29
+
30
+ function defaultsFor(id: string): Record<string, any> {
31
+ const schema = settingsSchemaFor(id);
32
+ const out: Record<string, any> = {};
33
+ for (const [k, v] of Object.entries(schema)) {
34
+ if (v.default !== undefined) out[k] = v.default;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ const SECRET_MASK = '••••••••';
40
+
41
+ function isSecretField(schema: PluginFieldSchema | undefined): boolean {
42
+ if (!schema) return false;
43
+ return schema.type === 'secret' || (schema.type as string) === 'password';
44
+ }
45
+
46
+ function maskSecrets(settings: Record<string, any>, schema: Record<string, PluginFieldSchema>): Record<string, any> {
47
+ const out: Record<string, any> = { ...settings };
48
+ for (const [k, v] of Object.entries(schema)) {
49
+ if (isSecretField(v) && typeof out[k] === 'string' && out[k]) {
50
+ out[k] = SECRET_MASK;
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function restoreSecrets(
57
+ incoming: Record<string, any>,
58
+ existing: Record<string, any>,
59
+ schema: Record<string, PluginFieldSchema>,
60
+ ): Record<string, any> {
61
+ const out: Record<string, any> = { ...incoming };
62
+ for (const [k, v] of Object.entries(schema)) {
63
+ if (isSecretField(v) && out[k] === SECRET_MASK) {
64
+ if (typeof existing[k] === 'string') out[k] = existing[k];
65
+ else delete out[k];
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
72
+ const { id } = await params;
73
+ const def = getPlugin(id);
74
+ if (!def || def.category !== 'connector') {
75
+ return NextResponse.json({ error: 'connector not found' }, { status: 404 });
76
+ }
77
+ const inst = getInstalledPlugin(id);
78
+ const stored = inst?.config || {};
79
+ const schema = settingsSchemaFor(id);
80
+ const merged = { ...defaultsFor(id), ...stored };
81
+ return NextResponse.json({
82
+ settings: maskSecrets(merged, schema),
83
+ schema,
84
+ installed: !!inst,
85
+ });
86
+ }
87
+
88
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
89
+ const { id } = await params;
90
+ const def = getPlugin(id);
91
+ if (!def || def.category !== 'connector') {
92
+ return NextResponse.json({ error: 'connector not found' }, { status: 404 });
93
+ }
94
+
95
+ const body = await req.json().catch(() => ({}));
96
+ const settings = body?.settings ?? body;
97
+ if (typeof settings !== 'object' || settings === null) {
98
+ return NextResponse.json({ error: 'settings must be an object' }, { status: 400 });
99
+ }
100
+
101
+ const existing = getInstalledPlugin(id);
102
+ const schema = settingsSchemaFor(id);
103
+ const merged = restoreSecrets(settings, existing?.config || {}, schema);
104
+ const ok = existing
105
+ ? updatePluginConfig(id, merged)
106
+ : installPlugin(id, merged);
107
+
108
+ if (!ok) {
109
+ return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
110
+ }
111
+ return NextResponse.json({ ok: true, settings: maskSecrets(merged, schema) });
112
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Connectors API — discovery surface for the Forge browser extension.
3
+ *
4
+ * The extension hits this endpoint to learn what connector plugins are
5
+ * installed and what tools they advertise. The new declarative-script
6
+ * design (see docs/Connector-DeclarativeExtract-Spec.md) means each tool
7
+ * carries its own `page` (where to navigate) and `script` (function body
8
+ * to run in the user's tab). Forge expands {base_url} / {settings.*}
9
+ * tokens here using the user's saved settings; {args.*} is left literal
10
+ * for the extension's runner to expand at execution time.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import {
15
+ listPlugins,
16
+ getPlugin,
17
+ getInstalledPlugin,
18
+ getConnectorsForPlugin,
19
+ } from '@/lib/plugins/registry';
20
+ import { expandSettingsTokens } from '@/lib/plugins/templates';
21
+ import type { Connector, ConnectorTool, PluginDefinition } from '@/lib/plugins/types';
22
+
23
+ // Auth is handled by middleware (session cookie OR X-Forge-Token).
24
+
25
+ function expandTool(tool: ConnectorTool, settings: Record<string, any> | undefined): ConnectorTool {
26
+ if (!tool.page && !tool.script) return tool;
27
+ const page = tool.page
28
+ ? {
29
+ url: expandSettingsTokens(tool.page.url, settings),
30
+ ...(tool.page.on_target
31
+ ? { on_target: expandSettingsTokens(tool.page.on_target, settings) }
32
+ : {}),
33
+ }
34
+ : undefined;
35
+ return { ...tool, ...(page ? { page } : {}) };
36
+ }
37
+
38
+ function expandConnector(entry: Connector, settings: Record<string, any> | undefined): Connector {
39
+ const out: Connector = {
40
+ ...entry,
41
+ tools: Object.fromEntries(
42
+ Object.entries(entry.tools).map(([name, t]) => [name, expandTool(t, settings)]),
43
+ ),
44
+ };
45
+ if (entry.host_match) out.host_match = expandSettingsTokens(entry.host_match, settings);
46
+ if (entry.login_redirect) out.login_redirect = expandSettingsTokens(entry.login_redirect, settings);
47
+ return out;
48
+ }
49
+
50
+ function toConnectorPayload(def: PluginDefinition) {
51
+ const inst = getInstalledPlugin(def.id);
52
+ const settings = inst?.config;
53
+ const entries = getConnectorsForPlugin(def).map(e => expandConnector(e, settings));
54
+
55
+ // Hoist host_match / login_redirect to plugin level for 1:1 cases — the
56
+ // common case (and what the spec example shows). Pull from the first entry
57
+ // when those came from the top-level plugin fields.
58
+ const hostMatch = def.host_match
59
+ ? expandSettingsTokens(def.host_match, settings)
60
+ : entries[0]?.host_match;
61
+ const loginRedirect = def.login_redirect
62
+ ? expandSettingsTokens(def.login_redirect, settings)
63
+ : entries[0]?.login_redirect;
64
+ // Default to 'main' so existing connectors (Mantis, GitLab) keep their
65
+ // current execution path. Plugins on strict-CSP sites opt into 'isolated'.
66
+ const runner = def.runner || entries[0]?.runner || 'main';
67
+
68
+ return {
69
+ plugin_id: def.id,
70
+ name: def.name,
71
+ icon: def.icon,
72
+ version: def.version,
73
+ author: def.author || 'forge',
74
+ description: def.description || '',
75
+ mode: def.mode || 'browser-side',
76
+ installed: !!inst,
77
+ ...(hostMatch ? { host_match: hostMatch } : {}),
78
+ ...(loginRedirect ? { login_redirect: loginRedirect } : {}),
79
+ runner,
80
+ entries,
81
+ };
82
+ }
83
+
84
+ export async function GET(req: Request) {
85
+ const url = new URL(req.url);
86
+ const id = url.searchParams.get('id');
87
+
88
+ // Single-connector detail
89
+ if (id) {
90
+ const def = getPlugin(id);
91
+ if (!def || def.category !== 'connector') {
92
+ return NextResponse.json({ error: 'connector not found' }, { status: 404 });
93
+ }
94
+ return NextResponse.json({ connector: toConnectorPayload(def) });
95
+ }
96
+
97
+ // List all connectors (installed or not — extension surfaces both for marketplace)
98
+ const onlyInstalled = url.searchParams.get('installed') === 'true';
99
+ const sources = listPlugins().filter(s => s.category === 'connector');
100
+
101
+ const connectors = sources
102
+ .map(s => getPlugin(s.id))
103
+ .filter((d): d is PluginDefinition => !!d)
104
+ .filter(d => !onlyInstalled || !!getInstalledPlugin(d.id))
105
+ .map(toConnectorPayload);
106
+
107
+ return NextResponse.json({ connectors });
108
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GET /api/health/tools — array of external-tool checks (glab, gh, git, jq).
3
+ *
4
+ * Used by the Pipelines view to show a banner when something needed by a
5
+ * built-in workflow is missing, with install instructions inline. See
6
+ * lib/health.ts for the catalog and `[tools]` startup log lines.
7
+ */
8
+
9
+ import { NextResponse } from 'next/server';
10
+ import { checkTools } from '@/lib/health';
11
+
12
+ export async function GET() {
13
+ return NextResponse.json({ tools: checkTools() });
14
+ }
@@ -0,0 +1,95 @@
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ getConfig, saveConfig, listConfigs, scanAndTrigger,
4
+ startScanner, getProcessedIssues, resetProcessedIssue,
5
+ type GitLabAutofixConfig,
6
+ } from '@/lib/issue-scanner-gitlab';
7
+ import { startPipeline } from '@/lib/pipeline';
8
+
9
+ // GET /api/issue-scanner-gitlab?project=PATH
10
+ export async function GET(req: Request) {
11
+ const { searchParams } = new URL(req.url);
12
+ const projectPath = searchParams.get('project');
13
+ if (projectPath) {
14
+ return NextResponse.json({
15
+ config: getConfig(projectPath),
16
+ processed: getProcessedIssues(projectPath),
17
+ });
18
+ }
19
+ return NextResponse.json({ configs: listConfigs() });
20
+ }
21
+
22
+ // POST /api/issue-scanner-gitlab
23
+ export async function POST(req: Request) {
24
+ const body = await req.json();
25
+
26
+ if (body.action === 'save-config') {
27
+ const c: GitLabAutofixConfig = {
28
+ projectPath: body.projectPath,
29
+ projectName: body.projectName,
30
+ enabled: !!body.enabled,
31
+ interval: body.interval || 30,
32
+ labels: body.labels || [],
33
+ baseBranch: body.baseBranch || '',
34
+ baseBranchRule: body.baseBranchRule || 'milestone',
35
+ mrTitleTemplate: body.mrTitleTemplate || '',
36
+ mrBodyTemplate: body.mrBodyTemplate || '',
37
+ assigneeFilter: body.assigneeFilter ?? 'me',
38
+ };
39
+ saveConfig(c);
40
+ startScanner();
41
+ return NextResponse.json({ ok: true });
42
+ }
43
+
44
+ if (body.action === 'scan') {
45
+ const c = getConfig(body.projectPath);
46
+ if (!c) return NextResponse.json({ error: 'Not configured' }, { status: 400 });
47
+ return NextResponse.json(scanAndTrigger(c));
48
+ }
49
+
50
+ if (body.action === 'trigger') {
51
+ const c = getConfig(body.projectPath);
52
+ const projectName = c?.projectName || body.projectName;
53
+ try {
54
+ const pipeline = startPipeline('gitlab-issue-fix-and-review', {
55
+ issue_id: String(body.issueId),
56
+ project: projectName,
57
+ base_branch: body.baseBranch || c?.baseBranch || 'auto',
58
+ base_branch_rule: c?.baseBranchRule || 'milestone',
59
+ mr_title_template: c?.mrTitleTemplate || '',
60
+ mr_body_template: c?.mrBodyTemplate || '',
61
+ extra_context: body.context || '',
62
+ });
63
+ return NextResponse.json({ ok: true, pipelineId: pipeline.id });
64
+ } catch (e) {
65
+ return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
66
+ }
67
+ }
68
+
69
+ if (body.action === 'reset') {
70
+ resetProcessedIssue(body.projectPath, body.issueId);
71
+ return NextResponse.json({ ok: true });
72
+ }
73
+
74
+ if (body.action === 'retry') {
75
+ resetProcessedIssue(body.projectPath, body.issueId);
76
+ const c = getConfig(body.projectPath);
77
+ const projectName = c?.projectName || body.projectName;
78
+ try {
79
+ const pipeline = startPipeline('gitlab-issue-fix-and-review', {
80
+ issue_id: String(body.issueId),
81
+ project: projectName,
82
+ base_branch: c?.baseBranch || 'auto',
83
+ base_branch_rule: c?.baseBranchRule || 'milestone',
84
+ mr_title_template: c?.mrTitleTemplate || '',
85
+ mr_body_template: c?.mrBodyTemplate || '',
86
+ extra_context: body.context || '',
87
+ });
88
+ return NextResponse.json({ ok: true, pipelineId: pipeline.id });
89
+ } catch (e) {
90
+ return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
91
+ }
92
+ }
93
+
94
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
95
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * POST /api/jobs/<id>/reset_dedup — wipe job_seen for this job so the
3
+ * next tick re-dispatches every item it finds.
4
+ */
5
+
6
+ import { NextResponse } from 'next/server';
7
+ import { getJob, resetDedup } from '@/lib/jobs/store';
8
+
9
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params;
11
+ const job = getJob(id);
12
+ if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
13
+ const removed = resetDedup(id);
14
+ return NextResponse.json({ ok: true, removed });
15
+ }