@aion0/forge 0.5.49 → 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/RELEASE_NOTES.md +48 -7
- 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/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 +105 -1
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +49 -1
- 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 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/tsconfig.json +6 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Runtime: load + cache craft server modules; build the ForgeServerApi per request.
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import * as esbuild from 'esbuild';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { createTask } from '@/lib/task-manager';
|
|
10
|
+
import type { CraftDescriptor, CraftServerDef, CraftRouteHandler, ForgeServerApi } from './types';
|
|
11
|
+
|
|
12
|
+
// Module cache: dir → { mtimeMs, mod }
|
|
13
|
+
interface CachedMod { mtimeMs: number; def: CraftServerDef; }
|
|
14
|
+
const cache = new Map<string, CachedMod>();
|
|
15
|
+
|
|
16
|
+
// Function-wrapped dynamic import so Turbopack doesn't try to statically resolve the URL.
|
|
17
|
+
const dynamicImport = new Function('u', 'return import(u)') as (u: string) => Promise<any>;
|
|
18
|
+
|
|
19
|
+
async function transpileToFile(src: string, resolveDir: string): Promise<string> {
|
|
20
|
+
// Compile TS → JS, bundling the @forge/craft/server SDK inline (node_modules stays external).
|
|
21
|
+
// Cache key includes a salt so older transpile outputs are skipped after format changes.
|
|
22
|
+
const hash = require('node:crypto').createHash('md5').update('v2:' + src).digest('hex').slice(0, 16);
|
|
23
|
+
const out = join(tmpdir(), `forge-craft-${hash}.mjs`);
|
|
24
|
+
if (existsSync(out)) return out;
|
|
25
|
+
const sdkServerEntry = require('node:path').resolve(process.cwd(), 'lib/craft-sdk/server.ts');
|
|
26
|
+
const result = await esbuild.build({
|
|
27
|
+
stdin: { contents: src, loader: 'ts', resolveDir },
|
|
28
|
+
bundle: true,
|
|
29
|
+
format: 'esm',
|
|
30
|
+
platform: 'node',
|
|
31
|
+
target: 'node20',
|
|
32
|
+
packages: 'external',
|
|
33
|
+
alias: {
|
|
34
|
+
'@forge/craft/server': sdkServerEntry,
|
|
35
|
+
},
|
|
36
|
+
write: false,
|
|
37
|
+
});
|
|
38
|
+
writeFileSync(out, result.outputFiles[0].text, 'utf8');
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadServer(craft: CraftDescriptor): Promise<CraftServerDef | null> {
|
|
43
|
+
if (!craft.hasServer) return null;
|
|
44
|
+
const file = join(craft.__dir, craft.server?.entry || 'server.ts');
|
|
45
|
+
const stat = statSync(file);
|
|
46
|
+
const cached = cache.get(craft.__dir);
|
|
47
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.def;
|
|
48
|
+
|
|
49
|
+
const src = readFileSync(file, 'utf8');
|
|
50
|
+
const compiled = await transpileToFile(src, craft.__dir);
|
|
51
|
+
// Bust ESM cache by adding a query string (Node ESM caches by URL).
|
|
52
|
+
// Use Function() so Turbopack doesn't try to statically analyze the import.
|
|
53
|
+
const url = pathToFileURL(compiled).href + `?t=${stat.mtimeMs}`;
|
|
54
|
+
const mod = await dynamicImport(url);
|
|
55
|
+
const def: CraftServerDef = mod.default ?? mod.craft ?? mod;
|
|
56
|
+
if (!def || typeof def !== 'object' || !def.routes) {
|
|
57
|
+
throw new Error(`Craft "${craft.name}" server.ts must export default defineCraftServer({...})`);
|
|
58
|
+
}
|
|
59
|
+
cache.set(craft.__dir, { mtimeMs: stat.mtimeMs, def });
|
|
60
|
+
return def;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Match a route key like "GET /items/:id" against a request method + path.
|
|
64
|
+
function matchRoute(routeKey: string, method: string, path: string): Record<string, string> | null {
|
|
65
|
+
const [m, pat] = routeKey.split(/\s+/, 2);
|
|
66
|
+
if (m.toUpperCase() !== method.toUpperCase()) return null;
|
|
67
|
+
// Normalize: ensure leading slash on both
|
|
68
|
+
const cleanPath = path.startsWith('/') ? path : '/' + path;
|
|
69
|
+
const cleanPat = pat.startsWith('/') ? pat : '/' + pat;
|
|
70
|
+
const pSegs = cleanPath.split('/').filter(Boolean);
|
|
71
|
+
const tSegs = cleanPat.split('/').filter(Boolean);
|
|
72
|
+
if (pSegs.length !== tSegs.length) return null;
|
|
73
|
+
const params: Record<string, string> = {};
|
|
74
|
+
for (let i = 0; i < tSegs.length; i++) {
|
|
75
|
+
const t = tSegs[i];
|
|
76
|
+
const p = pSegs[i];
|
|
77
|
+
if (t.startsWith(':')) params[t.slice(1)] = decodeURIComponent(p);
|
|
78
|
+
else if (t !== p) return null;
|
|
79
|
+
}
|
|
80
|
+
return params;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function findHandler(def: CraftServerDef, method: string, path: string): { handler: CraftRouteHandler; params: Record<string, string> } | null {
|
|
84
|
+
for (const [key, handler] of Object.entries(def.routes)) {
|
|
85
|
+
const params = matchRoute(key, method, path);
|
|
86
|
+
if (params) return { handler, params };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Forge server-side API ────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export function buildForgeApi(craft: CraftDescriptor, projectPath: string, projectName?: string): ForgeServerApi {
|
|
94
|
+
const dataDir = join(craft.__dir, 'data');
|
|
95
|
+
// For builtin crafts, redirect storage to the project so writes don't go into the install
|
|
96
|
+
const writableDataDir = craft.__scope === 'builtin'
|
|
97
|
+
? join(projectPath, '.forge', 'crafts', craft.name, 'data')
|
|
98
|
+
: dataDir;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
project: { path: projectPath, name: projectName },
|
|
102
|
+
|
|
103
|
+
storage: {
|
|
104
|
+
read<T>(file: string): T | null {
|
|
105
|
+
const f = join(writableDataDir, file);
|
|
106
|
+
if (!existsSync(f)) return null;
|
|
107
|
+
try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
|
|
108
|
+
},
|
|
109
|
+
write(file: string, data: any): void {
|
|
110
|
+
if (!existsSync(writableDataDir)) mkdirSync(writableDataDir, { recursive: true });
|
|
111
|
+
writeFileSync(join(writableDataDir, file), JSON.stringify(data, null, 2), 'utf8');
|
|
112
|
+
},
|
|
113
|
+
listFiles(): string[] {
|
|
114
|
+
if (!existsSync(writableDataDir)) return [];
|
|
115
|
+
return readdirSync(writableDataDir);
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
exec(cmd, opts = {}) {
|
|
120
|
+
try {
|
|
121
|
+
const stdout = execSync(cmd, {
|
|
122
|
+
cwd: projectPath,
|
|
123
|
+
timeout: opts.timeout ?? 30000,
|
|
124
|
+
input: opts.input,
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
127
|
+
}).toString();
|
|
128
|
+
return { stdout, stderr: '', code: 0 };
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
return { stdout: (e?.stdout || '').toString(), stderr: (e?.stderr || e?.message || '').toString(), code: e?.status ?? 1 };
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
task(opts) {
|
|
135
|
+
const task = createTask({
|
|
136
|
+
projectName: projectName || projectPath.split('/').filter(Boolean).pop() || 'project',
|
|
137
|
+
projectPath,
|
|
138
|
+
prompt: opts.prompt,
|
|
139
|
+
agent: opts.agent,
|
|
140
|
+
});
|
|
141
|
+
return { id: task.id };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
inject(text, opts = {}) {
|
|
145
|
+
// Reuse migration session-resolver logic inline
|
|
146
|
+
try {
|
|
147
|
+
const sessionName = opts.sessionName || resolveBoundSession(projectPath);
|
|
148
|
+
if (!sessionName) return { ok: false };
|
|
149
|
+
const buf = join(tmpdir(), `forge-craft-inject-${Date.now()}.txt`);
|
|
150
|
+
writeFileSync(buf, text);
|
|
151
|
+
execSync(`tmux load-buffer -t "${sessionName}" "${buf}" && tmux paste-buffer -t "${sessionName}" && sleep 0.2 && tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
|
|
152
|
+
try { require('node:fs').unlinkSync(buf); } catch {}
|
|
153
|
+
return { ok: true, sessionName };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { ok: false };
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
openapi(specPath) {
|
|
160
|
+
try {
|
|
161
|
+
const file = join(projectPath, specPath);
|
|
162
|
+
if (!existsSync(file)) return null;
|
|
163
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
164
|
+
} catch { return null; }
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
log: (...args) => console.log(`[craft:${craft.name}]`, ...args),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveBoundSession(projectPath: string): string | null {
|
|
172
|
+
try {
|
|
173
|
+
const sessions = execSync(`tmux list-sessions -F '#{session_name}'`, { encoding: 'utf8', timeout: 2000 })
|
|
174
|
+
.trim().split('\n').filter(Boolean).filter(n => /^mw[a-z0-9]*-/.test(n));
|
|
175
|
+
for (const s of sessions) {
|
|
176
|
+
try {
|
|
177
|
+
const cwd = execSync(`tmux display-message -p -t '${s}' '#{pane_current_path}'`, { encoding: 'utf8', timeout: 2000 }).trim();
|
|
178
|
+
if (cwd === projectPath || cwd.startsWith(projectPath + '/')) return s;
|
|
179
|
+
} catch {}
|
|
180
|
+
}
|
|
181
|
+
} catch {}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── UI transpile (TSX → JS) for browser dynamic import ──
|
|
186
|
+
|
|
187
|
+
export async function transpileUi(craft: CraftDescriptor): Promise<string> {
|
|
188
|
+
const file = join(craft.__dir, craft.ui?.tab || 'ui.tsx');
|
|
189
|
+
if (!existsSync(file)) throw new Error('No UI file');
|
|
190
|
+
const src = readFileSync(file, 'utf8');
|
|
191
|
+
const result = await esbuild.build({
|
|
192
|
+
stdin: { contents: src, loader: 'tsx', resolveDir: craft.__dir },
|
|
193
|
+
bundle: true,
|
|
194
|
+
format: 'esm',
|
|
195
|
+
platform: 'browser',
|
|
196
|
+
target: 'es2022',
|
|
197
|
+
jsx: 'automatic',
|
|
198
|
+
write: false,
|
|
199
|
+
// Mark React + SDK as externals so they share the host page's instances
|
|
200
|
+
external: ['react', 'react/jsx-runtime', 'react-dom', '@forge/craft'],
|
|
201
|
+
});
|
|
202
|
+
// Rewrite SDK + react bare imports → absolute URLs that Forge serves.
|
|
203
|
+
let code = result.outputFiles[0].text;
|
|
204
|
+
code = code.replace(/from\s*["']react["']/g, 'from "/api/craft-system/runtime/react"');
|
|
205
|
+
code = code.replace(/from\s*["']react\/jsx-runtime["']/g, 'from "/api/craft-system/runtime/react-jsx"');
|
|
206
|
+
code = code.replace(/from\s*["']@forge\/craft["']/g, 'from "/api/craft-system/runtime/sdk"');
|
|
207
|
+
return code;
|
|
208
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Craft = a project-scoped mini-app: UI tab + optional API routes.
|
|
2
|
+
// Lives at <project>/.forge/crafts/<name>/ or in lib/builtin-crafts/<name>/.
|
|
3
|
+
|
|
4
|
+
export interface CraftRequirements {
|
|
5
|
+
hasFile?: string[]; // any of these paths must exist (relative to project)
|
|
6
|
+
hasGlob?: string[]; // any glob must match (e.g. "**/*.java")
|
|
7
|
+
// future: language, framework detection
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CraftManifest {
|
|
11
|
+
name: string; // unique slug, dir name
|
|
12
|
+
displayName?: string; // tab label (default = name)
|
|
13
|
+
icon?: string; // emoji shown in tab
|
|
14
|
+
description?: string;
|
|
15
|
+
version?: string;
|
|
16
|
+
author?: string; // marketplace metadata
|
|
17
|
+
tags?: string[]; // for marketplace browsing/filtering
|
|
18
|
+
requires?: CraftRequirements; // project-type compatibility gate
|
|
19
|
+
|
|
20
|
+
ui?: {
|
|
21
|
+
tab?: string; // path to ui.tsx (default 'ui.tsx')
|
|
22
|
+
showWhen?: string; // expression: hasFile("X") | always (v2)
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
server?: {
|
|
26
|
+
entry?: string; // path to server.ts (default 'server.ts')
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Source of truth — set by loader, not authored.
|
|
30
|
+
__dir?: string; // absolute dir
|
|
31
|
+
__scope?: 'builtin' | 'project';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CraftDescriptor extends CraftManifest {
|
|
35
|
+
__dir: string;
|
|
36
|
+
__scope: 'builtin' | 'project';
|
|
37
|
+
hasUi: boolean;
|
|
38
|
+
hasServer: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CraftRouteHandlerCtx {
|
|
42
|
+
projectPath: string;
|
|
43
|
+
projectName?: string;
|
|
44
|
+
query: Record<string, string>;
|
|
45
|
+
params: Record<string, string>;
|
|
46
|
+
body?: any;
|
|
47
|
+
headers: Record<string, string>;
|
|
48
|
+
forge: ForgeServerApi;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type CraftRouteHandler = (ctx: CraftRouteHandlerCtx) => Promise<any> | any;
|
|
52
|
+
|
|
53
|
+
export interface CraftServerDef {
|
|
54
|
+
routes: Record<string, CraftRouteHandler>; // key = "METHOD path", e.g. "GET /items"
|
|
55
|
+
onLoad?: (ctx: { projectPath: string; forge: ForgeServerApi }) => Promise<void> | void;
|
|
56
|
+
schedule?: string; // optional cron — v2
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Server-side helper bag passed into every handler. Keep small + stable.
|
|
60
|
+
export interface ForgeServerApi {
|
|
61
|
+
// Project context
|
|
62
|
+
project: {
|
|
63
|
+
path: string;
|
|
64
|
+
name?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Storage scoped to <project>/.forge/crafts/<name>/data/
|
|
68
|
+
storage: {
|
|
69
|
+
read<T = any>(file: string): T | null;
|
|
70
|
+
write(file: string, data: any): void;
|
|
71
|
+
listFiles(): string[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Run a shell command in the project cwd
|
|
75
|
+
exec(cmd: string, opts?: { timeout?: number; input?: string }): {
|
|
76
|
+
stdout: string;
|
|
77
|
+
stderr: string;
|
|
78
|
+
code: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Spawn a Forge background task in this project
|
|
82
|
+
task(opts: { prompt: string; agent?: string }): { id: string };
|
|
83
|
+
|
|
84
|
+
// Inject text into the project's bound tmux session (auto-resolves)
|
|
85
|
+
inject(text: string, opts?: { sessionName?: string }): { ok: boolean; sessionName?: string };
|
|
86
|
+
|
|
87
|
+
// Lazy access to OpenAPI loader (when project has one configured)
|
|
88
|
+
openapi(specPath: string): any | null;
|
|
89
|
+
|
|
90
|
+
// Structured logging — visible in Forge logs
|
|
91
|
+
log: (...args: any[]) => void;
|
|
92
|
+
}
|