@aion0/forge 0.5.49 → 0.6.0

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 +4 -9
  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,8 @@
1
- # Forge v0.5.49
1
+ # Forge v0.6.0
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.50
6
6
 
7
- ### 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
11
7
 
12
-
13
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.48...v0.5.49
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.50...v0.6.0
@@ -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
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { bundleCraftForPublish } from '@/lib/crafts/registry';
3
+ import { loadSettings } from '@/lib/settings';
4
+
5
+ // POST /api/craft-system/publish body: { projectPath, name }
6
+ // Bundles the craft files + a registry-entry snippet ready to drop into the
7
+ // forge-crafts registry repo. Returns { entry, files, instructions }.
8
+ export async function POST(req: Request) {
9
+ const { projectPath, name } = await req.json() as { projectPath: string; name: string };
10
+ if (!projectPath || !name) return NextResponse.json({ error: 'projectPath + name required' }, { status: 400 });
11
+
12
+ const bundle = bundleCraftForPublish(projectPath, name);
13
+ if (bundle.error) return NextResponse.json({ error: bundle.error }, { status: 400 });
14
+
15
+ const repo = (loadSettings() as any).craftsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-crafts/main';
16
+ const repoMatch = repo.match(/github\.com\/([^/]+)\/([^/]+)|raw\.githubusercontent\.com\/([^/]+)\/([^/]+)/);
17
+ const ownerRepo = repoMatch ? `${repoMatch[1] || repoMatch[3]}/${(repoMatch[2] || repoMatch[4]).replace(/\.git$/, '')}` : 'aiwatching/forge-crafts';
18
+
19
+ // GitHub web-edit deep links — clicking "create file" without write access
20
+ // makes GitHub auto-fork the repo into the user's account, create the file
21
+ // in the fork, and prompt to open a PR. Zero local-clone required.
22
+ const ghBase = `https://github.com/${ownerRepo}`;
23
+ const editFileUrl = (path: string) => `${ghBase}/edit/main/${encodeURIComponent(path)}`;
24
+
25
+ // GitHub's /new/<branch>/<dir>?filename=<name>&value=<...> — dir + filename
26
+ // are independent so we split nested paths into prefix dir / leaf name.
27
+ const fileLinks = bundle.files.map(f => {
28
+ const lastSlash = f.path.lastIndexOf('/');
29
+ const subdir = lastSlash >= 0 ? f.path.slice(0, lastSlash) : '';
30
+ const filename = lastSlash >= 0 ? f.path.slice(lastSlash + 1) : f.path;
31
+ const dirPath = subdir ? `${bundle.entry.name}/${subdir}` : bundle.entry.name;
32
+ const githubUrl = `${ghBase}/new/main/${dirPath.split('/').map(encodeURIComponent).join('/')}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(f.content)}`;
33
+ return { path: f.path, githubUrl };
34
+ });
35
+
36
+ return NextResponse.json({
37
+ entry: bundle.entry,
38
+ files: bundle.files,
39
+ fileLinks,
40
+ repo: { owner: ownerRepo.split('/')[0], name: ownerRepo.split('/')[1], url: ghBase },
41
+ registryEditUrl: editFileUrl('registry.json'),
42
+ instructions: [
43
+ `All publishes go through a pull request — direct pushes to main are not accepted, even from maintainers.`,
44
+ `1. Click each file's "Open in GitHub" button. GitHub auto-forks ${ownerRepo} into your account (if you don't have write access) and creates the file there.`,
45
+ `2. After all files are created, open registry.json and append the JSON entry from the "registry.json entry" tab.`,
46
+ `3. In each commit dialog, pick "Create a new branch for this commit and start a pull request" — never commit to main directly.`,
47
+ `4. After the last commit, GitHub takes you straight to the PR. Submit it; once merged, the craft appears in every Forge user's marketplace.`,
48
+ ],
49
+ });
50
+ }
@@ -0,0 +1,16 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { fetchRegistry } from '@/lib/crafts/registry';
3
+
4
+ // GET /api/craft-system/registry?refresh=1
5
+ // Project-agnostic — returns the raw registry. Used by the global Marketplace
6
+ // browser where there's no single project context.
7
+ export async function GET(req: Request) {
8
+ const u = new URL(req.url);
9
+ const refresh = u.searchParams.get('refresh') === '1';
10
+ try {
11
+ const items = await fetchRegistry(refresh);
12
+ return NextResponse.json({ items });
13
+ } catch (e: any) {
14
+ return NextResponse.json({ items: [], error: e?.message || String(e) });
15
+ }
16
+ }