@aion0/forge 0.8.1 → 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 -6
- 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/skills/install-local/route.ts +282 -0
- package/components/ConnectorsPanel.tsx +526 -211
- 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 +13 -22
- package/lib/chat/protocols/http.ts +1 -1
- package/lib/chat/protocols/shell.ts +1 -1
- package/lib/chat/tool-dispatcher.ts +20 -20
- 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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connector Registry — load, install, uninstall, list connectors.
|
|
3
|
+
*
|
|
4
|
+
* Manifests live in `<dataDir>/connectors/<id>/manifest.yaml`, fetched
|
|
5
|
+
* by lib/connectors/sync.ts from the remote forge-connectors repo.
|
|
6
|
+
* There are no built-in connector manifests — a fresh install has zero
|
|
7
|
+
* connectors until the first registry sync completes.
|
|
8
|
+
*
|
|
9
|
+
* Per-instance config (settings filled in by the user — host URL,
|
|
10
|
+
* PAT, etc.) lives in `<dataDir>/connector-configs.json`. Secret
|
|
11
|
+
* fields are encrypted at rest with AES-256-GCM via lib/crypto.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import YAML from 'yaml';
|
|
17
|
+
import { getDataDir } from '../dirs';
|
|
18
|
+
import { encryptSecret, decryptSecret, isEncrypted } from '../crypto';
|
|
19
|
+
import type {
|
|
20
|
+
ConnectorDefinition,
|
|
21
|
+
ConnectorEntry,
|
|
22
|
+
ConnectorFieldSchema,
|
|
23
|
+
} from './types';
|
|
24
|
+
|
|
25
|
+
// ─── Paths ────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function connectorsDir(): string {
|
|
28
|
+
return join(getDataDir(), 'connectors');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function manifestPath(id: string): string {
|
|
32
|
+
return join(connectorsDir(), id, 'manifest.yaml');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configsFile(): string {
|
|
36
|
+
return join(getDataDir(), 'connector-configs.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureDir(p: string): void {
|
|
40
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Manifest loading ─────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function parseManifest(raw: string): ConnectorDefinition | null {
|
|
46
|
+
try {
|
|
47
|
+
const def = YAML.parse(raw) as ConnectorDefinition;
|
|
48
|
+
if (!def?.id || !def?.name) return null;
|
|
49
|
+
if (!def.tools && !def.connectors?.length) return null;
|
|
50
|
+
if (!def.version) def.version = '0.0.0';
|
|
51
|
+
if (!def.icon) def.icon = '🔌';
|
|
52
|
+
return def;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read the manifest for a single connector id; returns null if missing. */
|
|
59
|
+
export function getConnector(id: string): ConnectorDefinition | null {
|
|
60
|
+
const p = manifestPath(id);
|
|
61
|
+
if (!existsSync(p)) return null;
|
|
62
|
+
try {
|
|
63
|
+
return parseManifest(readFileSync(p, 'utf-8'));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** List installed connector ids (presence of `<dir>/manifest.yaml`). */
|
|
70
|
+
export function listConnectorIds(): string[] {
|
|
71
|
+
const dir = connectorsDir();
|
|
72
|
+
if (!existsSync(dir)) return [];
|
|
73
|
+
const out: string[] = [];
|
|
74
|
+
for (const name of readdirSync(dir)) {
|
|
75
|
+
try {
|
|
76
|
+
const sub = join(dir, name);
|
|
77
|
+
if (!statSync(sub).isDirectory()) continue;
|
|
78
|
+
if (existsSync(join(sub, 'manifest.yaml'))) out.push(name);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Normalize a connector to its entry list.
|
|
86
|
+
* 1:1 shape (top-level `tools`) → synthesizes one entry with id === connector id.
|
|
87
|
+
* 1:N shape (`connectors[]`) → returned as-is.
|
|
88
|
+
*/
|
|
89
|
+
export function getConnectorEntries(def: ConnectorDefinition): ConnectorEntry[] {
|
|
90
|
+
if (def.connectors?.length) return def.connectors;
|
|
91
|
+
if (def.tools) {
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
id: def.id,
|
|
95
|
+
tools: def.tools,
|
|
96
|
+
settings: def.settings,
|
|
97
|
+
host_match: def.host_match,
|
|
98
|
+
login_redirect: def.login_redirect,
|
|
99
|
+
runner: def.runner,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Config store ─────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wire shape of `<dataDir>/connector-configs.json`:
|
|
110
|
+
* { "<id>": { config, installed_version, enabled } }
|
|
111
|
+
*/
|
|
112
|
+
interface ConnectorConfigRow {
|
|
113
|
+
config: Record<string, unknown>;
|
|
114
|
+
installed_version: string;
|
|
115
|
+
enabled?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type ConfigStore = Record<string, ConnectorConfigRow>;
|
|
119
|
+
|
|
120
|
+
function getSecretFieldNames(def: ConnectorDefinition | null): string[] {
|
|
121
|
+
if (!def) return [];
|
|
122
|
+
const fields: Array<[string, ConnectorFieldSchema]> = [];
|
|
123
|
+
if (def.settings) fields.push(...Object.entries(def.settings));
|
|
124
|
+
for (const entry of def.connectors || []) {
|
|
125
|
+
if (entry.settings) fields.push(...Object.entries(entry.settings));
|
|
126
|
+
}
|
|
127
|
+
return fields
|
|
128
|
+
.filter(([, schema]) => {
|
|
129
|
+
const t = String((schema as any)?.type || '');
|
|
130
|
+
return t === 'secret' || t === 'password';
|
|
131
|
+
})
|
|
132
|
+
.map(([name]) => name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadStoreRaw(): ConfigStore {
|
|
136
|
+
const p = configsFile();
|
|
137
|
+
if (!existsSync(p)) return {};
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(readFileSync(p, 'utf-8')) as ConfigStore;
|
|
140
|
+
} catch {
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function loadStore(): ConfigStore {
|
|
146
|
+
const store = loadStoreRaw();
|
|
147
|
+
// Decrypt secret fields lazily — readers expect plaintext.
|
|
148
|
+
for (const [id, row] of Object.entries(store)) {
|
|
149
|
+
if (!row?.config) continue;
|
|
150
|
+
const def = getConnector(id);
|
|
151
|
+
const secrets = getSecretFieldNames(def);
|
|
152
|
+
if (!secrets.length) continue;
|
|
153
|
+
for (const key of secrets) {
|
|
154
|
+
const v = row.config[key];
|
|
155
|
+
if (typeof v === 'string' && isEncrypted(v)) {
|
|
156
|
+
try { row.config[key] = decryptSecret(v); }
|
|
157
|
+
catch (err) {
|
|
158
|
+
console.warn(`[connectors] failed to decrypt ${id}.${key}`, err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return store;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function saveStore(store: ConfigStore): void {
|
|
167
|
+
ensureDir(getDataDir());
|
|
168
|
+
// Deep-copy so we can encrypt without mutating caller-visible values.
|
|
169
|
+
const out: ConfigStore = {};
|
|
170
|
+
for (const [id, row] of Object.entries(store)) {
|
|
171
|
+
const def = getConnector(id);
|
|
172
|
+
const secrets = new Set(getSecretFieldNames(def));
|
|
173
|
+
const encryptedConfig: Record<string, unknown> = {};
|
|
174
|
+
for (const [k, v] of Object.entries(row.config || {})) {
|
|
175
|
+
if (secrets.has(k) && typeof v === 'string' && v.length > 0 && !isEncrypted(v)) {
|
|
176
|
+
try { encryptedConfig[k] = encryptSecret(v); }
|
|
177
|
+
catch { encryptedConfig[k] = v; }
|
|
178
|
+
} else {
|
|
179
|
+
encryptedConfig[k] = v;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out[id] = {
|
|
183
|
+
config: encryptedConfig,
|
|
184
|
+
installed_version: row.installed_version,
|
|
185
|
+
enabled: row.enabled !== false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
writeFileSync(configsFile(), JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Public API ───────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export interface InstalledConnector {
|
|
194
|
+
definition: ConnectorDefinition;
|
|
195
|
+
config: Record<string, unknown>;
|
|
196
|
+
installed_version: string;
|
|
197
|
+
enabled: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function isInstalled(id: string): boolean {
|
|
201
|
+
return existsSync(manifestPath(id)) && !!loadStoreRaw()[id];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* IDs that have a config row but no manifest on disk yet. Happens
|
|
206
|
+
* during the migration window after upgrading from pre-v0.9 Forge —
|
|
207
|
+
* the row is moved across, but the manifest can only be fetched
|
|
208
|
+
* from the remote registry. Sync uses this to know what to pull.
|
|
209
|
+
*/
|
|
210
|
+
export function listConfigOnlyIds(): string[] {
|
|
211
|
+
const store = loadStoreRaw();
|
|
212
|
+
const out: string[] = [];
|
|
213
|
+
for (const id of Object.keys(store)) {
|
|
214
|
+
if (!existsSync(manifestPath(id))) out.push(id);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function listInstalledConnectors(): InstalledConnector[] {
|
|
220
|
+
const store = loadStore();
|
|
221
|
+
const out: InstalledConnector[] = [];
|
|
222
|
+
for (const id of listConnectorIds()) {
|
|
223
|
+
const def = getConnector(id);
|
|
224
|
+
if (!def) continue;
|
|
225
|
+
const row = store[id];
|
|
226
|
+
if (!row) continue; // manifest on disk but no config row → not installed
|
|
227
|
+
out.push({
|
|
228
|
+
definition: def,
|
|
229
|
+
config: row.config || {},
|
|
230
|
+
installed_version: row.installed_version || def.version,
|
|
231
|
+
enabled: row.enabled !== false,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getInstalledConnector(id: string): InstalledConnector | null {
|
|
238
|
+
const def = getConnector(id);
|
|
239
|
+
if (!def) return null;
|
|
240
|
+
const row = loadStore()[id];
|
|
241
|
+
if (!row) return null;
|
|
242
|
+
return {
|
|
243
|
+
definition: def,
|
|
244
|
+
config: row.config || {},
|
|
245
|
+
installed_version: row.installed_version || def.version,
|
|
246
|
+
enabled: row.enabled !== false,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Install (or upgrade) a connector by writing its manifest YAML and
|
|
252
|
+
* recording the version in the config store. Existing user config is
|
|
253
|
+
* preserved across upgrades.
|
|
254
|
+
*/
|
|
255
|
+
export function installConnector(id: string, manifestYaml: string): boolean {
|
|
256
|
+
const def = parseManifest(manifestYaml);
|
|
257
|
+
if (!def || def.id !== id) return false;
|
|
258
|
+
const dir = join(connectorsDir(), id);
|
|
259
|
+
ensureDir(dir);
|
|
260
|
+
writeFileSync(join(dir, 'manifest.yaml'), manifestYaml, { mode: 0o600 });
|
|
261
|
+
|
|
262
|
+
const store = loadStore();
|
|
263
|
+
const prev = store[id]?.config || {};
|
|
264
|
+
store[id] = {
|
|
265
|
+
config: prev,
|
|
266
|
+
installed_version: def.version,
|
|
267
|
+
enabled: true,
|
|
268
|
+
};
|
|
269
|
+
saveStore(store);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Uninstall a connector.
|
|
275
|
+
* keepConfig=true (default): drop the manifest but retain settings/PAT
|
|
276
|
+
* so re-installing later restores them.
|
|
277
|
+
* keepConfig=false: also delete the config row.
|
|
278
|
+
*/
|
|
279
|
+
export function uninstallConnector(id: string, keepConfig = true): boolean {
|
|
280
|
+
const dir = join(connectorsDir(), id);
|
|
281
|
+
if (existsSync(dir)) {
|
|
282
|
+
try { rmSync(dir, { recursive: true, force: true }); }
|
|
283
|
+
catch { return false; }
|
|
284
|
+
}
|
|
285
|
+
if (!keepConfig) {
|
|
286
|
+
const store = loadStore();
|
|
287
|
+
delete store[id];
|
|
288
|
+
saveStore(store);
|
|
289
|
+
} else {
|
|
290
|
+
// Mark disabled so the runtime doesn't try to use a now-missing manifest.
|
|
291
|
+
const store = loadStore();
|
|
292
|
+
if (store[id]) {
|
|
293
|
+
store[id].enabled = false;
|
|
294
|
+
saveStore(store);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function setConnectorConfig(
|
|
301
|
+
id: string,
|
|
302
|
+
config: Record<string, unknown>,
|
|
303
|
+
): boolean {
|
|
304
|
+
const store = loadStore();
|
|
305
|
+
const row = store[id];
|
|
306
|
+
if (!row) {
|
|
307
|
+
// Allow setting config before install if a manifest already exists
|
|
308
|
+
// — supports auto-install-then-configure flows in the UI.
|
|
309
|
+
const def = getConnector(id);
|
|
310
|
+
if (!def) return false;
|
|
311
|
+
store[id] = { config, installed_version: def.version, enabled: true };
|
|
312
|
+
} else {
|
|
313
|
+
store[id] = { ...row, config };
|
|
314
|
+
}
|
|
315
|
+
saveStore(store);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function setConnectorEnabled(id: string, enabled: boolean): boolean {
|
|
320
|
+
const store = loadStore();
|
|
321
|
+
if (!store[id]) return false;
|
|
322
|
+
store[id].enabled = enabled;
|
|
323
|
+
saveStore(store);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Re-export for callers (matches plugin-registry surface so it's familiar).
|
|
328
|
+
export { getSecretFieldNames };
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connector sync — pull registry + manifests from forge-connectors.
|
|
3
|
+
*
|
|
4
|
+
* Default base URL: https://raw.githubusercontent.com/aiwatching/forge-connectors/main
|
|
5
|
+
* Override via settings.connectorsRepoUrl.
|
|
6
|
+
*
|
|
7
|
+
* The remote repo is expected to expose:
|
|
8
|
+
* /registry.json — listing of all available connectors (light)
|
|
9
|
+
* /<id>/manifest.yaml — the full YAML for one connector
|
|
10
|
+
*
|
|
11
|
+
* Local state:
|
|
12
|
+
* <dataDir>/connectors/registry-cache.json — last successful fetch
|
|
13
|
+
* <dataDir>/connectors/<id>/manifest.yaml — per-connector manifest
|
|
14
|
+
* (installed copy, refreshed on update)
|
|
15
|
+
*
|
|
16
|
+
* Sync is called non-blocking on startup and on-demand from the
|
|
17
|
+
* Settings Marketplace UI. Network failures are silent — callers
|
|
18
|
+
* keep the cached state. No built-in fallback: if the cache is empty
|
|
19
|
+
* and the network is down, the marketplace is just empty.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { loadSettings } from '../settings';
|
|
25
|
+
import { getDataDir } from '../dirs';
|
|
26
|
+
import { getConnector, installConnector, listConfigOnlyIds, listInstalledConnectors } from './registry';
|
|
27
|
+
import type { ConnectorMarketEntry } from './types';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_REPO = 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main';
|
|
30
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
31
|
+
|
|
32
|
+
interface RegistryFile {
|
|
33
|
+
version?: number;
|
|
34
|
+
connectors?: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
version: string;
|
|
38
|
+
icon?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
author?: string;
|
|
41
|
+
min_forge_version?: string;
|
|
42
|
+
/** Override manifest path; defaults to `<id>/manifest.yaml`. */
|
|
43
|
+
manifest?: string;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RegistryCache {
|
|
48
|
+
fetched_at: string;
|
|
49
|
+
base_url: string;
|
|
50
|
+
data: RegistryFile;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Paths ────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function connectorsDir(): string {
|
|
56
|
+
return join(getDataDir(), 'connectors');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cacheFile(): string {
|
|
60
|
+
return join(connectorsDir(), 'registry-cache.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureDir(p: string): void {
|
|
64
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function baseUrl(): string {
|
|
68
|
+
return (loadSettings().connectorsRepoUrl || '').trim() || DEFAULT_REPO;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Cache I/O ────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export function readCache(): RegistryCache | null {
|
|
74
|
+
const p = cacheFile();
|
|
75
|
+
if (!existsSync(p)) return null;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(p, 'utf-8')) as RegistryCache;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeCache(cache: RegistryCache): void {
|
|
84
|
+
ensureDir(connectorsDir());
|
|
85
|
+
writeFileSync(cacheFile(), JSON.stringify(cache, null, 2), { mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Fetch helpers ────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
async function fetchText(url: string): Promise<string> {
|
|
91
|
+
const ctrl = new AbortController();
|
|
92
|
+
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
93
|
+
try {
|
|
94
|
+
const r = await fetch(url, {
|
|
95
|
+
signal: ctrl.signal,
|
|
96
|
+
headers: {
|
|
97
|
+
// Some CDN edges (cloudflare in front of raw.githubusercontent.com)
|
|
98
|
+
// refuse default Node UA — set an explicit one.
|
|
99
|
+
'User-Agent': 'forge-connectors-sync/1.0',
|
|
100
|
+
'Cache-Control': 'no-cache',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
|
|
104
|
+
return await r.text();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// undici wraps the real failure under `err.cause`. Surface it so
|
|
107
|
+
// the user gets a useful message instead of a bare "fetch failed".
|
|
108
|
+
const e = err as Error & { cause?: unknown };
|
|
109
|
+
const cause = e.cause instanceof Error ? `: ${e.cause.message}` : e.cause ? `: ${String(e.cause)}` : '';
|
|
110
|
+
throw new Error(`${e.message}${cause} (${url})`);
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(t);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
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
|
+
function cacheBust(): string {
|
|
122
|
+
return `?_t=${Date.now()}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Sync API ─────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface SyncResult {
|
|
128
|
+
ok: boolean;
|
|
129
|
+
registry_count: number;
|
|
130
|
+
manifests_refreshed: number;
|
|
131
|
+
error?: string;
|
|
132
|
+
fetched_at?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pull the registry.json and refresh on-disk manifests for any
|
|
137
|
+
* connector the user has installed (so install_version stays accurate
|
|
138
|
+
* and scripts get bug fixes). For not-yet-installed entries we only
|
|
139
|
+
* cache the registry row — the manifest is pulled on demand at install
|
|
140
|
+
* time (see installFromRegistry).
|
|
141
|
+
*/
|
|
142
|
+
export async function syncRegistry(opts: { refreshInstalled?: boolean } = {}): Promise<SyncResult> {
|
|
143
|
+
const base = baseUrl();
|
|
144
|
+
console.log(`[connectors] Syncing registry from ${base}`);
|
|
145
|
+
let registry: RegistryFile;
|
|
146
|
+
try {
|
|
147
|
+
registry = await fetchJson<RegistryFile>(`${base}/registry.json${cacheBust()}`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
150
|
+
console.warn(`[connectors] registry fetch failed: ${msg}`);
|
|
151
|
+
return { ok: false, registry_count: 0, manifests_refreshed: 0, error: msg };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const entries = registry.connectors || [];
|
|
155
|
+
const cache: RegistryCache = {
|
|
156
|
+
fetched_at: new Date().toISOString(),
|
|
157
|
+
base_url: base,
|
|
158
|
+
data: registry,
|
|
159
|
+
};
|
|
160
|
+
writeCache(cache);
|
|
161
|
+
|
|
162
|
+
let refreshed = 0;
|
|
163
|
+
if (opts.refreshInstalled) {
|
|
164
|
+
// Two cases we want to pull manifests for:
|
|
165
|
+
// 1. local manifest exists but version is behind the registry
|
|
166
|
+
// 2. config row exists but no manifest on disk yet (post-migration
|
|
167
|
+
// from pre-v0.9 plugin-configs.json — we have the user's PAT
|
|
168
|
+
// but the manifest must come from the remote)
|
|
169
|
+
const configOnly = new Set(listConfigOnlyIds());
|
|
170
|
+
for (const e of entries) {
|
|
171
|
+
const local = getConnector(e.id);
|
|
172
|
+
const isConfigOnly = configOnly.has(e.id);
|
|
173
|
+
if (!local && !isConfigOnly) continue;
|
|
174
|
+
if (local && local.version === e.version) continue;
|
|
175
|
+
try {
|
|
176
|
+
const yamlText = await fetchManifest(e.id, e.manifest);
|
|
177
|
+
installConnector(e.id, yamlText);
|
|
178
|
+
refreshed += 1;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.warn(`[connectors] refresh failed for ${e.id}:`, err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
registry_count: entries.length,
|
|
188
|
+
manifests_refreshed: refreshed,
|
|
189
|
+
fetched_at: cache.fetched_at,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Fetch one connector's manifest from the configured repo. */
|
|
194
|
+
export async function fetchManifest(id: string, manifestPath?: string): Promise<string> {
|
|
195
|
+
const base = baseUrl();
|
|
196
|
+
const path = manifestPath || `${id}/manifest.yaml`;
|
|
197
|
+
return fetchText(`${base}/${path}${cacheBust()}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Install a connector by id — looks it up in the registry cache (or
|
|
202
|
+
* fetches a fresh one), pulls the manifest, and hands off to the
|
|
203
|
+
* registry. Returns the chosen version on success.
|
|
204
|
+
*/
|
|
205
|
+
export async function installFromRegistry(id: string): Promise<{ ok: boolean; version?: string; error?: string }> {
|
|
206
|
+
let cache = readCache();
|
|
207
|
+
if (!cache) {
|
|
208
|
+
const r = await syncRegistry();
|
|
209
|
+
if (!r.ok) return { ok: false, error: r.error || 'registry unavailable' };
|
|
210
|
+
cache = readCache();
|
|
211
|
+
}
|
|
212
|
+
const entry = cache?.data.connectors?.find((c) => c.id === id);
|
|
213
|
+
if (!entry) return { ok: false, error: `${id} not in registry` };
|
|
214
|
+
try {
|
|
215
|
+
const yamlText = await fetchManifest(id, entry.manifest);
|
|
216
|
+
const ok = installConnector(id, yamlText);
|
|
217
|
+
return ok ? { ok: true, version: entry.version } : { ok: false, error: 'manifest invalid' };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Marketplace listing ──────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Merge the registry cache with installed state to produce the
|
|
227
|
+
* marketplace view. Pure read; no network.
|
|
228
|
+
*
|
|
229
|
+
* If the registry cache is missing (first boot, offline, sync failed)
|
|
230
|
+
* we still surface locally-installed connectors so the user sees
|
|
231
|
+
* something — labelled as installed without a remote counterpart.
|
|
232
|
+
* Refresh button is the path back to a real registry listing.
|
|
233
|
+
*/
|
|
234
|
+
export function listMarketplace(): {
|
|
235
|
+
fetched_at?: string;
|
|
236
|
+
base_url?: string;
|
|
237
|
+
entries: ConnectorMarketEntry[];
|
|
238
|
+
} {
|
|
239
|
+
const cache = readCache();
|
|
240
|
+
const installed = listInstalledConnectors();
|
|
241
|
+
const installedById = new Map(installed.map((i) => [i.definition.id, i] as const));
|
|
242
|
+
|
|
243
|
+
const entries: ConnectorMarketEntry[] = [];
|
|
244
|
+
|
|
245
|
+
if (cache?.data.connectors?.length) {
|
|
246
|
+
for (const c of cache.data.connectors) {
|
|
247
|
+
const local = installedById.get(c.id);
|
|
248
|
+
entries.push({
|
|
249
|
+
id: c.id,
|
|
250
|
+
name: c.name,
|
|
251
|
+
version: c.version,
|
|
252
|
+
icon: c.icon,
|
|
253
|
+
description: c.description,
|
|
254
|
+
author: c.author,
|
|
255
|
+
installed_version: local?.installed_version,
|
|
256
|
+
update_available: !!local && compareVersions(c.version, local.installed_version) > 0,
|
|
257
|
+
compatible: isVersionCompatible(c.min_forge_version),
|
|
258
|
+
source: 'registry',
|
|
259
|
+
});
|
|
260
|
+
installedById.delete(c.id);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Locally-installed connectors with no registry counterpart — either
|
|
265
|
+
// installed via /api/connectors/install-local, or the registry is
|
|
266
|
+
// currently unreachable. Either way: show them with source=local so
|
|
267
|
+
// the UI suppresses the Update path.
|
|
268
|
+
for (const local of installedById.values()) {
|
|
269
|
+
const def = local.definition;
|
|
270
|
+
entries.push({
|
|
271
|
+
id: def.id,
|
|
272
|
+
name: def.name,
|
|
273
|
+
version: local.installed_version,
|
|
274
|
+
icon: def.icon,
|
|
275
|
+
description: def.description,
|
|
276
|
+
author: def.author,
|
|
277
|
+
installed_version: local.installed_version,
|
|
278
|
+
update_available: false,
|
|
279
|
+
compatible: true,
|
|
280
|
+
source: 'local',
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { fetched_at: cache?.fetched_at, base_url: cache?.base_url, entries };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Version helpers ──────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
function compareVersions(a: string, b: string): number {
|
|
290
|
+
const pa = (a || '0.0.0').split('.').map((n) => Number(n) || 0);
|
|
291
|
+
const pb = (b || '0.0.0').split('.').map((n) => Number(n) || 0);
|
|
292
|
+
for (let i = 0; i < 3; i++) {
|
|
293
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
294
|
+
if (d !== 0) return d;
|
|
295
|
+
}
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isVersionCompatible(min?: string): boolean {
|
|
300
|
+
if (!min) return true;
|
|
301
|
+
// Soft check — we don't ship a hard FORGE_VERSION constant on the
|
|
302
|
+
// server; rely on the user keeping Forge current.
|
|
303
|
+
// TODO: read version from package.json at boot if we want to enforce.
|
|
304
|
+
return true;
|
|
305
|
+
}
|