@aion0/forge 0.10.39 → 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.
Files changed (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -6
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +189 -30
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. package/templates/connector-config-template.json +0 -7
@@ -1,35 +1,52 @@
1
1
  /**
2
- * Connector sync — pull registry + manifests from forge-connectors.
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
- * Default base URL: https://raw.githubusercontent.com/aiwatching/forge-connectors/main
5
- * Override via settings.connectorsRepoUrl.
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
- * 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)
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 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.
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
- interface RegistryFile {
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 RegistryCache {
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 cacheFile(): string {
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 ensureDir(p: string): void {
64
- if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
205
+ function sourceWizardTemplatePath(sourceId: string): string {
206
+ return join(connectorsDir(), 'sources', sourceId, 'wizard-template.json');
65
207
  }
66
208
 
67
- function baseUrl(): string {
68
- return (loadSettings().connectorsRepoUrl || '').trim() || DEFAULT_REPO;
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
- export function readCache(): RegistryCache | null {
74
- const p = cacheFile();
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 RegistryCache;
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 writeCache(cache: RegistryCache): void {
84
- ensureDir(connectorsDir());
85
- writeFileSync(cacheFile(), JSON.stringify(cache, null, 2), { mode: 0o600 });
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 fetchText(url: string): Promise<string> {
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) throw new Error(`HTTP ${r.status} for ${url}`);
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
- // 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 };
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
- throw new Error(`${e.message}${cause} (${url})`);
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 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).
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 base = baseUrl();
146
- console.log(`[connectors] Syncing registry from ${base}`);
147
- let registry: RegistryFile;
148
- try {
149
- registry = await fetchJson<RegistryFile>(`${base}/registry.json${cacheBust()}`);
150
- } catch (err) {
151
- const msg = err instanceof Error ? err.message : String(err);
152
- console.warn(`[connectors] registry fetch failed: ${msg}`);
153
- return { ok: false, registry_count: 0, manifests_refreshed: 0, error: msg };
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 entries = registry.connectors || [];
157
- const cache: RegistryCache = {
158
- fetched_at: new Date().toISOString(),
159
- base_url: base,
160
- data: registry,
161
- };
162
- writeCache(cache);
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
- // Two cases we want to pull manifests for:
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: entries.length,
490
+ registry_count: uniqueIds.size,
194
491
  manifests_refreshed: refreshed,
195
- fetched_at: cache.fetched_at,
492
+ fetched_at,
493
+ sources: sourceResults,
196
494
  };
197
495
  }
198
496
 
199
- /** Fetch one connector's manifest from the configured repo. */
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 base = baseUrl();
202
- const path = manifestPath || `${id}/manifest.yaml`;
203
- return fetchText(`${base}/${path}${cacheBust()}`);
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
- * Install a connector by id looks it up in the registry cache (or
208
- * fetches a fresh one), pulls the manifest, and hands off to the
209
- * registry. Returns the chosen version on success.
210
- */
211
- export async function installFromRegistry(id: string): Promise<{ ok: boolean; version?: string; error?: string }> {
212
- let cache = readCache();
213
- if (!cache) {
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
- cache = readCache();
566
+ sources = listSources();
567
+ match = findEntryAcrossSources(sources, id);
217
568
  }
218
- const entry = cache?.data.connectors?.find((c) => c.id === id);
219
- if (!entry) return { ok: false, error: `${id} not in registry` };
569
+
570
+ if (!match) return { ok: false, error: `${id} not in registry` };
571
+
220
572
  try {
221
- const yamlText = await fetchManifest(id, entry.manifest);
222
- const ok = installConnector(id, yamlText);
223
- return ok ? { ok: true, version: entry.version } : { ok: false, error: 'manifest invalid' };
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 the registry cache with installed state to produce the
233
- * marketplace view. Pure read; no network.
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
- * If the registry cache is missing (first boot, offline, sync failed)
236
- * we still surface locally-installed connectors so the user sees
237
- * something labelled as installed without a remote counterpart.
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 cache = readCache();
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
- const entries: ConnectorMarketEntry[] = [];
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
- if (cache?.data.connectors?.length) {
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 local = installedById.get(c.id);
254
- entries.push({
255
- id: c.id,
256
- name: c.name,
257
- version: c.version,
258
- icon: c.icon,
259
- description: c.description,
260
- author: c.author,
261
- installed_version: local?.installed_version,
262
- update_available: !!local && compareVersions(c.version, local.installed_version) > 0,
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
- // Locally-installed connectors with no registry counterpart — either
271
- // installed via /api/connectors/install-local, or the registry is
272
- // currently unreachable. Either way: show them with source=local so
273
- // the UI suppresses the Update path.
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 { fetched_at: cache?.fetched_at, base_url: cache?.base_url, entries };
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(min?: string): boolean {
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
- // TODO: read version from package.json at boot if we want to enforce.
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
  }