@aion0/forge 0.8.0 → 0.8.2
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/RELEASE_NOTES.md +25 -101
- package/app/api/connectors/[id]/settings/route.ts +31 -37
- package/app/api/connectors/[id]/test/route.ts +260 -0
- package/app/api/connectors/install-local/route.ts +211 -0
- package/app/api/connectors/marketplace/route.ts +79 -0
- package/app/api/connectors/route.ts +41 -46
- package/app/api/jobs/route.ts +1 -0
- package/app/api/monitor/route.ts +26 -0
- package/app/api/skills/install-local/route.ts +282 -0
- package/components/ConnectorsPanel.tsx +526 -211
- package/components/MonitorPanel.tsx +43 -0
- package/components/SettingsModal.tsx +1 -0
- package/components/SkillsPanel.tsx +42 -1
- package/lib/agents/claude-adapter.ts +4 -0
- package/lib/agents/types.ts +6 -0
- package/lib/chat/agent-loop.ts +14 -23
- package/lib/chat/local-memory.ts +2 -2
- package/lib/chat/memory-store.ts +1 -1
- package/lib/chat/protocols/http.ts +2 -2
- package/lib/chat/protocols/shell.ts +2 -2
- package/lib/chat/session-store.ts +2 -2
- package/lib/chat/tool-dispatcher.ts +21 -21
- package/lib/connectors/migration.ts +110 -0
- package/lib/connectors/registry.ts +328 -0
- package/lib/connectors/sync.ts +305 -0
- package/lib/connectors/types.ts +253 -0
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/17-connectors.md +241 -189
- package/lib/help-docs/21-build-connector.md +314 -0
- package/lib/help-docs/CLAUDE.md +4 -2
- package/lib/init.ts +25 -0
- package/lib/jobs/dispatcher.ts +28 -8
- package/lib/jobs/scheduler.ts +21 -3
- package/lib/jobs/store.ts +11 -2
- package/lib/jobs/types.ts +12 -0
- package/lib/pipeline-scheduler.ts +3 -2
- package/lib/pipeline.ts +135 -13
- package/lib/plugins/registry.ts +9 -42
- package/lib/plugins/types.ts +4 -129
- package/lib/settings.ts +7 -0
- package/lib/skills.ts +27 -1
- package/lib/task-manager.ts +62 -2
- package/package.json +3 -1
- package/src/core/db/database.ts +4 -0
- package/lib/builtin-plugins/github-api.yaml +0 -93
- package/lib/builtin-plugins/gitlab.yaml +0 -860
- package/lib/builtin-plugins/mantis.probe.js +0 -176
- package/lib/builtin-plugins/mantis.yaml +0 -964
- package/lib/builtin-plugins/pmdb.yaml +0 -178
- package/lib/builtin-plugins/teams.yaml +0 -913
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,107 +1,31 @@
|
|
|
1
|
-
# Forge v0.8.
|
|
1
|
+
# Forge v0.8.2
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-20
|
|
4
4
|
|
|
5
|
-
## Changes since v0.
|
|
6
|
-
|
|
7
|
-
### Features
|
|
8
|
-
- feat: job preview auto-fill + forge worktree CLI + faster startup
|
|
9
|
-
|
|
10
|
-
### Documentation
|
|
11
|
-
- docs: example pipeline for a Forge Job driving Mantis → branch triage
|
|
12
|
-
- docs: Jobs — scheduled connector polls that fan out to pipelines or chat
|
|
13
|
-
- docs: connector declarative-extract spec + handoff + updated help doc
|
|
14
|
-
- docs: connector packaging section + plugin category ADR
|
|
15
|
-
- docs: ADR for browser-agent data architecture (Temper write strategy)
|
|
16
|
-
- docs: RFC + implementation plan for browser-agent / connector system
|
|
5
|
+
## Changes since v0.8.1
|
|
17
6
|
|
|
18
7
|
### Other
|
|
19
|
-
- feat(
|
|
20
|
-
-
|
|
21
|
-
- feat(
|
|
22
|
-
-
|
|
23
|
-
- fix(pipeline):
|
|
24
|
-
-
|
|
25
|
-
- feat(
|
|
26
|
-
-
|
|
27
|
-
- feat(
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
- feat(
|
|
33
|
-
- feat(
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
- feat(
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
- feat(teams): _probe diagnostic tool — DOM state in one call
|
|
41
|
-
- fix(chat): unwrap Node fetch 'fetch failed' with concrete remediation
|
|
42
|
-
- fix(teams): char-by-char typing for search input (keyboard events)
|
|
43
|
-
- feat(teams): v0.8.0 — send_message 3-tier resolver (left-rail → expand groups → search)
|
|
44
|
-
- feat(mantis): v0.4.3 — accurate filters via show_status + handler_id
|
|
45
|
-
- fix(pipeline): real workdir support + worktree-setup emits path only
|
|
46
|
-
- fix(pipeline): node tasks always start fresh Claude session
|
|
47
|
-
- fix(jobs): warn loudly when input_template keys render to empty
|
|
48
|
-
- feat(jobs): 'Force run' — reset dedup + run, one click
|
|
49
|
-
- fix(jobs): drop dedup_key from triggerPipeline — UNIQUE blocked replay
|
|
50
|
-
- feat(gitlab): full HTTP-API connector (32 tools) — replaces DOM scraping
|
|
51
|
-
- fix(mantis): bug notes always carry an id — fallback strategies + hash
|
|
52
|
-
- feat(chat): connector narrowing — /teams forces tool use; bare mention narrows tool list
|
|
53
|
-
- fix(chat): connector tools always surfaced + stronger 'use the tool' prompt
|
|
54
|
-
- fix(notify-test): explain 'fetch failed' (corp SSL / proxy / DNS)
|
|
55
|
-
- fix(notify-test): drop Markdown parse_mode + split chat_id + surface real reason
|
|
56
|
-
- fix(mantis): search_bugs — fix_schedule (version) filter + auto-pagination
|
|
57
|
-
- fix(mantis): search_bugs honours status + project_id, drops broken query syntax
|
|
58
|
-
- feat(health): startup probe + Pipelines banner for missing CLIs (glab/gh/git/jq)
|
|
59
|
-
- feat(pipeline): mantis-bug-fix-and-mr builtin + connector-tool API
|
|
60
|
-
- feat(mantis): notes carry stable note_id so Jobs can dedup on them
|
|
61
|
-
- feat(jobs): per-run verbose execution log persisted to job_runs.log
|
|
62
|
-
- feat(web): Jobs first in Automation, deep-link to logs filtered by job id
|
|
63
|
-
- fix(web): Automation sub-tabs squished header; new sub-toolbar row fix(terminal): Split Right/Down → SVG icons; mouse hint to tooltip feat(jobs): notes column — backfill + items_path mismatch surfaced
|
|
64
|
-
- feat(web): Automation hub — merge Tasks/Pipelines nav + add Jobs view
|
|
65
|
-
- feat(jobs): trace flow — Dashboard deep-link + CLI dispatches view
|
|
66
|
-
- feat(jobs): Jobs backend — scheduled connector polls + dispatch
|
|
67
|
-
- fix(settings): don't overwrite agent apiKey with masked placeholder
|
|
68
|
-
- feat(chat): port Temper memory to Forge — pinned blocks + memory_* tools
|
|
69
|
-
- feat(chat): reuse API agent profiles + add baseUrl (LiteLLM support)
|
|
70
|
-
- feat(bridge): drop pairing — bridge auths with the user's Forge token
|
|
71
|
-
- feat(connectors): Phase 3 — multi-protocol runtime (http, shell)
|
|
72
|
-
- feat(chat): Phase 5 part 3 — Telegram /chat command
|
|
73
|
-
- feat(chat): Phase 5 part 2 — forge chat CLI subcommand
|
|
74
|
-
- feat(chat): Phase 5 part 1 — chat-standalone (8408) + Next proxy
|
|
75
|
-
- fix(bridge): spawn browser-bridge from lib/init.ts too
|
|
76
|
-
- feat(chat): Phase 2 part 3 — HTTP API + WS push streaming
|
|
77
|
-
- feat(chat): Phase 2 part 2 — tool dispatcher + agent loop
|
|
78
|
-
- feat(chat): Phase 2 part 1 — chat backend foundation
|
|
79
|
-
- feat(bridge): Phase 1 — Forge↔Extension WebSocket bridge
|
|
80
|
-
- feat(connectors): teams v0.7.1 — send_message no longer requires confirmation
|
|
81
|
-
- feat(connectors): teams v0.7.0 — robust wait + send_message by name
|
|
82
|
-
- feat(connectors): teams v0.6.0 — read_chat + read_channel by name
|
|
83
|
-
- feat(connectors): teams v0.5.0 — channels support (list + read posts)
|
|
84
|
-
- feat(connectors): pmdb v0.2.0 — read-only Fortinet PMDB connector
|
|
85
|
-
- feat(connectors): teams v0.4.0 — send_message (destructive)
|
|
86
|
-
- feat(connectors): teams v0.3.0 — rewritten from real DOM probe
|
|
87
|
-
- feat(connectors): multi-runner — manifest declares execution context
|
|
88
|
-
- feat(connectors): teams (Microsoft Teams) — first-cut declarative manifest
|
|
89
|
-
- chore(connectors): bump gitlab to 0.2.0
|
|
90
|
-
- feat(connectors): gitlab — declarative manifest with 6 tools
|
|
91
|
-
- feat(connectors): declarative extract — manifest carries page + script
|
|
92
|
-
- chore(connectors): bump mantis to 0.2.0 — full tool set landed
|
|
93
|
-
- feat(mcp): user-managed external MCP servers in .mcp.json
|
|
94
|
-
- feat(connectors): mantis probe — auto-copy JSON result to clipboard
|
|
95
|
-
- fix(connectors): mantis probe — support 1.x classic URL + checkbox row marker
|
|
96
|
-
- feat(connectors): mantis selector probe sibling script
|
|
97
|
-
- chore(connectors): bump mantis + gitlab-browser to 0.1.1
|
|
98
|
-
- docs(connectors): DOM extraction is the default path, not REST APIs
|
|
99
|
-
- feat(ui): dedicated Connectors panel separate from Plugins
|
|
100
|
-
- fix(connectors-api): rely on middleware auth, drop explicit token check
|
|
101
|
-
- feat(api): connectors discovery + per-user settings endpoints
|
|
102
|
-
- feat(plugins): connector category + browser-side mode, mantis + gitlab manifests
|
|
103
|
-
- feat(gitlab): \`gitlab-issue-fix-and-review\` pipeline + scanner
|
|
104
|
-
- fix(mcp): mirror .forge/mcp.json to project root so claude picks it up
|
|
8
|
+
- feat(skills): local skill upload (.md / .zip), parallel to connector install-local
|
|
9
|
+
- fix(pipeline): auto-sync gitlab connector PAT into glab auth + env
|
|
10
|
+
- feat(jobs): Job.skills + thread through to claude --append-system-prompt
|
|
11
|
+
- fix(connectors): friendlier error when browser-probe handler missing
|
|
12
|
+
- fix(pipeline): allow retry on running nodes + reap orphans on boot
|
|
13
|
+
- feat(connectors): browser-side test probe via extension bridge
|
|
14
|
+
- feat(connectors): manifest-driven Test button
|
|
15
|
+
- docs(connectors): 21-build-connector.md + AI routing for authoring
|
|
16
|
+
- feat(connectors): Upload button + drag-and-drop in marketplace
|
|
17
|
+
- feat(connectors): install-local API — accept YAML or zip
|
|
18
|
+
- refactor(connectors): move marketplace from Settings to SkillsPanel tab
|
|
19
|
+
- fix(connectors): surface fetch root cause + show installed in marketplace
|
|
20
|
+
- docs(connectors): rewrite 17-connectors.md for marketplace model
|
|
21
|
+
- feat(connectors): drop builtin yamls + purge connector code from plugin/
|
|
22
|
+
- feat(connectors): one-shot migration from plugin-configs.json
|
|
23
|
+
- feat(connectors): Settings Marketplace panel
|
|
24
|
+
- feat(connectors): marketplace API /api/connectors/marketplace
|
|
25
|
+
- feat(connectors): route /api/connectors + chat through new registry
|
|
26
|
+
- feat(connectors): sync — pull registry + manifests from forge-connectors
|
|
27
|
+
- feat(connectors): registry — load manifests from <dataDir>/connectors/
|
|
28
|
+
- feat(connectors): extract independent types in lib/connectors/
|
|
105
29
|
|
|
106
30
|
|
|
107
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.
|
|
31
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.1...v0.8.2
|
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-user settings for
|
|
2
|
+
* Per-user settings for an installed connector.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* GET → masked settings + schema + installed flag
|
|
5
|
+
* POST → save settings (preserves stored secrets if client returns
|
|
6
|
+
* the bullet placeholder unchanged)
|
|
7
|
+
*
|
|
8
|
+
* Secrets (fields with type === 'secret') are encrypted at rest by
|
|
9
|
+
* lib/connectors/registry.ts (AES-256-GCM via lib/crypto.ts) and
|
|
10
|
+
* masked here so the plaintext never crosses the wire on GET.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { NextResponse } from 'next/server';
|
|
10
14
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from '@/lib/plugins/registry';
|
|
17
|
-
import type { PluginFieldSchema } from '@/lib/plugins/types';
|
|
15
|
+
getConnector,
|
|
16
|
+
getInstalledConnector,
|
|
17
|
+
setConnectorConfig,
|
|
18
|
+
} from '@/lib/connectors/registry';
|
|
19
|
+
import type { ConnectorFieldSchema } from '@/lib/connectors/types';
|
|
18
20
|
|
|
19
|
-
function settingsSchemaFor(id: string): Record<string,
|
|
20
|
-
const def =
|
|
21
|
+
function settingsSchemaFor(id: string): Record<string, ConnectorFieldSchema> {
|
|
22
|
+
const def = getConnector(id);
|
|
21
23
|
if (!def) return {};
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const merged: Record<string, ConnectorFieldSchema> = {};
|
|
25
|
+
if (def.settings) Object.assign(merged, def.settings);
|
|
26
|
+
// 1:N suites: union of each entry's settings
|
|
27
|
+
for (const entry of def.connectors || []) {
|
|
28
|
+
if (entry.settings) Object.assign(merged, entry.settings);
|
|
26
29
|
}
|
|
27
30
|
return merged;
|
|
28
31
|
}
|
|
@@ -38,12 +41,12 @@ function defaultsFor(id: string): Record<string, any> {
|
|
|
38
41
|
|
|
39
42
|
const SECRET_MASK = '••••••••';
|
|
40
43
|
|
|
41
|
-
function isSecretField(schema:
|
|
44
|
+
function isSecretField(schema: ConnectorFieldSchema | undefined): boolean {
|
|
42
45
|
if (!schema) return false;
|
|
43
46
|
return schema.type === 'secret' || (schema.type as string) === 'password';
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
function maskSecrets(settings: Record<string, any>, schema: Record<string,
|
|
49
|
+
function maskSecrets(settings: Record<string, any>, schema: Record<string, ConnectorFieldSchema>): Record<string, any> {
|
|
47
50
|
const out: Record<string, any> = { ...settings };
|
|
48
51
|
for (const [k, v] of Object.entries(schema)) {
|
|
49
52
|
if (isSecretField(v) && typeof out[k] === 'string' && out[k]) {
|
|
@@ -56,7 +59,7 @@ function maskSecrets(settings: Record<string, any>, schema: Record<string, Plugi
|
|
|
56
59
|
function restoreSecrets(
|
|
57
60
|
incoming: Record<string, any>,
|
|
58
61
|
existing: Record<string, any>,
|
|
59
|
-
schema: Record<string,
|
|
62
|
+
schema: Record<string, ConnectorFieldSchema>,
|
|
60
63
|
): Record<string, any> {
|
|
61
64
|
const out: Record<string, any> = { ...incoming };
|
|
62
65
|
for (const [k, v] of Object.entries(schema)) {
|
|
@@ -70,11 +73,9 @@ function restoreSecrets(
|
|
|
70
73
|
|
|
71
74
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
72
75
|
const { id } = await params;
|
|
73
|
-
const def =
|
|
74
|
-
if (!def
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
const inst = getInstalledPlugin(id);
|
|
76
|
+
const def = getConnector(id);
|
|
77
|
+
if (!def) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
|
|
78
|
+
const inst = getInstalledConnector(id);
|
|
78
79
|
const stored = inst?.config || {};
|
|
79
80
|
const schema = settingsSchemaFor(id);
|
|
80
81
|
const merged = { ...defaultsFor(id), ...stored };
|
|
@@ -87,10 +88,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
87
88
|
|
|
88
89
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
89
90
|
const { id } = await params;
|
|
90
|
-
const def =
|
|
91
|
-
if (!def
|
|
92
|
-
return NextResponse.json({ error: 'connector not found' }, { status: 404 });
|
|
93
|
-
}
|
|
91
|
+
const def = getConnector(id);
|
|
92
|
+
if (!def) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
|
|
94
93
|
|
|
95
94
|
const body = await req.json().catch(() => ({}));
|
|
96
95
|
const settings = body?.settings ?? body;
|
|
@@ -98,15 +97,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
98
97
|
return NextResponse.json({ error: 'settings must be an object' }, { status: 400 });
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
const existing =
|
|
100
|
+
const existing = getInstalledConnector(id);
|
|
102
101
|
const schema = settingsSchemaFor(id);
|
|
103
102
|
const merged = restoreSecrets(settings, existing?.config || {}, schema);
|
|
104
|
-
const ok =
|
|
105
|
-
|
|
106
|
-
: installPlugin(id, merged);
|
|
107
|
-
|
|
108
|
-
if (!ok) {
|
|
109
|
-
return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
|
|
110
|
-
}
|
|
103
|
+
const ok = setConnectorConfig(id, merged);
|
|
104
|
+
if (!ok) return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
|
|
111
105
|
return NextResponse.json({ ok: true, settings: maskSecrets(merged, schema) });
|
|
112
106
|
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/connectors/[id]/test
|
|
3
|
+
*
|
|
4
|
+
* Reachability probe for a connector. Runs the manifest's `test:`
|
|
5
|
+
* block (an HTTP request) against the user's saved settings, returns
|
|
6
|
+
* a structured result for the Settings → Connectors UI to render.
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* ok: true,
|
|
10
|
+
* status: 200,
|
|
11
|
+
* message: "Authenticated as zliu (Zhen Liu)",
|
|
12
|
+
* duration_ms: 152
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* On failure:
|
|
16
|
+
*
|
|
17
|
+
* {
|
|
18
|
+
* ok: false,
|
|
19
|
+
* status: 401,
|
|
20
|
+
* error: "Token was revoked. You have to re-authorize from the user.",
|
|
21
|
+
* body_preview: "{...}"
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* The probe is HTTP-only — connectors that need a different mechanism
|
|
25
|
+
* (browser DOM, shell exec) simply omit `test:` and the UI hides the
|
|
26
|
+
* button.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { NextResponse } from 'next/server';
|
|
30
|
+
import {
|
|
31
|
+
getConnector,
|
|
32
|
+
getConnectorEntries,
|
|
33
|
+
getInstalledConnector,
|
|
34
|
+
} from '@/lib/connectors/registry';
|
|
35
|
+
import { expandSettingsTokens, expandAllTokens } from '@/lib/plugins/templates';
|
|
36
|
+
import { bridgeRpc } from '@/lib/chat/bridge-client';
|
|
37
|
+
import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from '@/lib/connectors/types';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
40
|
+
const MAX_BODY_PREVIEW = 1024;
|
|
41
|
+
|
|
42
|
+
function expandString(s: string, settings: Record<string, unknown>): string {
|
|
43
|
+
return expandAllTokens(s, settings as Record<string, any>, {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
|
|
47
|
+
let url = expandString(spec.url, settings);
|
|
48
|
+
if (spec.query) {
|
|
49
|
+
const u = new URL(url);
|
|
50
|
+
for (const [k, raw] of Object.entries(spec.query)) {
|
|
51
|
+
u.searchParams.set(k, expandString(String(raw), settings));
|
|
52
|
+
}
|
|
53
|
+
url = u.toString();
|
|
54
|
+
}
|
|
55
|
+
return url;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildHeaders(spec: HttpRequestSpec, settings: Record<string, unknown>): Headers {
|
|
59
|
+
const h = new Headers();
|
|
60
|
+
if (spec.headers) {
|
|
61
|
+
for (const [k, raw] of Object.entries(spec.headers)) {
|
|
62
|
+
h.set(k, expandString(String(raw), settings));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return h;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildBody(
|
|
69
|
+
spec: HttpRequestSpec,
|
|
70
|
+
settings: Record<string, unknown>,
|
|
71
|
+
): { body?: string; contentType?: string } {
|
|
72
|
+
if (spec.body == null) return {};
|
|
73
|
+
if (typeof spec.body === 'string') {
|
|
74
|
+
return { body: expandString(spec.body, settings) };
|
|
75
|
+
}
|
|
76
|
+
const out: Record<string, unknown> = {};
|
|
77
|
+
for (const [k, v] of Object.entries(spec.body)) {
|
|
78
|
+
out[k] = typeof v === 'string' ? expandString(v, settings) : v;
|
|
79
|
+
}
|
|
80
|
+
return { body: JSON.stringify(out), contentType: 'application/json' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render `{{path.to.value}}` placeholders against a parsed JSON body.
|
|
85
|
+
* Missing paths render as "?", non-string scalars get stringified.
|
|
86
|
+
*/
|
|
87
|
+
function renderTemplate(template: string, body: unknown): string {
|
|
88
|
+
return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
|
|
89
|
+
const path = String(expr).trim().split('.').filter(Boolean);
|
|
90
|
+
let cur: any = body;
|
|
91
|
+
for (const p of path) {
|
|
92
|
+
if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
|
|
93
|
+
else return '?';
|
|
94
|
+
}
|
|
95
|
+
if (cur == null) return '?';
|
|
96
|
+
return typeof cur === 'string' ? cur : JSON.stringify(cur);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface TestResult {
|
|
101
|
+
ok: boolean;
|
|
102
|
+
status?: number;
|
|
103
|
+
message?: string;
|
|
104
|
+
error?: string;
|
|
105
|
+
duration_ms?: number;
|
|
106
|
+
body_preview?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>): Promise<TestResult> {
|
|
110
|
+
const spec = test.request;
|
|
111
|
+
if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
|
|
112
|
+
|
|
113
|
+
const method = (spec.method || 'GET').toUpperCase();
|
|
114
|
+
const url = buildUrl(spec, settings);
|
|
115
|
+
const headers = buildHeaders(spec, settings);
|
|
116
|
+
const { body, contentType } = buildBody(spec, settings);
|
|
117
|
+
if (body != null && contentType && !headers.has('content-type')) {
|
|
118
|
+
headers.set('content-type', contentType);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
|
|
122
|
+
const okStatus = test.ok_status?.length ? test.ok_status : [200];
|
|
123
|
+
|
|
124
|
+
const ctrl = new AbortController();
|
|
125
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
126
|
+
const t0 = Date.now();
|
|
127
|
+
let res: Response;
|
|
128
|
+
try {
|
|
129
|
+
res = await fetch(url, { method, headers, body, signal: ctrl.signal });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
const err = e as Error & { cause?: unknown };
|
|
133
|
+
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: `request failed: ${err.message}${cause}`,
|
|
137
|
+
duration_ms: Date.now() - t0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
|
|
142
|
+
const duration = Date.now() - t0;
|
|
143
|
+
const text = await res.text().catch(() => '');
|
|
144
|
+
const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
|
|
145
|
+
|
|
146
|
+
if (!okStatus.includes(res.status)) {
|
|
147
|
+
let errMsg = `HTTP ${res.status} ${res.statusText}`;
|
|
148
|
+
try {
|
|
149
|
+
const j = JSON.parse(text);
|
|
150
|
+
if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
|
|
151
|
+
else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
|
|
152
|
+
else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
|
|
153
|
+
} catch {}
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
status: res.status,
|
|
157
|
+
error: errMsg,
|
|
158
|
+
duration_ms: duration,
|
|
159
|
+
body_preview: preview,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let parsedBody: unknown = null;
|
|
164
|
+
try { parsedBody = JSON.parse(text); } catch {}
|
|
165
|
+
const message = test.ok_template
|
|
166
|
+
? renderTemplate(test.ok_template, parsedBody)
|
|
167
|
+
: `OK (HTTP ${res.status})`;
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
status: res.status,
|
|
171
|
+
message,
|
|
172
|
+
duration_ms: duration,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Browser probe: ask the paired extension to land on the connector's
|
|
178
|
+
* host_match URL and report whether `login_redirect` was hit.
|
|
179
|
+
*
|
|
180
|
+
* The extension implements `connector.probe` (see
|
|
181
|
+
* lib/help-docs/21-build-connector.md for the wire contract). On
|
|
182
|
+
* sites the user is logged in to, it returns
|
|
183
|
+
* { ok: true, url: '<final tab URL>' }
|
|
184
|
+
* On a redirect to the login page it returns
|
|
185
|
+
* { ok: false, error: 'login required', url: '<login url>' }
|
|
186
|
+
* On extension absence we throw, caller surfaces it.
|
|
187
|
+
*/
|
|
188
|
+
async function runBrowserProbe(
|
|
189
|
+
def: ConnectorDefinition,
|
|
190
|
+
settings: Record<string, unknown>,
|
|
191
|
+
): Promise<TestResult> {
|
|
192
|
+
const entries = getConnectorEntries(def);
|
|
193
|
+
const entry = entries[0];
|
|
194
|
+
const hostMatch = def.host_match || entry?.host_match;
|
|
195
|
+
const loginRedirect = def.login_redirect || entry?.login_redirect;
|
|
196
|
+
if (!hostMatch) {
|
|
197
|
+
return { ok: false, error: 'browser probe requires host_match on the manifest' };
|
|
198
|
+
}
|
|
199
|
+
const expandedHost = expandSettingsTokens(hostMatch, settings as any);
|
|
200
|
+
const expandedLoginRedirect = loginRedirect
|
|
201
|
+
? expandSettingsTokens(loginRedirect, settings as any)
|
|
202
|
+
: undefined;
|
|
203
|
+
|
|
204
|
+
const t0 = Date.now();
|
|
205
|
+
let value: unknown;
|
|
206
|
+
try {
|
|
207
|
+
value = await bridgeRpc('connector.probe', {
|
|
208
|
+
pluginId: def.id,
|
|
209
|
+
host_match: expandedHost,
|
|
210
|
+
login_redirect: expandedLoginRedirect,
|
|
211
|
+
runner: def.runner || entry?.runner || 'main',
|
|
212
|
+
timeout_ms: def.test?.timeout_ms || 30_000,
|
|
213
|
+
});
|
|
214
|
+
} catch (e) {
|
|
215
|
+
const raw = (e as Error).message || String(e);
|
|
216
|
+
let friendly = raw;
|
|
217
|
+
if (raw.includes('unknown method: connector.probe')) {
|
|
218
|
+
friendly =
|
|
219
|
+
'Your Forge browser extension is out of date — it doesn\'t know how to run browser probes yet. ' +
|
|
220
|
+
'Rebuild the extension (pnpm ext in forge-browser-extension), then chrome://extensions → Reload, and try Test again.';
|
|
221
|
+
} else if (raw.includes('bridge') && raw.includes('unreachable')) {
|
|
222
|
+
friendly =
|
|
223
|
+
'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
|
|
224
|
+
} else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
|
|
225
|
+
friendly =
|
|
226
|
+
'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
error: friendly,
|
|
231
|
+
duration_ms: Date.now() - t0,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
|
|
235
|
+
return {
|
|
236
|
+
ok: !!r.ok,
|
|
237
|
+
message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
|
|
238
|
+
error: r.ok ? undefined : (r.error || 'login required'),
|
|
239
|
+
duration_ms: Date.now() - t0,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
244
|
+
const { id } = await params;
|
|
245
|
+
const def = getConnector(id);
|
|
246
|
+
if (!def) return NextResponse.json({ ok: false, error: 'connector not found' }, { status: 404 });
|
|
247
|
+
if (!def.test) {
|
|
248
|
+
return NextResponse.json({ ok: false, error: 'connector has no test block' }, { status: 400 });
|
|
249
|
+
}
|
|
250
|
+
const inst = getInstalledConnector(id);
|
|
251
|
+
if (!inst) {
|
|
252
|
+
return NextResponse.json({ ok: false, error: 'connector not installed' }, { status: 400 });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const probe = def.test.probe || 'http';
|
|
256
|
+
const r = probe === 'browser'
|
|
257
|
+
? await runBrowserProbe(def, inst.config)
|
|
258
|
+
: await runHttpProbe(def.test, inst.config);
|
|
259
|
+
return NextResponse.json(r);
|
|
260
|
+
}
|