@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/lib/connectors/sync.ts
CHANGED
|
@@ -1,35 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Connector sync — pull registry + manifests from
|
|
2
|
+
* Connector sync — pull registry + manifests from one or more remote
|
|
3
|
+
* forge-connectors-style repos and surface them as a single marketplace
|
|
4
|
+
* view with enterprise-over-public priority.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Sources (priority order, 0 = highest):
|
|
7
|
+
* 1. Enterprise repos — listed by lib/enterprise.ts (one per configured
|
|
8
|
+
* key). Fetched via the GitHub Contents API with the key's PAT so
|
|
9
|
+
* private repos work.
|
|
10
|
+
* 2. Public — settings.connectorsRepoUrl or DEFAULT_REPO. Fetched via
|
|
11
|
+
* raw.githubusercontent.com (no auth needed).
|
|
6
12
|
*
|
|
7
|
-
*
|
|
8
|
-
* /registry.json
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (installed copy, refreshed on update)
|
|
13
|
+
* On-disk layout:
|
|
14
|
+
* <dataDir>/connectors/sources/<source-id>/registry-cache.json
|
|
15
|
+
* — last successful registry fetch for that source (one file per source)
|
|
16
|
+
* <dataDir>/connectors/<id>/manifest.yaml
|
|
17
|
+
* — installed connector manifest (single copy regardless of source;
|
|
18
|
+
* the source field on the in-memory marketplace entry tells the UI
|
|
19
|
+
* where it came from)
|
|
15
20
|
*
|
|
16
21
|
* Sync is called non-blocking on startup and on-demand from the
|
|
17
|
-
* Settings Marketplace UI. Network failures
|
|
18
|
-
*
|
|
19
|
-
*
|
|
22
|
+
* Settings Marketplace UI. Network failures on any one source are
|
|
23
|
+
* isolated — the marketplace falls back to whichever source caches
|
|
24
|
+
* still exist. No built-in fallback connectors.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
28
|
import { join } from 'node:path';
|
|
24
29
|
import { loadSettings } from '../settings';
|
|
25
30
|
import { getDataDir } from '../dirs';
|
|
31
|
+
import { listEnterpriseSources, type EnterpriseSource } from '../enterprise';
|
|
26
32
|
import { getConnector, installConnector, listConfigOnlyIds, listInstalledConnectors } from './registry';
|
|
27
33
|
import type { ConnectorMarketEntry } from './types';
|
|
28
34
|
|
|
29
35
|
const DEFAULT_REPO = 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main';
|
|
30
36
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Cross-marketplace shape: one enterprise repo declares its full
|
|
40
|
+
* inventory here — connectors AND workflow templates (recipes /
|
|
41
|
+
* pipelines). Connector sync writes the whole object to disk; the
|
|
42
|
+
* workflow marketplace reads the recipes/pipelines arrays from the
|
|
43
|
+
* same cache (via readSourceRegistry) so we never double-fetch.
|
|
44
|
+
*
|
|
45
|
+
* Public side splits across two repos (forge-connectors, forge-workflow);
|
|
46
|
+
* each writes only its own field group. Either way, readers should
|
|
47
|
+
* treat all fields as optional.
|
|
48
|
+
*/
|
|
49
|
+
export interface RegistryFile {
|
|
33
50
|
version?: number;
|
|
34
51
|
connectors?: Array<{
|
|
35
52
|
id: string;
|
|
@@ -42,185 +59,525 @@ interface RegistryFile {
|
|
|
42
59
|
/** Override manifest path; defaults to `<id>/manifest.yaml`. */
|
|
43
60
|
manifest?: string;
|
|
44
61
|
}>;
|
|
62
|
+
/** Recipe summaries (when this repo ships them). */
|
|
63
|
+
recipes?: Array<{
|
|
64
|
+
name: string;
|
|
65
|
+
display_name?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
version?: string;
|
|
68
|
+
author?: string;
|
|
69
|
+
tags?: string[];
|
|
70
|
+
score?: number;
|
|
71
|
+
rating?: number;
|
|
72
|
+
}>;
|
|
73
|
+
/** Pipeline summaries (when this repo ships them). */
|
|
74
|
+
pipelines?: Array<{
|
|
75
|
+
name: string;
|
|
76
|
+
display_name?: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
version?: string;
|
|
79
|
+
author?: string;
|
|
80
|
+
tags?: string[];
|
|
81
|
+
score?: number;
|
|
82
|
+
rating?: number;
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Repo-relative path to the onboarding wizard template JSON. When set,
|
|
86
|
+
* sync also fetches that file and caches it next to the registry so
|
|
87
|
+
* lib/connectors/wizard-template.ts can resolve a per-enterprise (or
|
|
88
|
+
* public) override at wizard time. Omit to defer to the next-lower
|
|
89
|
+
* priority source / the Forge-bundled fallback.
|
|
90
|
+
*/
|
|
91
|
+
wizard_template?: string;
|
|
92
|
+
/**
|
|
93
|
+
* E3: multi-department templates. When the source ships >1 template
|
|
94
|
+
* (one per department / product line), each entry lists its id,
|
|
95
|
+
* display label, and repo-relative path. Sync fetches and caches each
|
|
96
|
+
* one under `wizards/<dept>.json`. Mutually exclusive with the
|
|
97
|
+
* scalar `wizard_template`: if both are set, `wizard_templates` wins
|
|
98
|
+
* and the scalar is ignored (logged as a warning).
|
|
99
|
+
*
|
|
100
|
+
* The dept id is the slug used in URLs (`?source_id=...&dept=fortinac`),
|
|
101
|
+
* `display_name` is what the wizard's dropdown shows.
|
|
102
|
+
*/
|
|
103
|
+
wizard_templates?: Array<{
|
|
104
|
+
id: string;
|
|
105
|
+
display_name: string;
|
|
106
|
+
/** Optional. When omitted, the dept is just a label — picking it in
|
|
107
|
+
* the wizard falls through to the source/public default template,
|
|
108
|
+
* but the dept name still flows into {dept.name} substitutions and
|
|
109
|
+
* the installed_dept stamp on connector rows. Lets enterprises
|
|
110
|
+
* declare departments without authoring per-dept templates. */
|
|
111
|
+
path?: string;
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Craft summaries (when this repo ships them). Forge's craft sync
|
|
115
|
+
* subsystem (currently project-scoped via forge-crafts; multi-source
|
|
116
|
+
* wiring is Phase 2) will read these from per-source caches.
|
|
117
|
+
*/
|
|
118
|
+
crafts?: Array<{
|
|
119
|
+
name: string;
|
|
120
|
+
display_name?: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
version?: string;
|
|
123
|
+
author?: string;
|
|
124
|
+
icon?: string;
|
|
125
|
+
tags?: string[];
|
|
126
|
+
}>;
|
|
127
|
+
/**
|
|
128
|
+
* Skill summaries (when this repo ships them). Forge's skills sync
|
|
129
|
+
* subsystem (currently forge-skills only; multi-source wiring is
|
|
130
|
+
* Phase 2) will read these from per-source caches.
|
|
131
|
+
*/
|
|
132
|
+
skills?: Array<{
|
|
133
|
+
name: string;
|
|
134
|
+
display_name?: string;
|
|
135
|
+
description?: string;
|
|
136
|
+
version?: string;
|
|
137
|
+
tags?: string[];
|
|
138
|
+
}>;
|
|
45
139
|
}
|
|
46
140
|
|
|
47
|
-
interface
|
|
141
|
+
interface SourceCache {
|
|
48
142
|
fetched_at: string;
|
|
49
143
|
base_url: string;
|
|
50
144
|
data: RegistryFile;
|
|
51
145
|
}
|
|
52
146
|
|
|
147
|
+
// ─── Source model ─────────────────────────────────────────
|
|
148
|
+
//
|
|
149
|
+
// `public` and each `enterprise-<tenant_id>` are uniformly modelled as
|
|
150
|
+
// a SourceMeta — the rest of the file iterates over an ordered list of
|
|
151
|
+
// these and never cares which kind it's looking at.
|
|
152
|
+
|
|
153
|
+
export interface SourceMeta {
|
|
154
|
+
id: string; // 'public' | 'enterprise-<tenant_id>'
|
|
155
|
+
display_name: string;
|
|
156
|
+
is_enterprise: boolean;
|
|
157
|
+
priority: number; // 0 = highest
|
|
158
|
+
fetch_mode: 'raw' | 'github_api';
|
|
159
|
+
base_url?: string; // raw mode
|
|
160
|
+
repo_url?: string; // github_api mode — 'github.com/owner/repo'
|
|
161
|
+
github_pat?: string; // github_api mode
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function publicBaseUrl(): string {
|
|
165
|
+
return (loadSettings().connectorsRepoUrl || '').trim() || DEFAULT_REPO;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function enterpriseToSource(es: EnterpriseSource, priority: number): SourceMeta {
|
|
169
|
+
return {
|
|
170
|
+
id: `enterprise-${es.tenant_id}`,
|
|
171
|
+
display_name: es.display_name,
|
|
172
|
+
is_enterprise: true,
|
|
173
|
+
priority,
|
|
174
|
+
fetch_mode: 'github_api',
|
|
175
|
+
repo_url: es.repo_url,
|
|
176
|
+
github_pat: es.github_pat,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Ordered list of sources, highest priority first. Always includes
|
|
181
|
+
* `public` last; enterprise sources prepend in their configured order. */
|
|
182
|
+
export function listSources(): SourceMeta[] {
|
|
183
|
+
const enterprise = listEnterpriseSources().map(enterpriseToSource);
|
|
184
|
+
const publicSrc: SourceMeta = {
|
|
185
|
+
id: 'public',
|
|
186
|
+
display_name: 'Public',
|
|
187
|
+
is_enterprise: false,
|
|
188
|
+
priority: enterprise.length,
|
|
189
|
+
fetch_mode: 'raw',
|
|
190
|
+
base_url: publicBaseUrl(),
|
|
191
|
+
};
|
|
192
|
+
return [...enterprise, publicSrc];
|
|
193
|
+
}
|
|
194
|
+
|
|
53
195
|
// ─── Paths ────────────────────────────────────────────────
|
|
54
196
|
|
|
55
197
|
function connectorsDir(): string {
|
|
56
198
|
return join(getDataDir(), 'connectors');
|
|
57
199
|
}
|
|
58
200
|
|
|
59
|
-
function
|
|
60
|
-
return join(connectorsDir(), 'registry-cache.json');
|
|
201
|
+
function sourceCacheFile(sourceId: string): string {
|
|
202
|
+
return join(connectorsDir(), 'sources', sourceId, 'registry-cache.json');
|
|
61
203
|
}
|
|
62
204
|
|
|
63
|
-
function
|
|
64
|
-
|
|
205
|
+
function sourceWizardTemplatePath(sourceId: string): string {
|
|
206
|
+
return join(connectorsDir(), 'sources', sourceId, 'wizard-template.json');
|
|
65
207
|
}
|
|
66
208
|
|
|
67
|
-
|
|
68
|
-
|
|
209
|
+
/**
|
|
210
|
+
* E3: per-dept template cache path. Each department template lands at
|
|
211
|
+
* <sourceId>/wizards/<dept>.json next to the legacy single-template
|
|
212
|
+
* file, plus an index file `wizards/_index.json` listing the dept ids
|
|
213
|
+
* + display_names so the wizard UI can render the picker without
|
|
214
|
+
* re-reading the registry.
|
|
215
|
+
*/
|
|
216
|
+
export function sourceDeptTemplatePath(sourceId: string, deptId: string): string {
|
|
217
|
+
return join(connectorsDir(), 'sources', sourceId, 'wizards', `${deptId}.json`);
|
|
218
|
+
}
|
|
219
|
+
export function sourceDeptIndexPath(sourceId: string): string {
|
|
220
|
+
return join(connectorsDir(), 'sources', sourceId, 'wizards', '_index.json');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ensureDir(p: string): void {
|
|
224
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
|
|
69
225
|
}
|
|
70
226
|
|
|
71
227
|
// ─── Cache I/O ────────────────────────────────────────────
|
|
72
228
|
|
|
73
|
-
|
|
74
|
-
const p =
|
|
229
|
+
function readSourceCache(sourceId: string): SourceCache | null {
|
|
230
|
+
const p = sourceCacheFile(sourceId);
|
|
75
231
|
if (!existsSync(p)) return null;
|
|
76
232
|
try {
|
|
77
|
-
return JSON.parse(readFileSync(p, 'utf-8')) as
|
|
233
|
+
return JSON.parse(readFileSync(p, 'utf-8')) as SourceCache;
|
|
78
234
|
} catch {
|
|
79
235
|
return null;
|
|
80
236
|
}
|
|
81
237
|
}
|
|
82
238
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
239
|
+
function writeSourceCache(sourceId: string, cache: SourceCache): void {
|
|
240
|
+
const p = sourceCacheFile(sourceId);
|
|
241
|
+
ensureDir(join(connectorsDir(), 'sources', sourceId));
|
|
242
|
+
writeFileSync(p, JSON.stringify(cache, null, 2), { mode: 0o600 });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Internal — exposed for tests / debug only. */
|
|
246
|
+
export function readCache(): SourceCache | null {
|
|
247
|
+
// Back-compat shim: callers that imported readCache previously expected
|
|
248
|
+
// a single registry view. Hand back the public source's cache (the
|
|
249
|
+
// pre-enterprise behaviour). For enterprise-aware merging, use
|
|
250
|
+
// listMarketplace instead.
|
|
251
|
+
return readSourceCache('public');
|
|
86
252
|
}
|
|
87
253
|
|
|
88
254
|
// ─── Fetch helpers ────────────────────────────────────────
|
|
89
255
|
|
|
90
|
-
async function
|
|
256
|
+
async function rawFetch(url: string, headers: Record<string, string> = {}): Promise<string> {
|
|
91
257
|
const ctrl = new AbortController();
|
|
92
258
|
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
93
259
|
try {
|
|
94
260
|
const r = await fetch(url, {
|
|
95
261
|
signal: ctrl.signal,
|
|
96
262
|
headers: {
|
|
97
|
-
// Some CDN edges (cloudflare in front of raw.githubusercontent.com)
|
|
98
|
-
// refuse default Node UA — set an explicit one.
|
|
99
263
|
'User-Agent': 'forge-connectors-sync/1.0',
|
|
100
264
|
'Cache-Control': 'no-cache',
|
|
265
|
+
...headers,
|
|
101
266
|
},
|
|
102
267
|
});
|
|
103
|
-
if (!r.ok)
|
|
268
|
+
if (!r.ok) {
|
|
269
|
+
const err: Error & { status?: number } = new Error(`HTTP ${r.status} for ${url}`);
|
|
270
|
+
err.status = r.status;
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
104
273
|
return await r.text();
|
|
105
274
|
} catch (err) {
|
|
106
|
-
|
|
107
|
-
// the user gets a useful message instead of a bare "fetch failed".
|
|
108
|
-
const e = err as Error & { cause?: unknown };
|
|
275
|
+
const e = err as Error & { cause?: unknown; status?: number };
|
|
109
276
|
const cause = e.cause instanceof Error ? `: ${e.cause.message}` : e.cause ? `: ${String(e.cause)}` : '';
|
|
110
|
-
|
|
277
|
+
const wrapped: Error & { status?: number } = new Error(`${e.message}${cause} (${url})`);
|
|
278
|
+
if (e.status) wrapped.status = e.status;
|
|
279
|
+
throw wrapped;
|
|
111
280
|
} finally {
|
|
112
281
|
clearTimeout(t);
|
|
113
282
|
}
|
|
114
283
|
}
|
|
115
284
|
|
|
116
|
-
async function fetchJson<T>(url: string): Promise<T> {
|
|
117
|
-
const txt = await fetchText(url);
|
|
118
|
-
return JSON.parse(txt) as T;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
285
|
function cacheBust(): string {
|
|
122
286
|
return `?_t=${Date.now()}`;
|
|
123
287
|
}
|
|
124
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Enterprise sources only, in configured priority order. Workflow-
|
|
291
|
+
* marketplace shares these — one enterprise repo serves connectors
|
|
292
|
+
* AND workflows from the same registry.json + bearer PAT.
|
|
293
|
+
*/
|
|
294
|
+
export function listEnterpriseSourceMetas(): SourceMeta[] {
|
|
295
|
+
return listEnterpriseSources().map(enterpriseToSource);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read an enterprise (or `public`) source's cached registry data.
|
|
300
|
+
* Returns null when the cache file is missing. Used by other
|
|
301
|
+
* marketplaces (workflows) that piggyback on the same registry.json.
|
|
302
|
+
*/
|
|
303
|
+
export function readSourceRegistry(sourceId: string): RegistryFile | null {
|
|
304
|
+
const cache = readSourceCache(sourceId);
|
|
305
|
+
return cache ? cache.data : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Fetch a file from one source. Picks raw vs github API based on mode. */
|
|
309
|
+
export async function fetchSourceFile(source: SourceMeta, relPath: string): Promise<string> {
|
|
310
|
+
if (source.fetch_mode === 'raw') {
|
|
311
|
+
return rawFetch(`${source.base_url}/${relPath}${cacheBust()}`);
|
|
312
|
+
}
|
|
313
|
+
// github_api: GET /repos/<owner>/<repo>/contents/<path>?ref=main
|
|
314
|
+
// with Accept: application/vnd.github.raw — returns the file body
|
|
315
|
+
// directly (no base64 decode needed). Works for private repos given
|
|
316
|
+
// a Fine-grained PAT with Contents: read.
|
|
317
|
+
const repo = (source.repo_url || '').replace(/^github\.com\//, '');
|
|
318
|
+
const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main`;
|
|
319
|
+
try {
|
|
320
|
+
return await rawFetch(url, {
|
|
321
|
+
Authorization: `Bearer ${source.github_pat}`,
|
|
322
|
+
Accept: 'application/vnd.github.raw',
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// GitHub returns 404 (not 401) for private repos when the PAT
|
|
326
|
+
// lacks repository access OR the Contents:Read scope. Translate
|
|
327
|
+
// that into actionable wording so users don't chase missing files.
|
|
328
|
+
const e = err as Error & { status?: number };
|
|
329
|
+
if (e.status === 404) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`PAT cannot read github.com/${repo} — open https://github.com/settings/personal-access-tokens, ` +
|
|
332
|
+
`select your Forge PAT, and ensure (a) the repo is in "Repository access" and ` +
|
|
333
|
+
`(b) "Repository permissions → Contents" is set to Read-only. ` +
|
|
334
|
+
`Then re-sync. (original: HTTP 404 ${relPath})`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (e.status === 401 || e.status === 403) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`PAT rejected by GitHub for ${repo} — token expired or revoked. ` +
|
|
340
|
+
`Generate a new fine-grained PAT and update it in Settings → Marketplace Providers. ` +
|
|
341
|
+
`(original: HTTP ${e.status} ${relPath})`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── Last-sync tracking ───────────────────────────────────
|
|
349
|
+
// In-memory map of source_id → last attempt result. Populated by
|
|
350
|
+
// syncRegistry on every run; read by /api/enterprise-keys so the UI
|
|
351
|
+
// can flag stuck/404'd sources (typically: PAT lacks Contents:Read).
|
|
352
|
+
// Lives on globalThis so HMR + multiple module copies share state.
|
|
353
|
+
//
|
|
354
|
+
type LastSync = { ok: boolean; error?: string; fetched_at: string };
|
|
355
|
+
const G = globalThis as unknown as { __forgeLastSync?: Map<string, LastSync> };
|
|
356
|
+
if (!G.__forgeLastSync) G.__forgeLastSync = new Map();
|
|
357
|
+
const lastSyncMap = G.__forgeLastSync;
|
|
358
|
+
|
|
359
|
+
export function getLastSync(sourceId: string): LastSync | null {
|
|
360
|
+
return lastSyncMap.get(sourceId) || null;
|
|
361
|
+
}
|
|
362
|
+
|
|
125
363
|
// ─── Sync API ─────────────────────────────────────────────
|
|
126
364
|
|
|
127
365
|
export interface SyncResult {
|
|
128
366
|
ok: boolean;
|
|
129
|
-
registry_count: number;
|
|
367
|
+
registry_count: number; // unique connector ids across all sources
|
|
130
368
|
manifests_refreshed: number;
|
|
131
369
|
error?: string;
|
|
132
370
|
fetched_at?: string;
|
|
371
|
+
/** Per-source breakdown so the UI can surface partial failures. */
|
|
372
|
+
sources?: Array<{
|
|
373
|
+
source_id: string;
|
|
374
|
+
display_name: string;
|
|
375
|
+
ok: boolean;
|
|
376
|
+
count: number;
|
|
377
|
+
error?: string;
|
|
378
|
+
}>;
|
|
133
379
|
}
|
|
134
380
|
|
|
135
381
|
/**
|
|
136
|
-
* Pull
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* cache the registry row — the manifest is pulled on demand at install
|
|
140
|
-
* time (see installFromRegistry).
|
|
382
|
+
* Pull registry.json from every configured source and refresh manifests
|
|
383
|
+
* for connectors the user has installed. Per-source errors are isolated:
|
|
384
|
+
* if enterprise-fortinet is unreachable, public still updates and vice versa.
|
|
141
385
|
*/
|
|
142
386
|
export async function syncRegistry(
|
|
143
387
|
opts: { refreshInstalled?: boolean; force?: boolean } = {},
|
|
144
388
|
): Promise<SyncResult> {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
389
|
+
const sources = listSources();
|
|
390
|
+
const fetched_at = new Date().toISOString();
|
|
391
|
+
const sourceResults: NonNullable<SyncResult['sources']> = [];
|
|
392
|
+
const uniqueIds = new Set<string>();
|
|
393
|
+
|
|
394
|
+
for (const source of sources) {
|
|
395
|
+
const baseLabel = source.fetch_mode === 'raw'
|
|
396
|
+
? source.base_url
|
|
397
|
+
: `github.com/${(source.repo_url || '').replace(/^github\.com\//, '')}`;
|
|
398
|
+
console.log(`[connectors] Syncing ${source.id} from ${baseLabel}`);
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const registry = await fetchSourceFile(source, 'registry.json').then(t => JSON.parse(t) as RegistryFile);
|
|
402
|
+
const count = registry.connectors?.length || 0;
|
|
403
|
+
writeSourceCache(source.id, { fetched_at, base_url: baseLabel || '', data: registry });
|
|
404
|
+
registry.connectors?.forEach(c => uniqueIds.add(c.id));
|
|
405
|
+
|
|
406
|
+
// Wizard template(s) — multi-dept (E3) wins over the legacy scalar
|
|
407
|
+
// when both are present. Each dept JSON lands at wizards/<id>.json
|
|
408
|
+
// and a sibling _index.json lists the labels so the wizard UI can
|
|
409
|
+
// build a picker without re-reading registry.json. Failures are
|
|
410
|
+
// per-file isolated; rest of the source still counts ok.
|
|
411
|
+
const depts = registry.wizard_templates;
|
|
412
|
+
if (Array.isArray(depts) && depts.length > 0) {
|
|
413
|
+
if (registry.wizard_template) {
|
|
414
|
+
console.warn(`[connectors] ${source.id}: both wizard_template and wizard_templates set — using the latter, ignoring the scalar`);
|
|
415
|
+
}
|
|
416
|
+
ensureDir(join(connectorsDir(), 'sources', source.id, 'wizards'));
|
|
417
|
+
const index: Array<{ id: string; display_name: string; has_template: boolean }> = [];
|
|
418
|
+
for (const dept of depts) {
|
|
419
|
+
if (!dept?.id) continue;
|
|
420
|
+
let hasTemplate = false;
|
|
421
|
+
if (dept.path) {
|
|
422
|
+
try {
|
|
423
|
+
const wt = await fetchSourceFile(source, dept.path);
|
|
424
|
+
writeFileSync(sourceDeptTemplatePath(source.id, dept.id), wt, { mode: 0o600 });
|
|
425
|
+
hasTemplate = true;
|
|
426
|
+
} catch (wtErr) {
|
|
427
|
+
const m = wtErr instanceof Error ? wtErr.message : String(wtErr);
|
|
428
|
+
console.warn(`[connectors] ${source.id} dept template '${dept.id}' fetch failed: ${m}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Even template-less depts land in the index — they're real
|
|
432
|
+
// departments, just without per-dept customization. The
|
|
433
|
+
// wizard renders them with public/bundled defaults but
|
|
434
|
+
// stamps installed_dept on connectors so the user's choice
|
|
435
|
+
// is still recorded.
|
|
436
|
+
index.push({ id: dept.id, display_name: dept.display_name || dept.id, has_template: hasTemplate });
|
|
437
|
+
}
|
|
438
|
+
writeFileSync(sourceDeptIndexPath(source.id), JSON.stringify(index, null, 2), { mode: 0o600 });
|
|
439
|
+
} else if (registry.wizard_template) {
|
|
440
|
+
try {
|
|
441
|
+
const wt = await fetchSourceFile(source, registry.wizard_template);
|
|
442
|
+
const wtPath = sourceWizardTemplatePath(source.id);
|
|
443
|
+
ensureDir(join(connectorsDir(), 'sources', source.id));
|
|
444
|
+
writeFileSync(wtPath, wt, { mode: 0o600 });
|
|
445
|
+
} catch (wtErr) {
|
|
446
|
+
const m = wtErr instanceof Error ? wtErr.message : String(wtErr);
|
|
447
|
+
console.warn(`[connectors] ${source.id} wizard template fetch failed: ${m}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
sourceResults.push({
|
|
452
|
+
source_id: source.id,
|
|
453
|
+
display_name: source.display_name,
|
|
454
|
+
ok: true,
|
|
455
|
+
count,
|
|
456
|
+
});
|
|
457
|
+
lastSyncMap.set(source.id, { ok: true, fetched_at });
|
|
458
|
+
} catch (err) {
|
|
459
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
460
|
+
console.warn(`[connectors] ${source.id} registry fetch failed: ${msg}`);
|
|
461
|
+
sourceResults.push({
|
|
462
|
+
source_id: source.id,
|
|
463
|
+
display_name: source.display_name,
|
|
464
|
+
ok: false,
|
|
465
|
+
count: 0,
|
|
466
|
+
error: msg,
|
|
467
|
+
});
|
|
468
|
+
lastSyncMap.set(source.id, { ok: false, error: msg, fetched_at });
|
|
469
|
+
}
|
|
154
470
|
}
|
|
155
471
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
472
|
+
const anyOk = sourceResults.some(s => s.ok);
|
|
473
|
+
if (!anyOk) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
registry_count: 0,
|
|
477
|
+
manifests_refreshed: 0,
|
|
478
|
+
error: sourceResults.map(s => `${s.source_id}: ${s.error}`).join('; '),
|
|
479
|
+
sources: sourceResults,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
163
482
|
|
|
164
483
|
let refreshed = 0;
|
|
165
484
|
if (opts.refreshInstalled) {
|
|
166
|
-
|
|
167
|
-
// 1. local manifest exists but version is behind the registry
|
|
168
|
-
// 2. config row exists but no manifest on disk yet (post-migration
|
|
169
|
-
// from pre-v0.9 plugin-configs.json — we have the user's PAT
|
|
170
|
-
// but the manifest must come from the remote)
|
|
171
|
-
const configOnly = new Set(listConfigOnlyIds());
|
|
172
|
-
for (const e of entries) {
|
|
173
|
-
const local = getConnector(e.id);
|
|
174
|
-
const isConfigOnly = configOnly.has(e.id);
|
|
175
|
-
if (!local && !isConfigOnly) continue;
|
|
176
|
-
// Normally skip when local matches registry — saves bandwidth.
|
|
177
|
-
// `force` overrides so the user can pull a fresh manifest even
|
|
178
|
-
// when the version string hasn't changed (e.g. param-description
|
|
179
|
-
// tweaks shipped under the same patch version).
|
|
180
|
-
if (!opts.force && local && local.version === e.version) continue;
|
|
181
|
-
try {
|
|
182
|
-
const yamlText = await fetchManifest(e.id, e.manifest);
|
|
183
|
-
installConnector(e.id, yamlText);
|
|
184
|
-
refreshed += 1;
|
|
185
|
-
} catch (err) {
|
|
186
|
-
console.warn(`[connectors] refresh failed for ${e.id}:`, err);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
485
|
+
refreshed = await refreshInstalledManifests(sources, opts.force === true);
|
|
189
486
|
}
|
|
190
487
|
|
|
191
488
|
return {
|
|
192
489
|
ok: true,
|
|
193
|
-
registry_count:
|
|
490
|
+
registry_count: uniqueIds.size,
|
|
194
491
|
manifests_refreshed: refreshed,
|
|
195
|
-
fetched_at
|
|
492
|
+
fetched_at,
|
|
493
|
+
sources: sourceResults,
|
|
196
494
|
};
|
|
197
495
|
}
|
|
198
496
|
|
|
199
|
-
/**
|
|
497
|
+
/** For each locally installed id, find the highest-priority source that
|
|
498
|
+
* lists it and re-pull its manifest. Skips ids whose version matches
|
|
499
|
+
* unless `force` is set. */
|
|
500
|
+
async function refreshInstalledManifests(sources: SourceMeta[], force: boolean): Promise<number> {
|
|
501
|
+
const configOnly = new Set(listConfigOnlyIds());
|
|
502
|
+
const installedIds = new Set<string>(listInstalledConnectors().map(i => i.definition.id));
|
|
503
|
+
for (const id of configOnly) installedIds.add(id);
|
|
504
|
+
|
|
505
|
+
let refreshed = 0;
|
|
506
|
+
for (const id of installedIds) {
|
|
507
|
+
const match = findEntryAcrossSources(sources, id);
|
|
508
|
+
if (!match) continue;
|
|
509
|
+
const local = getConnector(id);
|
|
510
|
+
if (!force && local && local.version === match.entry.version) continue;
|
|
511
|
+
try {
|
|
512
|
+
const yamlText = await fetchSourceFile(
|
|
513
|
+
match.source,
|
|
514
|
+
match.entry.manifest || `${id}/manifest.yaml`,
|
|
515
|
+
);
|
|
516
|
+
installConnector(id, yamlText, { source_id: match.source.id });
|
|
517
|
+
refreshed += 1;
|
|
518
|
+
} catch (err) {
|
|
519
|
+
console.warn(`[connectors] refresh failed for ${id} via ${match.source.id}:`, err);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return refreshed;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
interface SourceEntryMatch {
|
|
526
|
+
source: SourceMeta;
|
|
527
|
+
entry: NonNullable<RegistryFile['connectors']>[number];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** First source (highest priority) whose cache contains a registry entry for `id`. */
|
|
531
|
+
function findEntryAcrossSources(sources: SourceMeta[], id: string): SourceEntryMatch | null {
|
|
532
|
+
for (const source of sources) {
|
|
533
|
+
const cache = readSourceCache(source.id);
|
|
534
|
+
const entry = cache?.data.connectors?.find(c => c.id === id);
|
|
535
|
+
if (entry) return { source, entry };
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Fetch one connector's manifest. Picks the highest-priority source
|
|
541
|
+
* that lists the id; falls back to public's `<id>/manifest.yaml` URL. */
|
|
200
542
|
export async function fetchManifest(id: string, manifestPath?: string): Promise<string> {
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
543
|
+
const sources = listSources();
|
|
544
|
+
const match = findEntryAcrossSources(sources, id);
|
|
545
|
+
if (match) {
|
|
546
|
+
return fetchSourceFile(match.source, manifestPath || match.entry.manifest || `${id}/manifest.yaml`);
|
|
547
|
+
}
|
|
548
|
+
// No source lists this id — try public's conventional path as a last
|
|
549
|
+
// resort (preserves the old behaviour for ids not yet in the registry).
|
|
550
|
+
const publicSrc = sources.find(s => s.id === 'public');
|
|
551
|
+
if (!publicSrc) throw new Error(`no source available for ${id}`);
|
|
552
|
+
return fetchSourceFile(publicSrc, manifestPath || `${id}/manifest.yaml`);
|
|
204
553
|
}
|
|
205
554
|
|
|
206
|
-
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
555
|
+
/** Install a connector by id — looks it up across all source caches in
|
|
556
|
+
* priority order, pulls the manifest from the winning source, and hands
|
|
557
|
+
* off to the registry. Returns the chosen version on success. */
|
|
558
|
+
export async function installFromRegistry(id: string): Promise<{ ok: boolean; version?: string; error?: string; source_id?: string }> {
|
|
559
|
+
let sources = listSources();
|
|
560
|
+
let match = findEntryAcrossSources(sources, id);
|
|
561
|
+
|
|
562
|
+
// Cache cold — try one sync round to populate it.
|
|
563
|
+
if (!match) {
|
|
214
564
|
const r = await syncRegistry();
|
|
215
565
|
if (!r.ok) return { ok: false, error: r.error || 'registry unavailable' };
|
|
216
|
-
|
|
566
|
+
sources = listSources();
|
|
567
|
+
match = findEntryAcrossSources(sources, id);
|
|
217
568
|
}
|
|
218
|
-
|
|
219
|
-
if (!
|
|
569
|
+
|
|
570
|
+
if (!match) return { ok: false, error: `${id} not in registry` };
|
|
571
|
+
|
|
220
572
|
try {
|
|
221
|
-
const yamlText = await
|
|
222
|
-
|
|
223
|
-
|
|
573
|
+
const yamlText = await fetchSourceFile(
|
|
574
|
+
match.source,
|
|
575
|
+
match.entry.manifest || `${id}/manifest.yaml`,
|
|
576
|
+
);
|
|
577
|
+
const ok = installConnector(id, yamlText, { source_id: match.source.id });
|
|
578
|
+
return ok
|
|
579
|
+
? { ok: true, version: match.entry.version, source_id: match.source.id }
|
|
580
|
+
: { ok: false, error: 'manifest invalid' };
|
|
224
581
|
} catch (err) {
|
|
225
582
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
226
583
|
}
|
|
@@ -229,48 +586,86 @@ export async function installFromRegistry(id: string): Promise<{ ok: boolean; ve
|
|
|
229
586
|
// ─── Marketplace listing ──────────────────────────────────
|
|
230
587
|
|
|
231
588
|
/**
|
|
232
|
-
* Merge
|
|
233
|
-
*
|
|
589
|
+
* Merge per-source caches with installed state into a single marketplace
|
|
590
|
+
* view. Higher-priority sources win for same id; outranked entries land
|
|
591
|
+
* in `hidden_sources`. Pure read; no network.
|
|
234
592
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* Refresh button is the path back to a real registry listing.
|
|
593
|
+
* `fetched_at` / `base_url` refer to the highest-priority source that
|
|
594
|
+
* has a cache (mostly enterprise[0] when configured, else public) — kept
|
|
595
|
+
* for back-compat with UI that reads them.
|
|
239
596
|
*/
|
|
240
597
|
export function listMarketplace(): {
|
|
241
598
|
fetched_at?: string;
|
|
242
599
|
base_url?: string;
|
|
243
600
|
entries: ConnectorMarketEntry[];
|
|
244
601
|
} {
|
|
245
|
-
const
|
|
602
|
+
const sources = listSources();
|
|
246
603
|
const installed = listInstalledConnectors();
|
|
247
604
|
const installedById = new Map(installed.map((i) => [i.definition.id, i] as const));
|
|
248
605
|
|
|
249
|
-
|
|
606
|
+
// Walk sources in priority order; first occurrence wins, later ones
|
|
607
|
+
// collect into `hidden_sources` on the winning entry.
|
|
608
|
+
interface PendingEntry {
|
|
609
|
+
winning_source: SourceMeta;
|
|
610
|
+
entry: NonNullable<RegistryFile['connectors']>[number];
|
|
611
|
+
hidden: { source_id: string; display_name: string; version: string }[];
|
|
612
|
+
}
|
|
613
|
+
const pending = new Map<string, PendingEntry>();
|
|
614
|
+
let primaryCache: SourceCache | null = null;
|
|
250
615
|
|
|
251
|
-
|
|
616
|
+
for (const source of sources) {
|
|
617
|
+
const cache = readSourceCache(source.id);
|
|
618
|
+
if (!cache?.data.connectors?.length) continue;
|
|
619
|
+
if (!primaryCache) primaryCache = cache;
|
|
252
620
|
for (const c of cache.data.connectors) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
id: c
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
compatible: isVersionCompatible(c.min_forge_version),
|
|
264
|
-
source: 'registry',
|
|
265
|
-
});
|
|
266
|
-
installedById.delete(c.id);
|
|
621
|
+
const existing = pending.get(c.id);
|
|
622
|
+
if (!existing) {
|
|
623
|
+
pending.set(c.id, { winning_source: source, entry: c, hidden: [] });
|
|
624
|
+
} else {
|
|
625
|
+
existing.hidden.push({
|
|
626
|
+
source_id: source.id,
|
|
627
|
+
display_name: source.display_name,
|
|
628
|
+
version: c.version,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
267
631
|
}
|
|
268
632
|
}
|
|
269
633
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
634
|
+
const entries: ConnectorMarketEntry[] = [];
|
|
635
|
+
for (const { winning_source, entry, hidden } of pending.values()) {
|
|
636
|
+
const local = installedById.get(entry.id);
|
|
637
|
+
// Installed source vs. registry-winning source can differ when the
|
|
638
|
+
// user installed from one source and then added a higher-priority
|
|
639
|
+
// source that hasn't refreshed yet. Surface the *installed* one
|
|
640
|
+
// so the badge reflects on-disk truth; refresh updates it.
|
|
641
|
+
const surfaced_source_id = local?.installed_source_id || winning_source.id;
|
|
642
|
+
const updateAvailable = !!local && compareVersions(entry.version, local.installed_version) > 0;
|
|
643
|
+
entries.push({
|
|
644
|
+
id: entry.id,
|
|
645
|
+
name: entry.name,
|
|
646
|
+
version: entry.version,
|
|
647
|
+
icon: entry.icon,
|
|
648
|
+
description: entry.description,
|
|
649
|
+
author: entry.author,
|
|
650
|
+
installed_version: local?.installed_version,
|
|
651
|
+
update_available: updateAvailable,
|
|
652
|
+
// Source where a fresh install / update / reinstall would pull from
|
|
653
|
+
// (highest-priority registry that lists this id). Always emitted so
|
|
654
|
+
// the UI can flag "this is installed from public but enterprise also
|
|
655
|
+
// publishes it" even when the versions match — user still wants to
|
|
656
|
+
// know they can flip the source via Reinstall.
|
|
657
|
+
update_source_id: winning_source.id,
|
|
658
|
+
compatible: isVersionCompatible(entry.min_forge_version),
|
|
659
|
+
source: 'registry',
|
|
660
|
+
source_id: surfaced_source_id,
|
|
661
|
+
hidden_sources: hidden.length ? hidden : undefined,
|
|
662
|
+
});
|
|
663
|
+
installedById.delete(entry.id);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Locally-installed connectors that no source lists — install-local
|
|
667
|
+
// uploads, or every registry currently unreachable. Show with
|
|
668
|
+
// source=local so UI suppresses the Update path.
|
|
274
669
|
for (const local of installedById.values()) {
|
|
275
670
|
const def = local.definition;
|
|
276
671
|
entries.push({
|
|
@@ -287,7 +682,11 @@ export function listMarketplace(): {
|
|
|
287
682
|
});
|
|
288
683
|
}
|
|
289
684
|
|
|
290
|
-
return {
|
|
685
|
+
return {
|
|
686
|
+
fetched_at: primaryCache?.fetched_at,
|
|
687
|
+
base_url: primaryCache?.base_url,
|
|
688
|
+
entries,
|
|
689
|
+
};
|
|
291
690
|
}
|
|
292
691
|
|
|
293
692
|
// ─── Version helpers ──────────────────────────────────────
|
|
@@ -302,10 +701,9 @@ function compareVersions(a: string, b: string): number {
|
|
|
302
701
|
return 0;
|
|
303
702
|
}
|
|
304
703
|
|
|
305
|
-
function isVersionCompatible(
|
|
306
|
-
if (!min) return true;
|
|
704
|
+
function isVersionCompatible(_min?: string): boolean {
|
|
307
705
|
// Soft check — we don't ship a hard FORGE_VERSION constant on the
|
|
308
|
-
// server; rely on the user keeping Forge current.
|
|
309
|
-
//
|
|
706
|
+
// server; rely on the user keeping Forge current. The unused arg is
|
|
707
|
+
// kept so the schema field stays meaningful for future enforcement.
|
|
310
708
|
return true;
|
|
311
709
|
}
|