@aion0/forge 0.5.48 → 0.5.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +0 -1
- package/RELEASE_NOTES.md +50 -4
- package/app/api/craft-system/build/route.ts +78 -0
- package/app/api/craft-system/delete/route.ts +28 -0
- package/app/api/craft-system/helpers/file/route.ts +20 -0
- package/app/api/craft-system/helpers/openapi/route.ts +27 -0
- package/app/api/craft-system/helpers/shell/route.ts +26 -0
- package/app/api/craft-system/inject/route.ts +41 -0
- package/app/api/craft-system/kill-session/route.ts +19 -0
- package/app/api/craft-system/manifest/route.ts +71 -0
- package/app/api/craft-system/marketplace/install/route.ts +11 -0
- package/app/api/craft-system/marketplace/route.ts +18 -0
- package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
- package/app/api/craft-system/marketplace/update/route.ts +10 -0
- package/app/api/craft-system/marketplace/updates/route.ts +17 -0
- package/app/api/craft-system/publish/auto/route.ts +173 -0
- package/app/api/craft-system/publish/route.ts +50 -0
- package/app/api/craft-system/registry/route.ts +16 -0
- package/app/api/craft-system/runtime/react/route.ts +26 -0
- package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
- package/app/api/craft-system/runtime/sdk/route.ts +18 -0
- package/app/api/craft-system/scaffold/route.ts +164 -0
- package/app/api/craft-system/sessions/route.ts +45 -0
- package/app/api/craft-system/storage/route.ts +44 -0
- package/app/api/craft-system/tmux-sessions/route.ts +62 -0
- package/app/api/craft-system/ui/route.ts +30 -0
- package/app/api/crafts/[name]/[...route]/route.ts +48 -0
- package/app/api/crafts/route.ts +29 -0
- package/app/api/tasks/[id]/log/entry/route.ts +13 -0
- package/app/api/tasks/[id]/log/route.ts +23 -0
- package/app/api/tasks/route.ts +2 -2
- package/components/CraftBuilder.tsx +241 -0
- package/components/CraftManifestEditor.tsx +258 -0
- package/components/CraftMarketplaceModal.tsx +207 -0
- package/components/CraftPublishModal.tsx +285 -0
- package/components/CraftTabs.tsx +279 -0
- package/components/CraftTerminal.tsx +305 -0
- package/components/CraftTerminalPicker.tsx +179 -0
- package/components/CraftsDropdown.tsx +186 -0
- package/components/CraftsMarketplacePanel.tsx +194 -0
- package/components/ProjectDetail.tsx +102 -13
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +250 -52
- package/lib/craft-sdk/client.tsx +260 -0
- package/lib/craft-sdk/server.ts +14 -0
- package/lib/crafts/loader.ts +117 -0
- package/lib/crafts/registry.ts +272 -0
- package/lib/crafts/runtime.ts +208 -0
- package/lib/crafts/types.ts +92 -0
- package/lib/forge-skills/craft-builder.md +231 -0
- package/lib/help-docs/15-crafts.md +127 -0
- package/lib/help-docs/CLAUDE.md +2 -2
- package/lib/task-manager.ts +110 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/src/types/index.ts +7 -0
- package/tsconfig.json +6 -0
- package/app/api/migration/config/route.ts +0 -19
- package/app/api/migration/discover/route.ts +0 -26
- package/app/api/migration/failures/route.ts +0 -35
- package/app/api/migration/fix/route.ts +0 -82
- package/app/api/migration/run/route.ts +0 -22
- package/app/api/migration/run-batch/route.ts +0 -86
- package/components/MigrationCockpit.tsx +0 -541
- package/lib/help-docs/14-migration.md +0 -154
- package/lib/migration/differ.ts +0 -193
- package/lib/migration/discoverer.ts +0 -363
- package/lib/migration/openapi.ts +0 -137
- package/lib/migration/runner.ts +0 -219
- package/lib/migration/store.ts +0 -89
- package/lib/migration/types.ts +0 -115
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Client-side SDK exposed to crafts. Lives behind a React context so each craft
|
|
4
|
+
// gets its project + craft scope automatically.
|
|
5
|
+
|
|
6
|
+
import React, { createContext, useContext, useCallback, useEffect, useState, useRef } from 'react';
|
|
7
|
+
|
|
8
|
+
interface CraftContextValue {
|
|
9
|
+
projectPath: string;
|
|
10
|
+
projectName: string;
|
|
11
|
+
craftName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CraftContext = createContext<CraftContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export function CraftSDKProvider({ projectPath, projectName, craftName, children }: {
|
|
17
|
+
projectPath: string; projectName: string; craftName: string; children: React.ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
return <CraftContext.Provider value={{ projectPath, projectName, craftName }}>{children}</CraftContext.Provider>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function useCraftCtx(): CraftContextValue {
|
|
23
|
+
const v = useContext(CraftContext);
|
|
24
|
+
if (!v) throw new Error('Craft SDK hook used outside CraftSDKProvider');
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── 1. useProject ────────────────────────────────────────
|
|
29
|
+
export interface ProjectInfo {
|
|
30
|
+
projectPath: string;
|
|
31
|
+
projectName: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useProject(): ProjectInfo {
|
|
35
|
+
const { projectPath, projectName } = useCraftCtx();
|
|
36
|
+
return { projectPath, projectName };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── 2. useForgeFetch ─────────────────────────────────────
|
|
40
|
+
export interface FetchState<T> {
|
|
41
|
+
data: T | null;
|
|
42
|
+
loading: boolean;
|
|
43
|
+
error: string | null;
|
|
44
|
+
refetch: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useForgeFetch<T = any>(path: string, opts: { auto?: boolean; init?: RequestInit } = {}): FetchState<T> {
|
|
48
|
+
const { projectPath } = useCraftCtx();
|
|
49
|
+
const [data, setData] = useState<T | null>(null);
|
|
50
|
+
const [loading, setLoading] = useState(false);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [tick, setTick] = useState(0);
|
|
53
|
+
|
|
54
|
+
const fullPath = path.includes('?') ? `${path}&projectPath=${encodeURIComponent(projectPath)}` : `${path}?projectPath=${encodeURIComponent(projectPath)}`;
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (opts.auto === false) return;
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
setLoading(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
fetch(fullPath, opts.init)
|
|
62
|
+
.then(async r => {
|
|
63
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
64
|
+
return r.json();
|
|
65
|
+
})
|
|
66
|
+
.then(j => { if (!cancelled) { setData(j); setLoading(false); } })
|
|
67
|
+
.catch(e => { if (!cancelled) { setError(e?.message || String(e)); setLoading(false); } });
|
|
68
|
+
return () => { cancelled = true; };
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [fullPath, tick]);
|
|
71
|
+
|
|
72
|
+
return { data, loading, error, refetch: () => setTick(t => t + 1) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 3. useInject ─────────────────────────────────────────
|
|
76
|
+
export function useInject(): (text: string, opts?: { sessionName?: string }) => Promise<{ ok: boolean; sessionName?: string }> {
|
|
77
|
+
const { projectPath, projectName, craftName } = useCraftCtx();
|
|
78
|
+
return useCallback(async (text: string, opts = {}) => {
|
|
79
|
+
const r = await fetch('/api/craft-system/inject', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ projectPath, text, sessionName: opts.sessionName }),
|
|
83
|
+
});
|
|
84
|
+
const j = await r.json();
|
|
85
|
+
return { ok: !!j.ok, sessionName: j.sessionName };
|
|
86
|
+
}, [projectPath, projectName, craftName]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 4. useTask ───────────────────────────────────────────
|
|
90
|
+
export interface TaskHandle {
|
|
91
|
+
id: string;
|
|
92
|
+
watch: (onLog: (entry: any) => void, onDone?: (task: any) => void) => () => void;
|
|
93
|
+
cancel: () => Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useTask(): (prompt: string, opts?: { agent?: string }) => Promise<TaskHandle> {
|
|
97
|
+
const { projectPath, projectName } = useCraftCtx();
|
|
98
|
+
return useCallback(async (prompt: string, opts = {}) => {
|
|
99
|
+
const r = await fetch('/api/tasks', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ projectName, projectPath, prompt, agent: opts.agent }),
|
|
103
|
+
});
|
|
104
|
+
const t = await r.json();
|
|
105
|
+
if (!t?.id) throw new Error(t?.error || 'failed to create task');
|
|
106
|
+
return {
|
|
107
|
+
id: t.id,
|
|
108
|
+
watch: (onLog, onDone) => {
|
|
109
|
+
const es = new EventSource(`/api/tasks/${t.id}/stream`);
|
|
110
|
+
es.onmessage = (ev) => {
|
|
111
|
+
try {
|
|
112
|
+
const data = JSON.parse(ev.data);
|
|
113
|
+
if (data.type === 'log') onLog(data.entry);
|
|
114
|
+
else if (data.type === 'complete' && onDone) { onDone(data.task); es.close(); }
|
|
115
|
+
} catch {}
|
|
116
|
+
};
|
|
117
|
+
es.onerror = () => es.close();
|
|
118
|
+
return () => es.close();
|
|
119
|
+
},
|
|
120
|
+
cancel: async () => { await fetch(`/api/tasks/${t.id}/cancel`, { method: 'POST' }); },
|
|
121
|
+
};
|
|
122
|
+
}, [projectName, projectPath]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 5. useStore ──────────────────────────────────────────
|
|
126
|
+
// Stores at <project>/.forge/crafts/<name>/data/<file>.json via /api/crafts/<name>/_store
|
|
127
|
+
export function useStore<T = any>(file: string, defaultValue?: T): [T | null, (next: T) => Promise<void>, { loading: boolean; error: string | null; reload: () => void }] {
|
|
128
|
+
const { projectPath, craftName } = useCraftCtx();
|
|
129
|
+
const [value, setValue] = useState<T | null>(defaultValue ?? null);
|
|
130
|
+
const [loading, setLoading] = useState(true);
|
|
131
|
+
const [error, setError] = useState<string | null>(null);
|
|
132
|
+
const [tick, setTick] = useState(0);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
setLoading(true);
|
|
137
|
+
fetch(`/api/craft-system/storage?projectPath=${encodeURIComponent(projectPath)}&craft=${craftName}&file=${encodeURIComponent(file)}`)
|
|
138
|
+
.then(async r => r.ok ? r.json() : { value: null })
|
|
139
|
+
.then(j => { if (!cancelled) { setValue(j.value ?? defaultValue ?? null); setLoading(false); } })
|
|
140
|
+
.catch(e => { if (!cancelled) { setError(e?.message); setLoading(false); } });
|
|
141
|
+
return () => { cancelled = true; };
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
|
+
}, [projectPath, craftName, file, tick]);
|
|
144
|
+
|
|
145
|
+
const save = useCallback(async (next: T) => {
|
|
146
|
+
setValue(next);
|
|
147
|
+
await fetch(`/api/craft-system/storage?projectPath=${encodeURIComponent(projectPath)}&craft=${craftName}&file=${encodeURIComponent(file)}`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify({ value: next }),
|
|
151
|
+
});
|
|
152
|
+
}, [projectPath, craftName, file]);
|
|
153
|
+
|
|
154
|
+
return [value, save, { loading, error, reload: () => setTick(t => t + 1) }];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 6. useOpenAPI — load + parse an OpenAPI spec from the project ──
|
|
158
|
+
export interface OpenAPIData {
|
|
159
|
+
spec: any | null;
|
|
160
|
+
paths: string[];
|
|
161
|
+
schemas: Record<string, any>;
|
|
162
|
+
loading: boolean;
|
|
163
|
+
error: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function useOpenAPI(specPath: string): OpenAPIData {
|
|
167
|
+
const { projectPath } = useCraftCtx();
|
|
168
|
+
const [state, setState] = useState<OpenAPIData>({ spec: null, paths: [], schemas: {}, loading: true, error: null });
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
let cancelled = false;
|
|
171
|
+
setState(s => ({ ...s, loading: true }));
|
|
172
|
+
fetch(`/api/craft-system/helpers/openapi?projectPath=${encodeURIComponent(projectPath)}&path=${encodeURIComponent(specPath)}`)
|
|
173
|
+
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
|
|
174
|
+
.then(j => { if (!cancelled) setState({ spec: j.spec, paths: j.paths || [], schemas: j.schemas || {}, loading: false, error: null }); })
|
|
175
|
+
.catch(e => { if (!cancelled) setState(s => ({ ...s, loading: false, error: e.message })); });
|
|
176
|
+
return () => { cancelled = true; };
|
|
177
|
+
}, [projectPath, specPath]);
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 7. useFile — read a file from the project (optional polling) ──
|
|
182
|
+
export function useFile(path: string, opts: { watch?: number } = {}): { content: string | null; loading: boolean; error: string | null; reload: () => void } {
|
|
183
|
+
const { projectPath } = useCraftCtx();
|
|
184
|
+
const [content, setContent] = useState<string | null>(null);
|
|
185
|
+
const [loading, setLoading] = useState(true);
|
|
186
|
+
const [error, setError] = useState<string | null>(null);
|
|
187
|
+
const [tick, setTick] = useState(0);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
let cancelled = false;
|
|
190
|
+
setLoading(true);
|
|
191
|
+
fetch(`/api/craft-system/helpers/file?projectPath=${encodeURIComponent(projectPath)}&path=${encodeURIComponent(path)}`)
|
|
192
|
+
.then(r => r.ok ? r.text() : Promise.reject(new Error(`${r.status}`)))
|
|
193
|
+
.then(t => { if (!cancelled) { setContent(t); setLoading(false); } })
|
|
194
|
+
.catch(e => { if (!cancelled) { setError(e.message); setLoading(false); } });
|
|
195
|
+
return () => { cancelled = true; };
|
|
196
|
+
}, [projectPath, path, tick]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!opts.watch) return;
|
|
199
|
+
const id = setInterval(() => setTick(t => t + 1), opts.watch);
|
|
200
|
+
return () => clearInterval(id);
|
|
201
|
+
}, [opts.watch]);
|
|
202
|
+
return { content, loading, error, reload: () => setTick(t => t + 1) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 8. useShell — run a shell command in the project cwd ──
|
|
206
|
+
export function useShell(): (cmd: string, opts?: { timeout?: number }) => Promise<{ stdout: string; stderr: string; code: number }> {
|
|
207
|
+
const { projectPath } = useCraftCtx();
|
|
208
|
+
return useCallback(async (cmd, opts = {}) => {
|
|
209
|
+
const r = await fetch(`/api/craft-system/helpers/shell?projectPath=${encodeURIComponent(projectPath)}`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ cmd, timeout: opts.timeout }),
|
|
213
|
+
});
|
|
214
|
+
return r.json();
|
|
215
|
+
}, [projectPath]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 9. useGit — quick git status / log helpers ──
|
|
219
|
+
export interface GitInfo {
|
|
220
|
+
branch?: string;
|
|
221
|
+
changes: { status: string; path: string }[];
|
|
222
|
+
ahead: number;
|
|
223
|
+
behind: number;
|
|
224
|
+
log: { hash: string; message: string; author: string; date: string }[];
|
|
225
|
+
}
|
|
226
|
+
export function useGit(): { info: GitInfo | null; loading: boolean; reload: () => void } {
|
|
227
|
+
const { projectPath } = useCraftCtx();
|
|
228
|
+
const [info, setInfo] = useState<GitInfo | null>(null);
|
|
229
|
+
const [loading, setLoading] = useState(true);
|
|
230
|
+
const [tick, setTick] = useState(0);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
let cancelled = false;
|
|
233
|
+
setLoading(true);
|
|
234
|
+
fetch(`/api/git?dir=${encodeURIComponent(projectPath)}`)
|
|
235
|
+
.then(r => r.ok ? r.json() : null)
|
|
236
|
+
.then(j => { if (!cancelled) { setInfo(j); setLoading(false); } })
|
|
237
|
+
.catch(() => { if (!cancelled) setLoading(false); });
|
|
238
|
+
return () => { cancelled = true; };
|
|
239
|
+
}, [projectPath, tick]);
|
|
240
|
+
return { info, loading, reload: () => setTick(t => t + 1) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 10. useToast — quick notification ──
|
|
244
|
+
type ToastFn = (msg: string, kind?: 'info' | 'success' | 'error') => void;
|
|
245
|
+
let globalToast: ToastFn | null = null;
|
|
246
|
+
export function setGlobalToast(fn: ToastFn) { globalToast = fn; }
|
|
247
|
+
export function useToast(): ToastFn {
|
|
248
|
+
return useCallback((msg, kind = 'info') => {
|
|
249
|
+
if (globalToast) globalToast(msg, kind);
|
|
250
|
+
else console.log(`[toast:${kind}]`, msg);
|
|
251
|
+
}, []);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Bundle exports for the runtime shim
|
|
255
|
+
export function getSDK() {
|
|
256
|
+
return {
|
|
257
|
+
useProject, useForgeFetch, useInject, useTask, useStore,
|
|
258
|
+
useOpenAPI, useFile, useShell, useGit, useToast,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Server-side SDK — used by craft authors in their `server.ts` file.
|
|
2
|
+
|
|
3
|
+
import type { CraftServerDef, CraftRouteHandler } from '@/lib/crafts/types';
|
|
4
|
+
|
|
5
|
+
export function defineCraftServer(def: CraftServerDef): CraftServerDef {
|
|
6
|
+
// Identity wrapper for type checking + future validation hooks.
|
|
7
|
+
if (!def || typeof def !== 'object' || !def.routes) {
|
|
8
|
+
throw new Error('defineCraftServer: routes is required');
|
|
9
|
+
}
|
|
10
|
+
return def;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Re-export types so authors can `import type { ... } from '@forge/craft/server'`.
|
|
14
|
+
export type { CraftServerDef, CraftRouteHandler, ForgeServerApi, CraftRouteHandlerCtx } from '@/lib/crafts/types';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Discovers crafts in a project's .forge/crafts/ + builtins shipped with Forge.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import * as YAML from 'yaml';
|
|
6
|
+
import type { CraftDescriptor, CraftManifest } from './types';
|
|
7
|
+
|
|
8
|
+
const BUILTIN_DIR = resolve(process.cwd(), 'lib/builtin-crafts');
|
|
9
|
+
|
|
10
|
+
function readManifest(dir: string): CraftManifest | null {
|
|
11
|
+
const yml = join(dir, 'craft.yaml');
|
|
12
|
+
if (!existsSync(yml)) return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = YAML.parse(readFileSync(yml, 'utf8')) as CraftManifest;
|
|
15
|
+
if (!parsed?.name) return null;
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describe(dir: string, scope: 'builtin' | 'project'): CraftDescriptor | null {
|
|
23
|
+
const m = readManifest(dir);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const uiFile = m.ui?.tab || 'ui.tsx';
|
|
26
|
+
const serverFile = m.server?.entry || 'server.ts';
|
|
27
|
+
return {
|
|
28
|
+
...m,
|
|
29
|
+
__dir: dir,
|
|
30
|
+
__scope: scope,
|
|
31
|
+
hasUi: existsSync(join(dir, uiFile)),
|
|
32
|
+
hasServer: existsSync(join(dir, serverFile)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listChildren(dir: string): string[] {
|
|
37
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
38
|
+
return readdirSync(dir)
|
|
39
|
+
.filter(n => !n.startsWith('.') && !n.startsWith('_'))
|
|
40
|
+
.map(n => join(dir, n))
|
|
41
|
+
.filter(p => statSync(p).isDirectory());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listProjectCrafts(projectPath: string): CraftDescriptor[] {
|
|
45
|
+
const out: CraftDescriptor[] = [];
|
|
46
|
+
// Builtins first (open-source samples shipped with Forge)
|
|
47
|
+
for (const dir of listChildren(BUILTIN_DIR)) {
|
|
48
|
+
const d = describe(dir, 'builtin');
|
|
49
|
+
if (d) out.push(d);
|
|
50
|
+
}
|
|
51
|
+
// Project-local crafts override / extend builtins by name
|
|
52
|
+
const projDir = join(projectPath, '.forge', 'crafts');
|
|
53
|
+
for (const dir of listChildren(projDir)) {
|
|
54
|
+
const d = describe(dir, 'project');
|
|
55
|
+
if (!d) continue;
|
|
56
|
+
const idx = out.findIndex(x => x.name === d.name);
|
|
57
|
+
if (idx >= 0) out[idx] = d; else out.push(d);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCraft(projectPath: string, name: string): CraftDescriptor | null {
|
|
63
|
+
return listProjectCrafts(projectPath).find(c => c.name === name) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Best-effort condition evaluator for showWhen expressions. Tiny DSL only.
|
|
67
|
+
// hasFile("path/relative/to/project")
|
|
68
|
+
// always
|
|
69
|
+
export function shouldShow(craft: CraftDescriptor, projectPath: string): boolean {
|
|
70
|
+
// First gate: requirements (project-type compatibility). If any are declared,
|
|
71
|
+
// at least one matcher must match. This is the same gate the marketplace uses.
|
|
72
|
+
if (craft.requires) {
|
|
73
|
+
if (!matchesRequirements(craft.requires, projectPath)) return false;
|
|
74
|
+
}
|
|
75
|
+
// Second gate: explicit ui.showWhen expression
|
|
76
|
+
const expr = craft.ui?.showWhen;
|
|
77
|
+
if (!expr || expr.trim() === 'always') return true;
|
|
78
|
+
const m = expr.match(/^hasFile\(\s*["']([^"']+)["']\s*\)$/);
|
|
79
|
+
if (m) return existsSync(join(projectPath, m[1]));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Evaluate craft's requires field against a project path. Returns true when
|
|
84
|
+
// the project satisfies at least one of the declared requirements (OR logic).
|
|
85
|
+
// An empty requires object means "no constraint" → true.
|
|
86
|
+
export function matchesRequirements(req: NonNullable<CraftDescriptor['requires']>, projectPath: string): boolean {
|
|
87
|
+
const files = req.hasFile || [];
|
|
88
|
+
const globs = req.hasGlob || [];
|
|
89
|
+
if (files.length === 0 && globs.length === 0) return true;
|
|
90
|
+
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
if (existsSync(join(projectPath, f))) return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Cheap glob match via shell. Bounded; runs once per craft, not per file.
|
|
96
|
+
for (const g of globs) {
|
|
97
|
+
try {
|
|
98
|
+
const r = require('node:child_process').execSync(
|
|
99
|
+
`find "${projectPath}" -path "${projectPath}/node_modules" -prune -o -path "${projectPath}/.git" -prune -o -name '*' -print 2>/dev/null | head -200 | grep -q -E "${globToRegex(g)}"`,
|
|
100
|
+
{ timeout: 3000, stdio: 'pipe' }
|
|
101
|
+
);
|
|
102
|
+
if (r) return true;
|
|
103
|
+
} catch {
|
|
104
|
+
// grep -q returns 1 when no match, but execSync throws — ignore
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function globToRegex(glob: string): string {
|
|
111
|
+
// Tiny glob → regex: ** → .*, * → [^/]*, . escaped
|
|
112
|
+
return glob
|
|
113
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
114
|
+
.replace(/\*\*/g, '§§')
|
|
115
|
+
.replace(/\*/g, '[^/]*')
|
|
116
|
+
.replace(/§§/g, '.*');
|
|
117
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Craft marketplace — slim version of skills.ts.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the Forge skills marketplace pattern: a GitHub-hosted repo with
|
|
4
|
+
// `registry.json` at the root + per-craft folders containing the manifest
|
|
5
|
+
// and ui.tsx/server.ts. Default repo: aiwatching/forge-crafts. Override via
|
|
6
|
+
// settings.craftsRepoUrl.
|
|
7
|
+
//
|
|
8
|
+
// Install state for crafts is implicit (file-system): if
|
|
9
|
+
// <project>/.forge/crafts/<name>/ exists, it's installed. No DB rows needed.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
import * as YAML from 'yaml';
|
|
14
|
+
import { loadSettings } from '../settings';
|
|
15
|
+
import type { CraftManifest, CraftRequirements } from './types';
|
|
16
|
+
import { matchesRequirements, listProjectCrafts } from './loader';
|
|
17
|
+
|
|
18
|
+
export interface RegistryEntry {
|
|
19
|
+
name: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
version: string;
|
|
23
|
+
author?: string;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
requires?: CraftRequirements;
|
|
26
|
+
files: string[]; // relative paths inside the craft dir on the registry
|
|
27
|
+
sourceUrl?: string; // GitHub web URL for browsing
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RegistryItem extends RegistryEntry {
|
|
31
|
+
// Per-project install info (populated by listMarketplace for a given project)
|
|
32
|
+
installed: boolean;
|
|
33
|
+
installedVersion?: string;
|
|
34
|
+
hasUpdate: boolean;
|
|
35
|
+
compatible: boolean; // requires gate against the project
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getBaseUrl(): string {
|
|
39
|
+
const s = loadSettings();
|
|
40
|
+
return (s as any).craftsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-crafts/main';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compareVersions(a: string, b: string): number {
|
|
44
|
+
const pa = (a || '0.0.0').split('.').map(Number);
|
|
45
|
+
const pb = (b || '0.0.0').split('.').map(Number);
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
48
|
+
if (d !== 0) return d;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Registry fetch ─────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
let cachedRegistry: { fetchedAt: number; entries: RegistryEntry[] } | null = null;
|
|
56
|
+
// Short TTL — registry.json is tiny + GitHub Pages caches independently anyway.
|
|
57
|
+
// 5-minute cache used to leave users staring at stale data after their own
|
|
58
|
+
// publish landed; 30s is the right "barely noticeable but still saves repeat
|
|
59
|
+
// hits during a single UI session" balance.
|
|
60
|
+
const REGISTRY_TTL_MS = 30_000;
|
|
61
|
+
|
|
62
|
+
export function invalidateRegistry() {
|
|
63
|
+
cachedRegistry = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function fetchRegistry(force = false): Promise<RegistryEntry[]> {
|
|
67
|
+
if (!force && cachedRegistry && Date.now() - cachedRegistry.fetchedAt < REGISTRY_TTL_MS) {
|
|
68
|
+
return cachedRegistry.entries;
|
|
69
|
+
}
|
|
70
|
+
const baseUrl = getBaseUrl();
|
|
71
|
+
try {
|
|
72
|
+
const ctrl = new AbortController();
|
|
73
|
+
const t = setTimeout(() => ctrl.abort(), 10000);
|
|
74
|
+
const res = await fetch(`${baseUrl}/registry.json?_t=${Date.now()}`, {
|
|
75
|
+
signal: ctrl.signal,
|
|
76
|
+
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
|
|
77
|
+
});
|
|
78
|
+
clearTimeout(t);
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
console.warn(`[craft-registry] fetch failed: ${res.status}`);
|
|
81
|
+
return cachedRegistry?.entries || [];
|
|
82
|
+
}
|
|
83
|
+
const data = await res.json() as { crafts?: RegistryEntry[] };
|
|
84
|
+
const entries = (data.crafts || []).filter(e => e.name && e.version && Array.isArray(e.files));
|
|
85
|
+
cachedRegistry = { fetchedAt: Date.now(), entries };
|
|
86
|
+
return entries;
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.warn('[craft-registry] fetch error', e);
|
|
89
|
+
return cachedRegistry?.entries || [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Per-project marketplace listing ────────────────────
|
|
94
|
+
|
|
95
|
+
export async function listMarketplace(projectPath: string): Promise<RegistryItem[]> {
|
|
96
|
+
const [entries, projectCrafts] = await Promise.all([
|
|
97
|
+
fetchRegistry(),
|
|
98
|
+
Promise.resolve(listProjectCrafts(projectPath)),
|
|
99
|
+
]);
|
|
100
|
+
const installedByName = new Map(projectCrafts.filter(c => c.__scope === 'project').map(c => [c.name, c]));
|
|
101
|
+
|
|
102
|
+
return entries.map(e => {
|
|
103
|
+
const inst = installedByName.get(e.name);
|
|
104
|
+
const installedVersion = inst?.version;
|
|
105
|
+
const hasUpdate = !!installedVersion && compareVersions(e.version, installedVersion) > 0;
|
|
106
|
+
const compatible = e.requires ? matchesRequirements(e.requires, projectPath) : true;
|
|
107
|
+
return {
|
|
108
|
+
...e,
|
|
109
|
+
installed: !!inst,
|
|
110
|
+
installedVersion,
|
|
111
|
+
hasUpdate,
|
|
112
|
+
compatible,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Install / uninstall ────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export async function installCraft(name: string, projectPath: string): Promise<{ ok: boolean; error?: string }> {
|
|
120
|
+
// Always pull fresh on install — user just clicked install, can't be stale.
|
|
121
|
+
const entries = await fetchRegistry(true);
|
|
122
|
+
const entry = entries.find(e => e.name === name);
|
|
123
|
+
if (!entry) return { ok: false, error: `craft "${name}" not in registry` };
|
|
124
|
+
|
|
125
|
+
const baseUrl = getBaseUrl();
|
|
126
|
+
const targetDir = join(projectPath, '.forge', 'crafts', name);
|
|
127
|
+
if (existsSync(targetDir)) {
|
|
128
|
+
return { ok: false, error: `craft "${name}" already installed at ${targetDir} (uninstall first to upgrade)` };
|
|
129
|
+
}
|
|
130
|
+
mkdirSync(targetDir, { recursive: true });
|
|
131
|
+
mkdirSync(join(targetDir, 'data'), { recursive: true });
|
|
132
|
+
|
|
133
|
+
for (const rel of entry.files) {
|
|
134
|
+
try {
|
|
135
|
+
const url = `${baseUrl}/${name}/${rel}?_t=${Date.now()}`;
|
|
136
|
+
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
|
137
|
+
if (!res.ok) throw new Error(`${url}: ${res.status}`);
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
const dest = join(targetDir, rel);
|
|
140
|
+
mkdirSync(join(dest, '..'), { recursive: true });
|
|
141
|
+
writeFileSync(dest, text, 'utf8');
|
|
142
|
+
} catch (e: any) {
|
|
143
|
+
// Roll back partial install
|
|
144
|
+
try { rmSync(targetDir, { recursive: true, force: true }); } catch {}
|
|
145
|
+
return { ok: false, error: `download failed: ${e?.message || e}` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function uninstallCraft(name: string, projectPath: string): { ok: boolean; error?: string } {
|
|
152
|
+
const targetDir = join(projectPath, '.forge', 'crafts', name);
|
|
153
|
+
if (!existsSync(targetDir)) return { ok: false, error: 'not installed' };
|
|
154
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
155
|
+
return { ok: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// In-place update — preserves <project>/.forge/crafts/<name>/data/ (which holds
|
|
159
|
+
// useStore JSON) and overwrites everything else. Atomic: downloads first, then
|
|
160
|
+
// writes; if any download fails the existing install is untouched.
|
|
161
|
+
export async function updateCraft(name: string, projectPath: string): Promise<{ ok: boolean; error?: string; from?: string; to?: string }> {
|
|
162
|
+
const targetDir = join(projectPath, '.forge', 'crafts', name);
|
|
163
|
+
if (!existsSync(targetDir)) return { ok: false, error: 'not installed' };
|
|
164
|
+
|
|
165
|
+
const entries = await fetchRegistry(true);
|
|
166
|
+
const entry = entries.find(e => e.name === name);
|
|
167
|
+
if (!entry) return { ok: false, error: `craft "${name}" not in registry` };
|
|
168
|
+
|
|
169
|
+
// Read current version from local craft.yaml for the response
|
|
170
|
+
let fromVersion: string | undefined;
|
|
171
|
+
try {
|
|
172
|
+
const m = YAML.parse(readFileSync(join(targetDir, 'craft.yaml'), 'utf8')) as any;
|
|
173
|
+
fromVersion = m?.version;
|
|
174
|
+
} catch {}
|
|
175
|
+
|
|
176
|
+
// Download everything into memory first
|
|
177
|
+
const baseUrl = getBaseUrl();
|
|
178
|
+
const downloaded: { rel: string; content: string }[] = [];
|
|
179
|
+
for (const rel of entry.files) {
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch(`${baseUrl}/${name}/${rel}?_t=${Date.now()}`, { headers: { 'Cache-Control': 'no-cache' } });
|
|
182
|
+
if (!res.ok) throw new Error(`${rel}: HTTP ${res.status}`);
|
|
183
|
+
downloaded.push({ rel, content: await res.text() });
|
|
184
|
+
} catch (e: any) {
|
|
185
|
+
return { ok: false, error: `download failed for ${rel}: ${e?.message || e}` };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// All downloads succeeded — write them. data/ stays intact.
|
|
190
|
+
const fs = require('node:fs');
|
|
191
|
+
for (const d of downloaded) {
|
|
192
|
+
const dest = join(targetDir, d.rel);
|
|
193
|
+
mkdirSync(dest.split('/').slice(0, -1).join('/'), { recursive: true });
|
|
194
|
+
fs.writeFileSync(dest, d.content, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, from: fromVersion, to: entry.version };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Returns just the names of installed crafts that have updates available.
|
|
200
|
+
// Used by the dropdown badge — cheap, no per-craft enrichment beyond version
|
|
201
|
+
// compare.
|
|
202
|
+
export async function listAvailableUpdates(projectPath: string): Promise<{ name: string; from?: string; to: string }[]> {
|
|
203
|
+
const items = await listMarketplace(projectPath);
|
|
204
|
+
return items
|
|
205
|
+
.filter(it => it.installed && it.hasUpdate)
|
|
206
|
+
.map(it => ({ name: it.name, from: it.installedVersion, to: it.version }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── File scanner ───────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const EXCLUDE_DIRS = new Set(['data', '.git', 'node_modules', '.next', 'dist', 'build', '.cache']);
|
|
212
|
+
const EXCLUDE_FILES = new Set(['prompt.md', '.DS_Store']);
|
|
213
|
+
const INCLUDE_EXT = new Set(['.yaml', '.yml', '.tsx', '.ts', '.jsx', '.js', '.md', '.json', '.css']);
|
|
214
|
+
const MAX_FILE_BYTES = 1024 * 1024; // 1 MB per file
|
|
215
|
+
|
|
216
|
+
function collectCraftFiles(dir: string): { path: string; content: string }[] {
|
|
217
|
+
const out: { path: string; content: string }[] = [];
|
|
218
|
+
const walk = (d: string) => {
|
|
219
|
+
for (const name of readdirSync(d)) {
|
|
220
|
+
if (name.startsWith('.') && name !== '.gitignore') continue;
|
|
221
|
+
if (EXCLUDE_FILES.has(name)) continue;
|
|
222
|
+
const full = join(d, name);
|
|
223
|
+
const st = statSync(full);
|
|
224
|
+
if (st.isDirectory()) {
|
|
225
|
+
if (EXCLUDE_DIRS.has(name)) continue;
|
|
226
|
+
walk(full);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!st.isFile()) continue;
|
|
230
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
231
|
+
const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
|
|
232
|
+
if (ext && !INCLUDE_EXT.has(ext)) continue;
|
|
233
|
+
out.push({ path: relative(dir, full), content: readFileSync(full, 'utf8') });
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
walk(dir);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Publish helper ─────────────────────────────────────
|
|
241
|
+
// MVP: returns a registry-entry JSON snippet + a tar of the craft dir for
|
|
242
|
+
// the user to attach to a PR on the registry repo. Real "press a button to
|
|
243
|
+
// publish" requires GitHub auth + write — out of scope for v1.
|
|
244
|
+
|
|
245
|
+
export function bundleCraftForPublish(projectPath: string, craftName: string): { entry: RegistryEntry; files: { path: string; content: string }[]; error?: string } {
|
|
246
|
+
const dir = join(projectPath, '.forge', 'crafts', craftName);
|
|
247
|
+
if (!existsSync(dir)) return { entry: null as any, files: [], error: 'craft not found' };
|
|
248
|
+
|
|
249
|
+
let manifest: CraftManifest;
|
|
250
|
+
try {
|
|
251
|
+
manifest = YAML.parse(readFileSync(join(dir, 'craft.yaml'), 'utf8'));
|
|
252
|
+
} catch {
|
|
253
|
+
return { entry: null as any, files: [], error: 'craft.yaml unreadable' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Recursive scan — pick up arbitrary multi-file crafts (e.g. server.ts split
|
|
257
|
+
// into _types.ts / _runner.ts / etc.) while excluding runtime data, history,
|
|
258
|
+
// and anything obviously not source.
|
|
259
|
+
const files = collectCraftFiles(dir);
|
|
260
|
+
|
|
261
|
+
const entry: RegistryEntry = {
|
|
262
|
+
name: manifest.name,
|
|
263
|
+
displayName: manifest.displayName || manifest.name,
|
|
264
|
+
description: manifest.description,
|
|
265
|
+
version: manifest.version || '0.1.0',
|
|
266
|
+
author: manifest.author,
|
|
267
|
+
tags: manifest.tags,
|
|
268
|
+
requires: manifest.requires,
|
|
269
|
+
files: files.map(f => f.path),
|
|
270
|
+
};
|
|
271
|
+
return { entry, files };
|
|
272
|
+
}
|