@aion0/forge 0.8.5 → 0.8.7

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.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Workflow Marketplace — sync recipes + pipelines from a Git repo.
3
+ *
4
+ * Repo layout (default: aiwatching/forge-workflow):
5
+ *
6
+ * 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)
10
+ * pipelines/<name>/pipeline.yaml
11
+ * pipelines/<name>/info.json
12
+ *
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)
17
+ */
18
+
19
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import YAML from 'yaml';
22
+ import { getDataDir } from './dirs';
23
+ import { loadSettings } from './settings';
24
+ import { listRecipes } from './jobs/recipes';
25
+ import { listWorkflows } from './pipeline';
26
+
27
+ export type WorkflowKind = 'recipe' | 'pipeline';
28
+
29
+ export interface MarketplaceEntry {
30
+ kind: WorkflowKind;
31
+ name: string;
32
+ display_name: string;
33
+ description?: string;
34
+ version: string;
35
+ author?: string;
36
+ tags?: string[];
37
+ score?: number;
38
+ rating?: number;
39
+ source: 'registry' | 'local';
40
+ installed: boolean;
41
+ installed_version?: string;
42
+ has_update?: boolean;
43
+ }
44
+
45
+ const CACHE_FILE = () => join(getDataDir(), 'workflow-cache.json');
46
+ const REQUEST_TIMEOUT_MS = 10_000;
47
+
48
+ function baseUrl(): string {
49
+ return (loadSettings().workflowRepoUrl || '').replace(/\/+$/, '');
50
+ }
51
+
52
+ interface RegistryFile {
53
+ version: number;
54
+ updated_at?: string;
55
+ recipes?: Array<Partial<MarketplaceEntry>>;
56
+ pipelines?: Array<Partial<MarketplaceEntry>>;
57
+ }
58
+
59
+ function readCache(): RegistryFile | null {
60
+ try {
61
+ const path = CACHE_FILE();
62
+ if (!existsSync(path)) return null;
63
+ return JSON.parse(readFileSync(path, 'utf-8'));
64
+ } catch { return null; }
65
+ }
66
+
67
+ function writeCache(data: RegistryFile): void {
68
+ mkdirSync(getDataDir(), { recursive: true });
69
+ writeFileSync(CACHE_FILE(), JSON.stringify(data, null, 2), { mode: 0o600 });
70
+ }
71
+
72
+ // ─── Sync ──────────────────────────────────────────────────
73
+
74
+ export async function syncMarketplace(): Promise<{ ok: boolean; recipes: number; pipelines: number; error?: string }> {
75
+ const base = baseUrl();
76
+ if (!base) return { ok: false, recipes: 0, pipelines: 0, error: 'workflowRepoUrl is empty' };
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
79
+ try {
80
+ const cacheBust = `_t=${Date.now()}`;
81
+ const res = await fetch(`${base}/registry.json?${cacheBust}`, {
82
+ signal: controller.signal,
83
+ headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
84
+ });
85
+ clearTimeout(timer);
86
+ if (!res.ok) return { ok: false, recipes: 0, pipelines: 0, error: `registry fetch failed: HTTP ${res.status}` };
87
+ 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`);
90
+ return { ok: true, recipes: data.recipes?.length ?? 0, pipelines: data.pipelines?.length ?? 0 };
91
+ } catch (e) {
92
+ clearTimeout(timer);
93
+ const msg = (e as Error).message || String(e);
94
+ console.warn(`[workflow-marketplace] sync failed:`, msg);
95
+ return { ok: false, recipes: 0, pipelines: 0, error: msg };
96
+ }
97
+ }
98
+
99
+ // ─── List (merged registry + installed) ────────────────────
100
+
101
+ function compareVersions(a: string, b: string): number {
102
+ const pa = (a || '0.0.0').split('.').map(Number);
103
+ const pb = (b || '0.0.0').split('.').map(Number);
104
+ for (let i = 0; i < 3; i++) {
105
+ const diff = (pa[i] || 0) - (pb[i] || 0);
106
+ if (diff !== 0) return diff;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: MarketplaceEntry[] } {
112
+ const cache: RegistryFile = readCache() || { version: 1, recipes: [], pipelines: [] };
113
+ const installedRecipes = listRecipes();
114
+ const installedWorkflows = listWorkflows();
115
+
116
+ 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
+ });
132
+ }
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
+ for (const ir of installedRecipes) {
140
+ const cached = recipeMap.get(ir.name);
141
+ if (cached) {
142
+ cached.installed = true;
143
+ cached.installed_version = ir.version;
144
+ cached.has_update = compareVersions(cached.version, ir.version) > 0;
145
+ }
146
+ // else: local-only recipe — intentionally skipped
147
+ }
148
+
149
+ 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
+ });
165
+ }
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
+ for (const iw of installedWorkflows) {
172
+ if (iw.builtin) continue;
173
+ const cached = pipelineMap.get(iw.name);
174
+ if (cached) {
175
+ cached.installed = true;
176
+ cached.installed_version = (iw as any).version || cached.version;
177
+ }
178
+ // else: local-only pipeline — intentionally skipped
179
+ }
180
+
181
+ return {
182
+ recipes: Array.from(recipeMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
183
+ pipelines: Array.from(pipelineMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
184
+ };
185
+ }
186
+
187
+ // ─── Install ───────────────────────────────────────────────
188
+
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
+ const res = await fetch(url, { signal: controller.signal });
194
+ clearTimeout(timer);
195
+ if (!res.ok) return null;
196
+ return await res.text();
197
+ } catch { clearTimeout(timer); return null; }
198
+ }
199
+
200
+ export async function installFromMarketplace(
201
+ kind: WorkflowKind,
202
+ name: string,
203
+ opts: { target_name?: string; overwrite?: boolean } = {},
204
+ ): Promise<{ ok: boolean; error?: string; installed_as?: string }> {
205
+ const base = baseUrl();
206
+ if (!base) return { ok: false, error: 'workflowRepoUrl is empty' };
207
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
208
+
209
+ const targetName = opts.target_name || name;
210
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(targetName)) {
211
+ return { ok: false, error: `target_name "${targetName}" must be lowercase alphanumerics + hyphens/underscores` };
212
+ }
213
+
214
+ // Recipes live under recipes/<name>/recipe.yaml, pipelines under
215
+ // pipelines/<name>/pipeline.yaml. Falls back to a flat file too,
216
+ // for repos that don't use a per-item folder.
217
+ const subdir = kind === 'recipe' ? 'recipes' : 'pipelines';
218
+ const file = kind === 'recipe' ? 'recipe.yaml' : 'pipeline.yaml';
219
+ const candidates = [
220
+ `${base}/${subdir}/${name}/${file}`,
221
+ `${base}/${subdir}/${name}.yaml`,
222
+ ];
223
+
224
+ let yamlText: string | null = null;
225
+ for (const url of candidates) {
226
+ yamlText = await fetchText(url);
227
+ if (yamlText) break;
228
+ }
229
+ if (!yamlText) return { ok: false, error: `${kind} "${name}" not found in registry` };
230
+
231
+ // Validate it parses + has the right shape before writing.
232
+ let parsed: any;
233
+ try {
234
+ parsed = YAML.parse(yamlText);
235
+ if (!parsed?.name) return { ok: false, error: 'yaml missing top-level "name"' };
236
+ if (parsed.name !== name) return { ok: false, error: `yaml name "${parsed.name}" doesn't match registry id "${name}"` };
237
+ if (kind === 'recipe') {
238
+ if (!Array.isArray(parsed.params)) return { ok: false, error: 'recipe yaml missing "params" array' };
239
+ if (!parsed.job) return { ok: false, error: 'recipe yaml missing "job" block' };
240
+ }
241
+ } catch (e) {
242
+ return { ok: false, error: `invalid YAML: ${(e as Error).message}` };
243
+ }
244
+
245
+ const targetDir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
246
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
247
+ const targetPath = join(targetDir, `${targetName}.yaml`);
248
+ if (existsSync(targetPath) && !opts.overwrite) {
249
+ return { ok: false, error: `"${targetName}" already exists locally — pass overwrite=true to replace it, or choose a different name` };
250
+ }
251
+
252
+ // If renaming, rewrite the yaml's top-level `name:` so the on-disk
253
+ // copy matches its filename. Without this the file would be loaded
254
+ // as the original name and collide with other clones.
255
+ let outputYaml = yamlText;
256
+ if (targetName !== name) {
257
+ parsed.name = targetName;
258
+ outputYaml = YAML.stringify(parsed);
259
+ }
260
+ writeFileSync(targetPath, outputYaml, { mode: 0o600 });
261
+ return { ok: true, installed_as: targetName };
262
+ }
263
+
264
+ // ─── Uninstall ─────────────────────────────────────────────
265
+
266
+ export function uninstallFromMarketplace(kind: WorkflowKind, name: string): { ok: boolean; error?: string } {
267
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
268
+ const dir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
269
+ const yamlPath = join(dir, `${name}.yaml`);
270
+ const ymlPath = join(dir, `${name}.yml`);
271
+ const { unlinkSync } = require('node:fs');
272
+ if (existsSync(yamlPath)) { unlinkSync(yamlPath); return { ok: true }; }
273
+ if (existsSync(ymlPath)) { unlinkSync(ymlPath); return { ok: true }; }
274
+ return { ok: false, error: 'not installed' };
275
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {