@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.
Files changed (52) hide show
  1. package/RELEASE_NOTES.md +48 -7
  2. package/app/api/craft-system/build/route.ts +78 -0
  3. package/app/api/craft-system/delete/route.ts +28 -0
  4. package/app/api/craft-system/helpers/file/route.ts +20 -0
  5. package/app/api/craft-system/helpers/openapi/route.ts +27 -0
  6. package/app/api/craft-system/helpers/shell/route.ts +26 -0
  7. package/app/api/craft-system/inject/route.ts +41 -0
  8. package/app/api/craft-system/kill-session/route.ts +19 -0
  9. package/app/api/craft-system/manifest/route.ts +71 -0
  10. package/app/api/craft-system/marketplace/install/route.ts +11 -0
  11. package/app/api/craft-system/marketplace/route.ts +18 -0
  12. package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
  13. package/app/api/craft-system/marketplace/update/route.ts +10 -0
  14. package/app/api/craft-system/marketplace/updates/route.ts +17 -0
  15. package/app/api/craft-system/publish/auto/route.ts +173 -0
  16. package/app/api/craft-system/publish/route.ts +50 -0
  17. package/app/api/craft-system/registry/route.ts +16 -0
  18. package/app/api/craft-system/runtime/react/route.ts +26 -0
  19. package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
  20. package/app/api/craft-system/runtime/sdk/route.ts +18 -0
  21. package/app/api/craft-system/scaffold/route.ts +164 -0
  22. package/app/api/craft-system/sessions/route.ts +45 -0
  23. package/app/api/craft-system/storage/route.ts +44 -0
  24. package/app/api/craft-system/tmux-sessions/route.ts +62 -0
  25. package/app/api/craft-system/ui/route.ts +30 -0
  26. package/app/api/crafts/[name]/[...route]/route.ts +48 -0
  27. package/app/api/crafts/route.ts +29 -0
  28. package/components/CraftBuilder.tsx +241 -0
  29. package/components/CraftManifestEditor.tsx +258 -0
  30. package/components/CraftMarketplaceModal.tsx +207 -0
  31. package/components/CraftPublishModal.tsx +285 -0
  32. package/components/CraftTabs.tsx +279 -0
  33. package/components/CraftTerminal.tsx +305 -0
  34. package/components/CraftTerminalPicker.tsx +179 -0
  35. package/components/CraftsDropdown.tsx +186 -0
  36. package/components/CraftsMarketplacePanel.tsx +194 -0
  37. package/components/ProjectDetail.tsx +105 -1
  38. package/components/SkillsPanel.tsx +12 -4
  39. package/components/TaskDetail.tsx +49 -1
  40. package/lib/craft-sdk/client.tsx +260 -0
  41. package/lib/craft-sdk/server.ts +14 -0
  42. package/lib/crafts/loader.ts +117 -0
  43. package/lib/crafts/registry.ts +272 -0
  44. package/lib/crafts/runtime.ts +208 -0
  45. package/lib/crafts/types.ts +92 -0
  46. package/lib/forge-skills/craft-builder.md +231 -0
  47. package/lib/help-docs/15-crafts.md +127 -0
  48. package/lib/help-docs/CLAUDE.md +2 -0
  49. package/lib/terminal-standalone.ts +1 -0
  50. package/next.config.ts +1 -1
  51. package/package.json +2 -1
  52. package/tsconfig.json +6 -0
package/RELEASE_NOTES.md CHANGED
@@ -1,13 +1,54 @@
1
- # Forge v0.5.49
1
+ # Forge v0.5.50
2
2
 
3
- Released: 2026-04-27
3
+ Released: 2026-04-28
4
4
 
5
- ## Changes since v0.5.48
5
+ ## Changes since v0.5.49
6
+
7
+ ### Features
8
+ - feat: API migration cockpit MVP for legacy → new parity testing
9
+
10
+ ### Refactoring
11
+ - refactor: remove Migration Cockpit from Forge core (replaced by craft)
12
+
13
+ ### Documentation
14
+ - feat: API migration cockpit MVP for legacy → new parity testing
6
15
 
7
16
  ### Other
8
- - perf(tasks): paginated log endpoint with per-entry truncation
9
- - perf(tasks): /api/tasks list ships only metadata, TaskDetail lazy-fetches body
10
- - fix(tasks): TaskDetail freezes browser on large session logs
17
+ - ui(crafts): move hide/close buttons into the terminal panel toolbar
18
+ - feat(crafts): multi-file support recursive bundle + nested file links
19
+ - fix(crafts): kill stale-registry display after publish + auto-bust on mutations
20
+ - fix(crafts): widen manifest editor 640px → 900px
21
+ - feat(crafts): in-Forge manifest editor (bump version etc. without leaving the UI)
22
+ - feat(crafts): in-place update + reminder badge in dropdown
23
+ - refactor(crafts): drop redundant outer + Craft button
24
+ - feat(crafts): add Crafts category to global Marketplace panel
25
+ - feat(crafts): one-click publish PR via gh CLI
26
+ - fix(crafts): publish flow always uses PR, not direct push
27
+ - feat(crafts): publish via GitHub auto-fork (no write access required)
28
+ - feat(crafts): marketplace + project-type requires + publish flow
29
+ - refactor(crafts): drop file-counter sample, collapse craft tabs into dropdown
30
+ - fix(crafts): keep craft tabs (incl. terminal panel) mounted across tab switches
31
+ - fix(crafts): list sessions scoped to the craft's cwd, not the project root
32
+ - fix(crafts): resume sessionId actually applied — closure staleness
33
+ - fix(crafts): always show picker on open + hide vs close split
34
+ - fix(crafts): picker re-creates tmux session with chosen --resume
35
+ - fix(crafts): keep craft tmux sessions alive across tab switches + restarts
36
+ - feat(crafts): terminal closed by default + agent/session picker on first open
37
+ - feat(crafts): integrated terminal at bottom of every craft tab
38
+ - fix(crafts): agent picker reads agents[] from /api/agents response
39
+ - fix(tasks): truncate long task prompt in detail view, click to expand
40
+ - feat(crafts): name + agent picker + terminal session as default builder mode
41
+ - fix(crafts): bundle SDK inline + cache-bust transpile output
42
+ - fix(crafts): move system routes out of _ namespace + delete + better builder
43
+ - feat(crafts): project-scoped mini-apps with AI builder
44
+ - feat(migration): per-endpoint flag for known deviations
45
+ - feat(migration): smart prompt + lenient nullable + per-violation ignore
46
+ - feat(migration): editable diagnosis prompt before send
47
+ - feat(migration): inline request/response inspector for debugging
48
+ - feat(migration): default Fix actions to inject into bound terminal
49
+ - feat(migration): rich diagnosis context + connectivity banner + fix-task hand-off
50
+ - feat(migration): OpenAPI as primary source + shape diff mode
51
+ - fix(migration): parser missed 159 per-controller docs and dropped stubbed endpoints
11
52
 
12
53
 
13
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.48...v0.5.49
54
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.49...v0.5.50
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { createTask } from '@/lib/task-manager';
5
+ import { getCraft } from '@/lib/crafts/loader';
6
+
7
+ interface BuildRequest {
8
+ projectPath: string;
9
+ projectName?: string;
10
+ request: string; // user's natural-language description
11
+ craftName?: string; // for refine — existing craft to modify
12
+ }
13
+
14
+ function buildPrompt(req: BuildRequest, refining?: boolean, existing?: { manifest?: string; ui?: string; server?: string; promptHistory?: string }) {
15
+ const lines: string[] = [];
16
+ lines.push(refining
17
+ ? `# Refine Forge Craft \`${req.craftName}\``
18
+ : `# Build a new Forge Craft for project ${req.projectName || req.projectPath}`);
19
+ lines.push('');
20
+ lines.push('Use the **craft-builder** skill loaded in your environment. Follow its rules exactly.');
21
+ lines.push('');
22
+ lines.push('## User request');
23
+ lines.push('');
24
+ lines.push('```');
25
+ lines.push(req.request);
26
+ lines.push('```');
27
+ lines.push('');
28
+ if (refining && existing) {
29
+ lines.push('## Existing craft files');
30
+ lines.push('');
31
+ if (existing.manifest) { lines.push('### craft.yaml'); lines.push('```yaml'); lines.push(existing.manifest); lines.push('```'); lines.push(''); }
32
+ if (existing.ui) { lines.push('### ui.tsx'); lines.push('```tsx'); lines.push(existing.ui); lines.push('```'); lines.push(''); }
33
+ if (existing.server) { lines.push('### server.ts'); lines.push('```ts'); lines.push(existing.server); lines.push('```'); lines.push(''); }
34
+ if (existing.promptHistory) { lines.push('### prompt.md (history)'); lines.push('```markdown'); lines.push(existing.promptHistory); lines.push('```'); lines.push(''); }
35
+ lines.push('Apply the user request as a **minimal change** to the existing craft. Preserve everything that works. Append the refine request to `prompt.md`.');
36
+ } else {
37
+ lines.push(`Write the craft into \`${req.projectPath}/.forge/crafts/<chosen-name>/\`.`);
38
+ lines.push('Pick a kebab-case name based on what the user wants. After writing files, the new tab will show up automatically.');
39
+ }
40
+ lines.push('');
41
+ lines.push('Reference: see the "Minimum viable example" in the `craft-builder` skill for file shape + SDK use.');
42
+ return lines.join('\n');
43
+ }
44
+
45
+ export async function POST(req: Request) {
46
+ const body = await req.json() as BuildRequest;
47
+ const { projectPath, projectName, request, craftName } = body;
48
+ if (!projectPath || !request) return NextResponse.json({ error: 'projectPath + request required' }, { status: 400 });
49
+
50
+ let refining = false;
51
+ let existing: any = undefined;
52
+ if (craftName) {
53
+ const c = getCraft(projectPath, craftName);
54
+ if (c && c.__scope === 'project') {
55
+ refining = true;
56
+ const dir = c.__dir;
57
+ const read = (f: string) => existsSync(join(dir, f)) ? readFileSync(join(dir, f), 'utf8') : undefined;
58
+ existing = {
59
+ manifest: read('craft.yaml'),
60
+ ui: read('ui.tsx'),
61
+ server: read('server.ts'),
62
+ promptHistory: read('prompt.md'),
63
+ };
64
+ }
65
+ }
66
+
67
+ // Ensure project crafts dir exists so the AI has somewhere to write.
68
+ const craftsDir = join(projectPath, '.forge', 'crafts');
69
+ if (!existsSync(craftsDir)) mkdirSync(craftsDir, { recursive: true });
70
+
71
+ const prompt = buildPrompt(body, refining, existing);
72
+ const task = createTask({
73
+ projectName: projectName || projectPath.split('/').filter(Boolean).pop() || 'project',
74
+ projectPath,
75
+ prompt,
76
+ });
77
+ return NextResponse.json({ ok: true, taskId: task.id, refining });
78
+ }
@@ -0,0 +1,28 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { rmSync, existsSync } from 'node:fs';
3
+ import { join, normalize } from 'node:path';
4
+ import { getCraft } from '@/lib/crafts/loader';
5
+
6
+ // DELETE /api/craft-system/delete?projectPath=...&name=...
7
+ // Deletes <project>/.forge/crafts/<name>/. Cannot delete builtin crafts.
8
+ export async function DELETE(req: Request) {
9
+ const u = new URL(req.url);
10
+ const projectPath = u.searchParams.get('projectPath');
11
+ const name = u.searchParams.get('name');
12
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
13
+
14
+ const c = getCraft(projectPath, name);
15
+ if (!c) return NextResponse.json({ error: 'craft not found' }, { status: 404 });
16
+ if (c.__scope === 'builtin') return NextResponse.json({ error: 'builtin crafts cannot be deleted (only project-local ones)' }, { status: 400 });
17
+
18
+ const target = join(projectPath, '.forge', 'crafts', name);
19
+ // Path-traversal guard
20
+ const normalized = normalize(target);
21
+ if (!normalized.startsWith(join(projectPath, '.forge', 'crafts'))) {
22
+ return NextResponse.json({ error: 'invalid path' }, { status: 400 });
23
+ }
24
+ if (!existsSync(normalized)) return NextResponse.json({ ok: true, alreadyMissing: true });
25
+
26
+ rmSync(normalized, { recursive: true, force: true });
27
+ return NextResponse.json({ ok: true });
28
+ }
@@ -0,0 +1,20 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, normalize } from 'node:path';
3
+
4
+ // GET /api/crafts/_helpers/file?projectPath=...&path=src/foo.ts
5
+ export async function GET(req: Request) {
6
+ const u = new URL(req.url);
7
+ const projectPath = u.searchParams.get('projectPath');
8
+ const path = u.searchParams.get('path');
9
+ if (!projectPath || !path) return new Response('missing args', { status: 400 });
10
+
11
+ const full = normalize(join(projectPath, path));
12
+ if (!full.startsWith(projectPath)) return new Response('invalid path', { status: 400 });
13
+ if (!existsSync(full)) return new Response('not found', { status: 404 });
14
+ if (statSync(full).isDirectory()) return new Response('is a directory', { status: 400 });
15
+
16
+ // Cap at 1MB to avoid blowing up the client
17
+ const buf = readFileSync(full);
18
+ if (buf.length > 1024 * 1024) return new Response(buf.subarray(0, 1024 * 1024).toString('utf8') + '\n…(truncated)', { headers: { 'Content-Type': 'text/plain' } });
19
+ return new Response(buf.toString('utf8'), { headers: { 'Content-Type': 'text/plain' } });
20
+ }
@@ -0,0 +1,27 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, normalize } from 'node:path';
4
+
5
+ // GET /api/crafts/_helpers/openapi?projectPath=...&path=docs/openapi.json
6
+ export async function GET(req: Request) {
7
+ const u = new URL(req.url);
8
+ const projectPath = u.searchParams.get('projectPath');
9
+ const path = u.searchParams.get('path');
10
+ if (!projectPath || !path) return NextResponse.json({ error: 'projectPath + path required' }, { status: 400 });
11
+
12
+ // Path traversal guard
13
+ const full = normalize(join(projectPath, path));
14
+ if (!full.startsWith(projectPath)) return NextResponse.json({ error: 'invalid path' }, { status: 400 });
15
+ if (!existsSync(full)) return NextResponse.json({ spec: null, paths: [], schemas: {} });
16
+
17
+ try {
18
+ const spec = JSON.parse(readFileSync(full, 'utf8'));
19
+ return NextResponse.json({
20
+ spec,
21
+ paths: Object.keys(spec.paths || {}),
22
+ schemas: spec.components?.schemas || {},
23
+ });
24
+ } catch (e: any) {
25
+ return NextResponse.json({ error: e?.message || String(e) }, { status: 500 });
26
+ }
27
+ }
@@ -0,0 +1,26 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { execSync } from 'node:child_process';
3
+
4
+ // POST /api/crafts/_helpers/shell?projectPath=... body: { cmd, timeout? }
5
+ export async function POST(req: Request) {
6
+ const u = new URL(req.url);
7
+ const projectPath = u.searchParams.get('projectPath');
8
+ if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
9
+ const { cmd, timeout } = await req.json() as { cmd: string; timeout?: number };
10
+ if (!cmd) return NextResponse.json({ error: 'cmd required' }, { status: 400 });
11
+ try {
12
+ const stdout = execSync(cmd, {
13
+ cwd: projectPath,
14
+ timeout: timeout ?? 30000,
15
+ encoding: 'utf8',
16
+ maxBuffer: 10 * 1024 * 1024,
17
+ }).toString();
18
+ return NextResponse.json({ stdout, stderr: '', code: 0 });
19
+ } catch (e: any) {
20
+ return NextResponse.json({
21
+ stdout: (e?.stdout || '').toString(),
22
+ stderr: (e?.stderr || e?.message || '').toString(),
23
+ code: e?.status ?? 1,
24
+ });
25
+ }
26
+ }
@@ -0,0 +1,41 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { execSync } from 'node:child_process';
3
+ import { tmpdir } from 'node:os';
4
+ import { writeFileSync, unlinkSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ // POST /api/craft-system/inject body: { projectPath, text, sessionName? }
8
+ // Pastes text into a tmux session and presses Enter. Used by the SDK
9
+ // useInject() hook so crafts can hand prompts to the project's bound terminal.
10
+
11
+ function findBoundSession(projectPath: string): string | null {
12
+ try {
13
+ const sessions = execSync(`tmux list-sessions -F '#{session_name}'`, { encoding: 'utf8', timeout: 2000 })
14
+ .trim().split('\n').filter(Boolean).filter(n => /^mw[a-z0-9]*-/.test(n));
15
+ for (const s of sessions) {
16
+ try {
17
+ const cwd = execSync(`tmux display-message -p -t '${s}' '#{pane_current_path}'`, { encoding: 'utf8', timeout: 2000 }).trim();
18
+ if (cwd === projectPath || cwd.startsWith(projectPath + '/')) return s;
19
+ } catch {}
20
+ }
21
+ } catch {}
22
+ return null;
23
+ }
24
+
25
+ export async function POST(req: Request) {
26
+ const { projectPath, text, sessionName } = await req.json() as { projectPath: string; text: string; sessionName?: string };
27
+ if (!projectPath || !text) return NextResponse.json({ error: 'projectPath + text required' }, { status: 400 });
28
+
29
+ const target = sessionName || findBoundSession(projectPath);
30
+ if (!target) return NextResponse.json({ ok: false, error: 'no bound session' }, { status: 404 });
31
+
32
+ try {
33
+ const buf = join(tmpdir(), `forge-craft-inject-${Date.now()}.txt`);
34
+ writeFileSync(buf, text);
35
+ execSync(`tmux load-buffer -t "${target}" "${buf}" && tmux paste-buffer -t "${target}" && sleep 0.2 && tmux send-keys -t "${target}" Enter`, { timeout: 5000 });
36
+ try { unlinkSync(buf); } catch {}
37
+ return NextResponse.json({ ok: true, sessionName: target });
38
+ } catch (e: any) {
39
+ return NextResponse.json({ ok: false, error: e?.message || String(e) }, { status: 500 });
40
+ }
41
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { execSync } from 'node:child_process';
3
+
4
+ // POST /api/craft-system/kill-session body: { sessionName }
5
+ // Used when the user re-picks an agent/session for a craft and wants the
6
+ // existing tmux session torn down so CraftTerminal can recreate it with
7
+ // the new --resume flag.
8
+ export async function POST(req: Request) {
9
+ const { sessionName } = await req.json() as { sessionName?: string };
10
+ if (!sessionName) return NextResponse.json({ error: 'sessionName required' }, { status: 400 });
11
+ // Safety: only craft sessions can be killed via this route.
12
+ if (!sessionName.startsWith('mw-craft-')) {
13
+ return NextResponse.json({ error: 'refusing to kill non-craft session' }, { status: 400 });
14
+ }
15
+ try {
16
+ execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
17
+ } catch {}
18
+ return NextResponse.json({ ok: true });
19
+ }
@@ -0,0 +1,71 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import * as YAML from 'yaml';
5
+ import { getCraft } from '@/lib/crafts/loader';
6
+
7
+ // GET /api/craft-system/manifest?projectPath=...&name=...
8
+ // Returns the raw craft.yaml + parsed object so the editor can show both.
9
+ export async function GET(req: Request) {
10
+ const u = new URL(req.url);
11
+ const projectPath = u.searchParams.get('projectPath');
12
+ const name = u.searchParams.get('name');
13
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
14
+
15
+ const c = getCraft(projectPath, name);
16
+ if (!c || c.__scope !== 'project') return NextResponse.json({ error: 'craft not found in this project' }, { status: 404 });
17
+
18
+ const file = join(c.__dir, 'craft.yaml');
19
+ if (!existsSync(file)) return NextResponse.json({ error: 'craft.yaml missing' }, { status: 404 });
20
+
21
+ const raw = readFileSync(file, 'utf8');
22
+ let parsed: any = null;
23
+ try { parsed = YAML.parse(raw); } catch (e: any) {
24
+ return NextResponse.json({ raw, parsed: null, parseError: e?.message });
25
+ }
26
+ return NextResponse.json({ raw, parsed });
27
+ }
28
+
29
+ // PUT /api/craft-system/manifest body: { projectPath, name, raw } OR { projectPath, name, patch: {...} }
30
+ // `raw` overwrites the whole file. `patch` shallow-merges fields into the parsed manifest
31
+ // then re-serializes (preserves any unknown keys in the existing yaml).
32
+ export async function PUT(req: Request) {
33
+ const body = await req.json() as { projectPath: string; name: string; raw?: string; patch?: any };
34
+ const { projectPath, name, raw, patch } = body;
35
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
36
+ if (!raw && !patch) return NextResponse.json({ error: 'raw or patch required' }, { status: 400 });
37
+
38
+ const c = getCraft(projectPath, name);
39
+ if (!c || c.__scope !== 'project') return NextResponse.json({ error: 'craft not found in this project' }, { status: 404 });
40
+
41
+ const file = join(c.__dir, 'craft.yaml');
42
+
43
+ let nextRaw: string;
44
+ if (raw) {
45
+ // Validate the raw YAML still parses + has a name field
46
+ try {
47
+ const m = YAML.parse(raw);
48
+ if (!m || typeof m !== 'object' || m.name !== name) {
49
+ return NextResponse.json({ error: 'craft.yaml must keep the same name field' }, { status: 400 });
50
+ }
51
+ } catch (e: any) {
52
+ return NextResponse.json({ error: `YAML parse error: ${e?.message}` }, { status: 400 });
53
+ }
54
+ nextRaw = raw;
55
+ } else {
56
+ // Patch mode — read current, merge, re-serialize
57
+ const current = existsSync(file) ? (YAML.parse(readFileSync(file, 'utf8')) || {}) : {};
58
+ if (patch.name && patch.name !== name) {
59
+ return NextResponse.json({ error: 'cannot rename a craft via patch (delete + recreate instead)' }, { status: 400 });
60
+ }
61
+ const merged = { ...current, ...patch, name }; // name pinned
62
+ // Drop empty arrays/strings that user explicitly cleared so yaml stays clean
63
+ for (const k of Object.keys(merged)) {
64
+ if (merged[k] === '' || (Array.isArray(merged[k]) && merged[k].length === 0)) delete merged[k];
65
+ }
66
+ nextRaw = YAML.stringify(merged);
67
+ }
68
+
69
+ writeFileSync(file, nextRaw, 'utf8');
70
+ return NextResponse.json({ ok: true, raw: nextRaw });
71
+ }
@@ -0,0 +1,11 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { installCraft, invalidateRegistry } from '@/lib/crafts/registry';
3
+
4
+ // POST /api/craft-system/marketplace/install body: { projectPath, name }
5
+ export async function POST(req: Request) {
6
+ const { projectPath, name } = await req.json() as { projectPath: string; name: string };
7
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
8
+ invalidateRegistry(); // bust cache so the next listMarketplace sees fresh state
9
+ const r = await installCraft(name, projectPath);
10
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
11
+ }
@@ -0,0 +1,18 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { listMarketplace, fetchRegistry } from '@/lib/crafts/registry';
3
+
4
+ // GET /api/craft-system/marketplace?projectPath=...
5
+ // Returns the registry entries with per-project install + compatibility info.
6
+ export async function GET(req: Request) {
7
+ const u = new URL(req.url);
8
+ const projectPath = u.searchParams.get('projectPath');
9
+ const refresh = u.searchParams.get('refresh') === '1';
10
+ if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
11
+ if (refresh) await fetchRegistry(true);
12
+ try {
13
+ const items = await listMarketplace(projectPath);
14
+ return NextResponse.json({ items });
15
+ } catch (e: any) {
16
+ return NextResponse.json({ error: e?.message || String(e), items: [] }, { status: 500 });
17
+ }
18
+ }
@@ -0,0 +1,11 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { uninstallCraft, invalidateRegistry } from '@/lib/crafts/registry';
3
+
4
+ // POST /api/craft-system/marketplace/uninstall body: { projectPath, name }
5
+ export async function POST(req: Request) {
6
+ const { projectPath, name } = await req.json() as { projectPath: string; name: string };
7
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
8
+ invalidateRegistry();
9
+ const r = uninstallCraft(name, projectPath);
10
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
11
+ }
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { updateCraft } from '@/lib/crafts/registry';
3
+
4
+ // POST /api/craft-system/marketplace/update body: { projectPath, name }
5
+ export async function POST(req: Request) {
6
+ const { projectPath, name } = await req.json() as { projectPath: string; name: string };
7
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
8
+ const r = await updateCraft(name, projectPath);
9
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
10
+ }
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { listAvailableUpdates } from '@/lib/crafts/registry';
3
+
4
+ // GET /api/craft-system/marketplace/updates?projectPath=...
5
+ // Returns the (potentially empty) list of installed crafts with newer versions.
6
+ // Cheap — used by the Crafts dropdown badge.
7
+ export async function GET(req: Request) {
8
+ const u = new URL(req.url);
9
+ const projectPath = u.searchParams.get('projectPath');
10
+ if (!projectPath) return NextResponse.json({ updates: [] });
11
+ try {
12
+ const updates = await listAvailableUpdates(projectPath);
13
+ return NextResponse.json({ updates });
14
+ } catch (e: any) {
15
+ return NextResponse.json({ updates: [], error: e?.message || String(e) });
16
+ }
17
+ }
@@ -0,0 +1,173 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { execSync } from 'node:child_process';
3
+ import { existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { bundleCraftForPublish } from '@/lib/crafts/registry';
7
+ import { loadSettings } from '@/lib/settings';
8
+
9
+ // One-click publish via the user's gh CLI: fork → clone → write files →
10
+ // append registry entry → push → open PR. Falls back to the manual flow
11
+ // (existing /api/craft-system/publish) when gh isn't available.
12
+
13
+ interface AutoPublishRequest {
14
+ projectPath: string;
15
+ name: string;
16
+ }
17
+
18
+ function exec(cmd: string, cwd?: string, timeout = 60000): string {
19
+ return execSync(cmd, { cwd, timeout, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024 });
20
+ }
21
+
22
+ export async function POST(req: Request) {
23
+ const { projectPath, name } = (await req.json()) as AutoPublishRequest;
24
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
25
+
26
+ // ── Pre-flight ────────────────────────────────────
27
+ let me: string;
28
+ try {
29
+ exec('gh auth status', undefined, 5000);
30
+ me = exec('gh api user --jq .login', undefined, 5000).trim();
31
+ } catch (e: any) {
32
+ return NextResponse.json({
33
+ error: 'gh CLI is not authenticated. Run `gh auth login` in a terminal, then retry.',
34
+ gh: false,
35
+ }, { status: 400 });
36
+ }
37
+
38
+ const bundle = bundleCraftForPublish(projectPath, name);
39
+ if (bundle.error || !bundle.entry) {
40
+ return NextResponse.json({ error: bundle.error || 'failed to bundle craft' }, { status: 400 });
41
+ }
42
+
43
+ const repo = (loadSettings() as any).craftsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-crafts/main';
44
+ const repoMatch = repo.match(/github\.com\/([^/]+)\/([^/]+)|raw\.githubusercontent\.com\/([^/]+)\/([^/]+)/);
45
+ if (!repoMatch) return NextResponse.json({ error: `cannot parse craftsRepoUrl: ${repo}` }, { status: 400 });
46
+ const ownerRepo = `${repoMatch[1] || repoMatch[3]}/${(repoMatch[2] || repoMatch[4]).replace(/\.git$/, '')}`;
47
+
48
+ const branch = `craft/${name}-${bundle.entry.version}-${Date.now().toString(36).slice(-4)}`;
49
+ const prTitle = `Add ${bundle.entry.displayName || bundle.entry.name} v${bundle.entry.version}`;
50
+ const tmp = mkdtempSync(join(tmpdir(), 'forge-craft-pr-'));
51
+ const repoDir = join(tmp, 'forge-crafts');
52
+
53
+ const log: string[] = [];
54
+ const step = (msg: string) => { log.push(msg); };
55
+
56
+ try {
57
+ // ── 1. Fork (idempotent — succeeds whether or not fork exists) ────
58
+ step(`Forking ${ownerRepo}…`);
59
+ try {
60
+ exec(`gh repo fork ${ownerRepo} --clone=false --default-branch-only`, tmp, 30000);
61
+ } catch {
62
+ // Fork may already exist — gh exits non-zero in that case but still works
63
+ }
64
+
65
+ // ── 2. Clone the fork ────────────────────────────────────────
66
+ step(`Cloning your fork ${me}/${ownerRepo.split('/')[1]}…`);
67
+ exec(`gh repo clone ${me}/${ownerRepo.split('/')[1]} forge-crafts`, tmp, 60000);
68
+
69
+ // Add upstream, sync from upstream main to avoid stale fork
70
+ exec(`git remote add upstream https://github.com/${ownerRepo}.git`, repoDir, 5000);
71
+ try {
72
+ exec(`git fetch upstream main`, repoDir, 30000);
73
+ exec(`git checkout main`, repoDir, 5000);
74
+ exec(`git merge upstream/main --ff-only`, repoDir, 10000);
75
+ exec(`git push origin main`, repoDir, 30000);
76
+ } catch (e: any) {
77
+ // Empty upstream repo or already in sync — both fine
78
+ step(`(sync skipped: ${e?.message?.split('\n')[0] || ''})`);
79
+ }
80
+
81
+ // ── 3. New branch ────────────────────────────────────────────
82
+ step(`Creating branch ${branch}…`);
83
+ exec(`git checkout -b ${branch}`, repoDir, 5000);
84
+
85
+ // ── 4. Write craft files ────────────────────────────────────
86
+ const craftDir = join(repoDir, name);
87
+ mkdirSync(craftDir, { recursive: true });
88
+ for (const f of bundle.files) {
89
+ const dest = join(craftDir, f.path);
90
+ mkdirSync(dirname(dest), { recursive: true });
91
+ writeFileSync(dest, f.content, 'utf8');
92
+ }
93
+ step(`Wrote ${bundle.files.length} files into ${name}/`);
94
+
95
+ // ── 5. Update registry.json ─────────────────────────────────
96
+ const regPath = join(repoDir, 'registry.json');
97
+ let registry: { version: number; crafts: any[] };
98
+ if (existsSync(regPath)) {
99
+ try {
100
+ registry = JSON.parse(readFileSync(regPath, 'utf8'));
101
+ if (!Array.isArray(registry.crafts)) registry.crafts = [];
102
+ } catch {
103
+ registry = { version: 1, crafts: [] };
104
+ }
105
+ } else {
106
+ registry = { version: 1, crafts: [] };
107
+ }
108
+
109
+ // Replace existing entry with same name (republish/update path)
110
+ registry.crafts = registry.crafts.filter((c: any) => c?.name !== bundle.entry.name);
111
+ registry.crafts.push(bundle.entry);
112
+ registry.crafts.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
113
+ writeFileSync(regPath, JSON.stringify(registry, null, 2) + '\n', 'utf8');
114
+ step(`Updated registry.json (${registry.crafts.length} crafts)`);
115
+
116
+ // ── 6. Commit + push ───────────────────────────────────────
117
+ exec(`git add .`, repoDir, 5000);
118
+ // Configure committer if not set globally — required for fresh containers
119
+ try { exec('git config user.email', repoDir, 2000); } catch {
120
+ exec(`git config user.email "${me}@users.noreply.github.com"`, repoDir, 2000);
121
+ exec(`git config user.name "${me}"`, repoDir, 2000);
122
+ }
123
+ exec(`git commit -m "${prTitle.replace(/"/g, '\\"')}"`, repoDir, 10000);
124
+ step(`Pushing branch…`);
125
+ exec(`git push -u origin ${branch}`, repoDir, 60000);
126
+
127
+ // ── 7. Open PR against upstream ────────────────────────────
128
+ step(`Opening PR…`);
129
+ const body = [
130
+ `Submitted via Forge's one-click craft publish.`,
131
+ ``,
132
+ `**Craft**: \`${bundle.entry.name}\` v${bundle.entry.version}`,
133
+ bundle.entry.description ? `**Description**: ${bundle.entry.description}` : '',
134
+ bundle.entry.tags?.length ? `**Tags**: ${bundle.entry.tags.join(', ')}` : '',
135
+ bundle.entry.author ? `**Author**: ${bundle.entry.author}` : '',
136
+ ``,
137
+ `Files:`,
138
+ ...bundle.files.map(f => `- \`${name}/${f.path}\``),
139
+ `- \`registry.json\` (entry added/updated)`,
140
+ ].filter(Boolean).join('\n');
141
+ const bodyFile = join(tmp, 'pr-body.md');
142
+ writeFileSync(bodyFile, body, 'utf8');
143
+ const prOut = exec(
144
+ `gh pr create --repo ${ownerRepo} --base main --head ${me}:${branch} --title "${prTitle.replace(/"/g, '\\"')}" --body-file "${bodyFile}"`,
145
+ repoDir, 30000
146
+ ).trim();
147
+
148
+ // gh prints the PR URL on the last line
149
+ const prUrl = prOut.split('\n').filter(l => l.startsWith('http')).pop() || prOut;
150
+
151
+ return NextResponse.json({ ok: true, prUrl, branch, log });
152
+ } catch (e: any) {
153
+ const stderr = (e?.stderr || '').toString();
154
+ return NextResponse.json({
155
+ error: e?.message?.split('\n')[0] || String(e),
156
+ stderr: stderr.slice(0, 2000),
157
+ log,
158
+ }, { status: 500 });
159
+ } finally {
160
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
161
+ }
162
+ }
163
+
164
+ // GET — quick probe so the UI can decide whether to show the one-click button
165
+ export async function GET() {
166
+ try {
167
+ exec('gh auth status', undefined, 5000);
168
+ const me = exec('gh api user --jq .login', undefined, 5000).trim();
169
+ return NextResponse.json({ available: true, user: me });
170
+ } catch {
171
+ return NextResponse.json({ available: false });
172
+ }
173
+ }