@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
@@ -1570,7 +1570,13 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
1570
1570
  await new Promise(r => setTimeout(r, 500));
1571
1571
  // cd to doc root and start claude
1572
1572
  const sf = settings.skipPermissions ? ' --dangerously-skip-permissions' : '';
1573
- spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude -c${sf}`, 'Enter'], { timeout: 5000 });
1573
+ // Resolve absolute claude path bare `claude` rides on tmux pane
1574
+ // PATH which can pick a stale global install. See
1575
+ // feedback_terminal_launch memory.
1576
+ const { resolveTerminalLaunch } = await import('./agents');
1577
+ const launch = resolveTerminalLaunch('claude', 'telegram');
1578
+ const claudeCmd = `"${launch.cliCmd}"`;
1579
+ spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && ${claudeCmd} -c${sf}`, 'Enter'], { timeout: 5000 });
1574
1580
  // Wait for Claude to start up
1575
1581
  await new Promise(r => setTimeout(r, 3000));
1576
1582
  await send(chatId, '🚀 Auto-started Docs Claude session.');
@@ -1590,7 +1596,13 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
1590
1596
  if (paneCmd === 'zsh' || paneCmd === 'bash' || paneCmd === 'fish' || !paneCmd) {
1591
1597
  try {
1592
1598
  const sf = settings.skipPermissions ? ' --dangerously-skip-permissions' : '';
1593
- spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude -c${sf}`, 'Enter'], { timeout: 5000 });
1599
+ // Resolve absolute claude path bare `claude` rides on tmux pane
1600
+ // PATH which can pick a stale global install. See
1601
+ // feedback_terminal_launch memory.
1602
+ const { resolveTerminalLaunch } = await import('./agents');
1603
+ const launch = resolveTerminalLaunch('claude', 'telegram');
1604
+ const claudeCmd = `"${launch.cliCmd}"`;
1605
+ spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && ${claudeCmd} -c${sf}`, 'Enter'], { timeout: 5000 });
1594
1606
  await new Promise(r => setTimeout(r, 3000));
1595
1607
  await send(chatId, '🚀 Auto-started Claude in Docs session.');
1596
1608
  } catch {
@@ -1,28 +1,45 @@
1
1
  /**
2
- * Workflow Marketplace — sync recipes + pipelines from a Git repo.
2
+ * Workflow Marketplace — sync recipes + pipelines from one or more Git
3
+ * repos. Mirrors the connector marketplace's multi-source model:
3
4
  *
4
- * Repo layout (default: aiwatching/forge-workflow):
5
+ * Priority 0..N-1: Enterprise repos (the SAME repos used by connector
6
+ * sync — each enterprise registry.json declares its connectors AND
7
+ * its workflow templates). Workflow side reads the recipes/pipelines
8
+ * arrays out of the cache connector sync already wrote.
9
+ *
10
+ * Priority N (lowest): Public — settings.workflowRepoUrl (default
11
+ * aiwatching/forge-workflow). Fetched via raw.githubusercontent.com.
12
+ *
13
+ * Repo layout (same in public and enterprise):
5
14
  *
6
15
  * registry.json
7
- * { version, updated_at, recipes: [...summary], pipelines: [...summary] }
8
- * recipes/<name>/recipe.yaml ← the actual recipe content
9
- * recipes/<name>/info.json optional richer metadata (rating, tags)
16
+ * { version, recipes: [...], pipelines: [...], (connectors: [...]) }
17
+ * recipes/<name>/recipe.yaml
18
+ * recipes/<name>/info.json (optional)
10
19
  * pipelines/<name>/pipeline.yaml
11
- * pipelines/<name>/info.json
20
+ * pipelines/<name>/info.json (optional)
12
21
  *
13
- * Local cache: <dataDir>/workflow-cache.json — last fetched registry.
14
- * Installed status is derived by checking the files on disk under
15
- * <dataDir>/recipes/ (handled by lib/jobs/recipes.ts)
16
- * <dataDir>/flows/ (handled by lib/pipeline.ts)
22
+ * Local cache: <dataDir>/workflow-cache.json — public registry only.
23
+ * Enterprise registries live in the connector cache; we read them via
24
+ * `readSourceRegistry` from lib/connectors/sync.ts. Installed status
25
+ * is derived by scanning files on disk under <dataDir>/recipes/ and
26
+ * <dataDir>/flows/ (handled by lib/jobs/recipes.ts and lib/pipeline.ts).
17
27
  */
18
28
 
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
29
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
20
30
  import { join } from 'node:path';
21
31
  import YAML from 'yaml';
22
32
  import { getDataDir } from './dirs';
23
33
  import { loadSettings } from './settings';
24
34
  import { listRecipes } from './jobs/recipes';
25
35
  import { listWorkflows } from './pipeline';
36
+ import {
37
+ listEnterpriseSourceMetas,
38
+ readSourceRegistry,
39
+ fetchSourceFile,
40
+ type SourceMeta,
41
+ type RegistryFile,
42
+ } from './connectors/sync';
26
43
 
27
44
  export type WorkflowKind = 'recipe' | 'pipeline';
28
45
 
@@ -40,23 +57,43 @@ export interface MarketplaceEntry {
40
57
  installed: boolean;
41
58
  installed_version?: string;
42
59
  has_update?: boolean;
60
+ /** Which marketplace source provided the winning entry. */
61
+ source_id?: string;
62
+ /** Other sources that also list this name but were outranked. */
63
+ hidden_sources?: { source_id: string; display_name: string; version: string }[];
43
64
  }
44
65
 
45
66
  const CACHE_FILE = () => join(getDataDir(), 'workflow-cache.json');
46
67
  const REQUEST_TIMEOUT_MS = 10_000;
47
68
 
48
- function baseUrl(): string {
49
- return (loadSettings().workflowRepoUrl || '').replace(/\/+$/, '');
69
+ const DEFAULT_PUBLIC_REPO = 'https://raw.githubusercontent.com/aiwatching/forge-workflow/main';
70
+
71
+ function publicBaseUrl(): string {
72
+ return (loadSettings().workflowRepoUrl || '').trim().replace(/\/+$/, '') || DEFAULT_PUBLIC_REPO;
50
73
  }
51
74
 
52
- interface RegistryFile {
53
- version: number;
54
- updated_at?: string;
55
- recipes?: Array<Partial<MarketplaceEntry>>;
56
- pipelines?: Array<Partial<MarketplaceEntry>>;
75
+ /** Public workflow source meta — separate repo from connectors, so we
76
+ * build it locally rather than reusing the connector sync's `public`. */
77
+ function publicWorkflowSource(priority: number): SourceMeta {
78
+ return {
79
+ id: 'public',
80
+ display_name: 'Public',
81
+ is_enterprise: false,
82
+ priority,
83
+ fetch_mode: 'raw',
84
+ base_url: publicBaseUrl(),
85
+ };
57
86
  }
58
87
 
59
- function readCache(): RegistryFile | null {
88
+ /** Ordered list of workflow sources, highest priority first. */
89
+ function listSources(): SourceMeta[] {
90
+ const enterprise = listEnterpriseSourceMetas();
91
+ return [...enterprise, publicWorkflowSource(enterprise.length)];
92
+ }
93
+
94
+ // ─── Public cache I/O ──────────────────────────────────────
95
+
96
+ function readPublicCache(): RegistryFile | null {
60
97
  try {
61
98
  const path = CACHE_FILE();
62
99
  if (!existsSync(path)) return null;
@@ -64,15 +101,31 @@ function readCache(): RegistryFile | null {
64
101
  } catch { return null; }
65
102
  }
66
103
 
67
- function writeCache(data: RegistryFile): void {
104
+ function writePublicCache(data: RegistryFile): void {
68
105
  mkdirSync(getDataDir(), { recursive: true });
69
106
  writeFileSync(CACHE_FILE(), JSON.stringify(data, null, 2), { mode: 0o600 });
70
107
  }
71
108
 
109
+ function readSourceData(source: SourceMeta): RegistryFile | null {
110
+ if (source.is_enterprise) {
111
+ // Piggyback on connector sync's per-source cache — enterprise
112
+ // registry.json contains recipes/pipelines alongside connectors.
113
+ return readSourceRegistry(source.id);
114
+ }
115
+ return readPublicCache();
116
+ }
117
+
72
118
  // ─── Sync ──────────────────────────────────────────────────
73
119
 
120
+ /**
121
+ * Refresh the workflow registry for every source whose registry we
122
+ * own. Enterprise registries are owned by the connector sync — those
123
+ * are pulled in `lib/connectors/sync.ts#syncRegistry`. This function
124
+ * just refreshes the public workflow registry; the UI typically
125
+ * triggers both syncs back-to-back.
126
+ */
74
127
  export async function syncMarketplace(): Promise<{ ok: boolean; recipes: number; pipelines: number; error?: string }> {
75
- const base = baseUrl();
128
+ const base = publicBaseUrl();
76
129
  if (!base) return { ok: false, recipes: 0, pipelines: 0, error: 'workflowRepoUrl is empty' };
77
130
  const controller = new AbortController();
78
131
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -85,8 +138,8 @@ export async function syncMarketplace(): Promise<{ ok: boolean; recipes: number;
85
138
  clearTimeout(timer);
86
139
  if (!res.ok) return { ok: false, recipes: 0, pipelines: 0, error: `registry fetch failed: HTTP ${res.status}` };
87
140
  const data = (await res.json()) as RegistryFile;
88
- writeCache(data);
89
- console.log(`[workflow-marketplace] synced ${data.recipes?.length ?? 0} recipes + ${data.pipelines?.length ?? 0} pipelines`);
141
+ writePublicCache(data);
142
+ console.log(`[workflow-marketplace] public synced ${data.recipes?.length ?? 0} recipes + ${data.pipelines?.length ?? 0} pipelines`);
90
143
  return { ok: true, recipes: data.recipes?.length ?? 0, pipelines: data.pipelines?.length ?? 0 };
91
144
  } catch (e) {
92
145
  clearTimeout(timer);
@@ -96,7 +149,7 @@ export async function syncMarketplace(): Promise<{ ok: boolean; recipes: number;
96
149
  }
97
150
  }
98
151
 
99
- // ─── List (merged registry + installed) ────────────────────
152
+ // ─── List (merged across sources) ──────────────────────────
100
153
 
101
154
  function compareVersions(a: string, b: string): number {
102
155
  const pa = (a || '0.0.0').split('.').map(Number);
@@ -108,34 +161,68 @@ function compareVersions(a: string, b: string): number {
108
161
  return 0;
109
162
  }
110
163
 
164
+ interface PendingEntry {
165
+ winning_source: SourceMeta;
166
+ raw: NonNullable<RegistryFile['recipes']>[number] | NonNullable<RegistryFile['pipelines']>[number];
167
+ hidden: { source_id: string; display_name: string; version: string }[];
168
+ }
169
+
170
+ /** Walk sources in priority order; first occurrence wins, later ones
171
+ * collect into hidden_sources on the winning entry. */
172
+ function mergeAcrossSources(
173
+ kindKey: 'recipes' | 'pipelines',
174
+ sources: SourceMeta[],
175
+ ): Map<string, PendingEntry> {
176
+ const out = new Map<string, PendingEntry>();
177
+ for (const source of sources) {
178
+ const data = readSourceData(source);
179
+ const items = (data?.[kindKey] || []) as NonNullable<RegistryFile['recipes']>;
180
+ for (const item of items) {
181
+ if (!item?.name) continue;
182
+ const existing = out.get(item.name);
183
+ if (!existing) {
184
+ out.set(item.name, { winning_source: source, raw: item, hidden: [] });
185
+ } else {
186
+ existing.hidden.push({
187
+ source_id: source.id,
188
+ display_name: source.display_name,
189
+ version: item.version || '0.0.0',
190
+ });
191
+ }
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ function toEntry(kind: WorkflowKind, pe: PendingEntry): MarketplaceEntry {
198
+ const r = pe.raw;
199
+ return {
200
+ kind,
201
+ name: r.name,
202
+ display_name: r.display_name || r.name,
203
+ description: r.description,
204
+ version: r.version || '0.0.0',
205
+ author: r.author,
206
+ tags: r.tags,
207
+ score: r.score ?? 0,
208
+ rating: r.rating ?? 0,
209
+ source: 'registry',
210
+ source_id: pe.winning_source.id,
211
+ hidden_sources: pe.hidden.length ? pe.hidden : undefined,
212
+ installed: false,
213
+ };
214
+ }
215
+
111
216
  export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: MarketplaceEntry[] } {
112
- const cache: RegistryFile = readCache() || { version: 1, recipes: [], pipelines: [] };
217
+ const sources = listSources();
113
218
  const installedRecipes = listRecipes();
114
219
  const installedWorkflows = listWorkflows();
115
220
 
221
+ // Recipes
116
222
  const recipeMap = new Map<string, MarketplaceEntry>();
117
- for (const r of (cache.recipes || [])) {
118
- if (!r.name) continue;
119
- recipeMap.set(r.name, {
120
- kind: 'recipe',
121
- name: r.name,
122
- display_name: r.display_name || r.name,
123
- description: r.description,
124
- version: r.version || '0.0.0',
125
- author: r.author,
126
- tags: r.tags,
127
- score: r.score ?? 0,
128
- rating: r.rating ?? 0,
129
- source: 'registry',
130
- installed: false,
131
- });
223
+ for (const [name, pe] of mergeAcrossSources('recipes', sources)) {
224
+ recipeMap.set(name, toEntry('recipe', pe));
132
225
  }
133
- // Marketplace = registry catalog ONLY. Recipes / pipelines that
134
- // exist only locally (uploaded copy, hand-edited file, custom
135
- // workflow) belong to the user and don't show here — Marketplace
136
- // is for browsing the remote forge-workflow repo. We still mark
137
- // registry items as `installed` when a local copy exists, so the
138
- // UI can show "your local copy is on v0.2.0, registry is v0.3.0".
139
226
  for (const ir of installedRecipes) {
140
227
  const cached = recipeMap.get(ir.name);
141
228
  if (cached) {
@@ -143,31 +230,14 @@ export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: Mar
143
230
  cached.installed_version = ir.version;
144
231
  cached.has_update = compareVersions(cached.version, ir.version) > 0;
145
232
  }
146
- // else: local-only recipe — intentionally skipped
233
+ // local-only recipe — intentionally skipped (Marketplace = remote catalog)
147
234
  }
148
235
 
236
+ // Pipelines
149
237
  const pipelineMap = new Map<string, MarketplaceEntry>();
150
- for (const p of (cache.pipelines || [])) {
151
- if (!p.name) continue;
152
- pipelineMap.set(p.name, {
153
- kind: 'pipeline',
154
- name: p.name,
155
- display_name: p.display_name || p.name,
156
- description: p.description,
157
- version: p.version || '0.0.0',
158
- author: p.author,
159
- tags: p.tags,
160
- score: p.score ?? 0,
161
- rating: p.rating ?? 0,
162
- source: 'registry',
163
- installed: false,
164
- });
238
+ for (const [name, pe] of mergeAcrossSources('pipelines', sources)) {
239
+ pipelineMap.set(name, toEntry('pipeline', pe));
165
240
  }
166
- // Same model for pipelines: Marketplace lists only registry items.
167
- // Local-only workflows (yours, edited, custom) live in the
168
- // Pipelines tab list — they're shown there, not here. This split
169
- // keeps "browse upstream catalog" and "manage your local files"
170
- // as two distinct mental modes.
171
241
  for (const iw of installedWorkflows) {
172
242
  if (iw.builtin) continue;
173
243
  const cached = pipelineMap.get(iw.name);
@@ -175,7 +245,7 @@ export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: Mar
175
245
  cached.installed = true;
176
246
  cached.installed_version = (iw as any).version || cached.version;
177
247
  }
178
- // else: local-only pipeline — intentionally skipped
248
+ // local-only pipeline — intentionally skipped
179
249
  }
180
250
 
181
251
  return {
@@ -186,53 +256,50 @@ export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: Mar
186
256
 
187
257
  // ─── Install ───────────────────────────────────────────────
188
258
 
189
- async function fetchText(url: string): Promise<string | null> {
190
- const controller = new AbortController();
191
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
192
- try {
193
- // Cache-bust + no-cache so a Sync click bypasses any intermediate
194
- // HTTP cache and pulls the latest yaml from the repo CDN.
195
- const sep = url.includes('?') ? '&' : '?';
196
- const res = await fetch(`${url}${sep}_t=${Date.now()}`, {
197
- signal: controller.signal,
198
- headers: { 'Cache-Control': 'no-cache', 'User-Agent': 'forge-workflow-sync/1.0' },
199
- });
200
- clearTimeout(timer);
201
- if (!res.ok) return null;
202
- return await res.text();
203
- } catch { clearTimeout(timer); return null; }
259
+ /** Find the highest-priority source that lists `name` for `kind`. */
260
+ function findEntrySource(kind: WorkflowKind, name: string): SourceMeta | null {
261
+ const kindKey = kind === 'recipe' ? 'recipes' : 'pipelines';
262
+ for (const source of listSources()) {
263
+ const data = readSourceData(source);
264
+ const items = (data?.[kindKey] || []) as Array<{ name?: string }>;
265
+ if (items.some(i => i?.name === name)) return source;
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /** Try a few candidate paths inside a source — `<kind>/<name>/<kind>.yaml`
271
+ * first (canonical), then the flat `<kind>/<name>.yaml` fallback. */
272
+ async function fetchWorkflowYaml(source: SourceMeta, kind: WorkflowKind, name: string): Promise<string | null> {
273
+ const subdir = kind === 'recipe' ? 'recipes' : 'pipelines';
274
+ const file = kind === 'recipe' ? 'recipe.yaml' : 'pipeline.yaml';
275
+ const candidates = [`${subdir}/${name}/${file}`, `${subdir}/${name}.yaml`];
276
+ for (const path of candidates) {
277
+ try {
278
+ const text = await fetchSourceFile(source, path);
279
+ if (text) return text;
280
+ } catch {
281
+ // try next candidate
282
+ }
283
+ }
284
+ return null;
204
285
  }
205
286
 
206
287
  export async function installFromMarketplace(
207
288
  kind: WorkflowKind,
208
289
  name: string,
209
290
  opts: { target_name?: string; overwrite?: boolean } = {},
210
- ): Promise<{ ok: boolean; error?: string; installed_as?: string }> {
211
- const base = baseUrl();
212
- if (!base) return { ok: false, error: 'workflowRepoUrl is empty' };
291
+ ): Promise<{ ok: boolean; error?: string; installed_as?: string; source_id?: string }> {
213
292
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
214
-
215
293
  const targetName = opts.target_name || name;
216
294
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(targetName)) {
217
295
  return { ok: false, error: `target_name "${targetName}" must be lowercase alphanumerics + hyphens/underscores` };
218
296
  }
219
297
 
220
- // Recipes live under recipes/<name>/recipe.yaml, pipelines under
221
- // pipelines/<name>/pipeline.yaml. Falls back to a flat file too,
222
- // for repos that don't use a per-item folder.
223
- const subdir = kind === 'recipe' ? 'recipes' : 'pipelines';
224
- const file = kind === 'recipe' ? 'recipe.yaml' : 'pipeline.yaml';
225
- const candidates = [
226
- `${base}/${subdir}/${name}/${file}`,
227
- `${base}/${subdir}/${name}.yaml`,
228
- ];
229
-
230
- let yamlText: string | null = null;
231
- for (const url of candidates) {
232
- yamlText = await fetchText(url);
233
- if (yamlText) break;
234
- }
235
- if (!yamlText) return { ok: false, error: `${kind} "${name}" not found in registry` };
298
+ const source = findEntrySource(kind, name);
299
+ if (!source) return { ok: false, error: `${kind} "${name}" not found in registry try Sync` };
300
+
301
+ const yamlText = await fetchWorkflowYaml(source, kind, name);
302
+ if (!yamlText) return { ok: false, error: `${kind} "${name}" file not reachable in ${source.id}` };
236
303
 
237
304
  // Validate it parses + has the right shape before writing.
238
305
  let parsed: any;
@@ -255,16 +322,16 @@ export async function installFromMarketplace(
255
322
  return { ok: false, error: `"${targetName}" already exists locally — pass overwrite=true to replace it, or choose a different name` };
256
323
  }
257
324
 
258
- // If renaming, rewrite the yaml's top-level `name:` so the on-disk
259
- // copy matches its filename. Without this the file would be loaded
260
- // as the original name and collide with other clones.
325
+ // Rename rewrites the yaml's top-level `name:` so the on-disk copy
326
+ // matches its filename. Without this the file would be loaded under
327
+ // the original name and collide with other clones.
261
328
  let outputYaml = yamlText;
262
329
  if (targetName !== name) {
263
330
  parsed.name = targetName;
264
331
  outputYaml = YAML.stringify(parsed);
265
332
  }
266
333
  writeFileSync(targetPath, outputYaml, { mode: 0o600 });
267
- return { ok: true, installed_as: targetName };
334
+ return { ok: true, installed_as: targetName, source_id: source.id };
268
335
  }
269
336
 
270
337
  // ─── Uninstall ─────────────────────────────────────────────
@@ -274,7 +341,6 @@ export function uninstallFromMarketplace(kind: WorkflowKind, name: string): { ok
274
341
  const dir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
275
342
  const yamlPath = join(dir, `${name}.yaml`);
276
343
  const ymlPath = join(dir, `${name}.yml`);
277
- const { unlinkSync } = require('node:fs');
278
344
  if (existsSync(yamlPath)) { unlinkSync(yamlPath); return { ok: true }; }
279
345
  if (existsSync(ymlPath)) { unlinkSync(ymlPath); return { ok: true }; }
280
346
  return { ok: false, error: 'not installed' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.39",
3
+ "version": "0.10.41",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
 
3
- export function middleware(req: NextRequest) {
3
+ export function proxy(req: NextRequest) {
4
4
  // Skip auth entirely in dev mode
5
5
  const isDev = process.env.NODE_ENV !== 'production' || process.env.FORGE_DEV === '1';
6
6
  if (isDev) {
@@ -15,6 +15,7 @@ export function middleware(req: NextRequest) {
15
15
  pathname.startsWith('/login') ||
16
16
  pathname.startsWith('/api/auth') ||
17
17
  pathname === '/api/version' ||
18
+ pathname === '/api/bridge-info' ||
18
19
  pathname.startsWith('/api/telegram') ||
19
20
  (pathname.startsWith('/api/workspace') && (pathname.endsWith('/smith') || pathname === '/api/workspace')) ||
20
21
  pathname.startsWith('/_next') ||
@@ -20,10 +20,16 @@ export function getDb(dbPath: string): Database.Database {
20
20
  }
21
21
 
22
22
  function initSchema(db: Database.Database) {
23
- // Migrations for existing tables (catch duplicate column errors silently)
23
+ // Migrations for existing tables silence both
24
+ // "duplicate column" (column already added on a prior run) AND
25
+ // "no such table" (fresh DB — table doesn't exist yet, will be
26
+ // CREATEd in the block below; ALTER is irrelevant).
27
+ // Anything else (typo in SQL, real corruption) still logs as an error.
24
28
  const migrate = (sql: string) => {
25
29
  try { db.exec(sql); } catch (e: any) {
26
- if (!String(e.message).includes('duplicate column')) console.error('[db] Migration failed:', sql, e.message);
30
+ const msg = String(e.message);
31
+ if (msg.includes('duplicate column') || msg.includes('no such table')) return;
32
+ console.error('[db] Migration failed:', sql, e.message);
27
33
  }
28
34
  };
29
35
  migrate('ALTER TABLE tasks ADD COLUMN scheduled_at TEXT');
@@ -83,13 +83,6 @@
83
83
  "enabled": true
84
84
  },
85
85
 
86
- "jenkins": {
87
- "config": {
88
- "instances": "[{\"name\":\"default-jenkins\",\"base_url\":\"http://nac-dev-jenkins.fortinet-us.com:8080\",\"username\":\"${jenkins_username}\",\"api_token\":\"${jenkins_api_token}\",\"gitlab_pat\":\"${gitlab_pat}\",\"gitlab_token_name\":\"${gitlab_token_name}\",\"gitlab_token_name_param\":\"${gitlab_token_name}\",\"gitlab_pat_param\":\"\",\"inject_params\":\"[{\\\"name\\\":\\\"TOKEN_USER\\\",\\\"value\\\":\\\"${gitlab_token_name}\\\"},{\\\"name\\\":\\\"TOKEN_PASSWORD\\\",\\\"value\\\":\\\"${gitlab_pat}\\\"}]\"}]"
89
- },
90
- "enabled": true
91
- },
92
-
93
86
  "tp": {
94
87
  "config": {
95
88
  "base_url": "https://nac-tp.fortinet-us.com",