@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
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,13 +1,54 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.50
|
|
2
2
|
|
|
3
|
-
Released: 2026-04-
|
|
3
|
+
Released: 2026-04-28
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
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
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- fix(
|
|
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.
|
|
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
|
+
}
|