@aion0/forge 0.5.49 → 0.5.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +48 -7
- package/app/api/craft-system/build/route.ts +78 -0
- package/app/api/craft-system/delete/route.ts +28 -0
- package/app/api/craft-system/helpers/file/route.ts +20 -0
- package/app/api/craft-system/helpers/openapi/route.ts +27 -0
- package/app/api/craft-system/helpers/shell/route.ts +26 -0
- package/app/api/craft-system/inject/route.ts +41 -0
- package/app/api/craft-system/kill-session/route.ts +19 -0
- package/app/api/craft-system/manifest/route.ts +71 -0
- package/app/api/craft-system/marketplace/install/route.ts +11 -0
- package/app/api/craft-system/marketplace/route.ts +18 -0
- package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
- package/app/api/craft-system/marketplace/update/route.ts +10 -0
- package/app/api/craft-system/marketplace/updates/route.ts +17 -0
- package/app/api/craft-system/publish/auto/route.ts +173 -0
- package/app/api/craft-system/publish/route.ts +50 -0
- package/app/api/craft-system/registry/route.ts +16 -0
- package/app/api/craft-system/runtime/react/route.ts +26 -0
- package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
- package/app/api/craft-system/runtime/sdk/route.ts +18 -0
- package/app/api/craft-system/scaffold/route.ts +164 -0
- package/app/api/craft-system/sessions/route.ts +45 -0
- package/app/api/craft-system/storage/route.ts +44 -0
- package/app/api/craft-system/tmux-sessions/route.ts +62 -0
- package/app/api/craft-system/ui/route.ts +30 -0
- package/app/api/crafts/[name]/[...route]/route.ts +48 -0
- package/app/api/crafts/route.ts +29 -0
- package/components/CraftBuilder.tsx +241 -0
- package/components/CraftManifestEditor.tsx +258 -0
- package/components/CraftMarketplaceModal.tsx +207 -0
- package/components/CraftPublishModal.tsx +285 -0
- package/components/CraftTabs.tsx +279 -0
- package/components/CraftTerminal.tsx +305 -0
- package/components/CraftTerminalPicker.tsx +179 -0
- package/components/CraftsDropdown.tsx +186 -0
- package/components/CraftsMarketplacePanel.tsx +194 -0
- package/components/ProjectDetail.tsx +105 -1
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +49 -1
- package/lib/craft-sdk/client.tsx +260 -0
- package/lib/craft-sdk/server.ts +14 -0
- package/lib/crafts/loader.ts +117 -0
- package/lib/crafts/registry.ts +272 -0
- package/lib/crafts/runtime.ts +208 -0
- package/lib/crafts/types.ts +92 -0
- package/lib/forge-skills/craft-builder.md +231 -0
- package/lib/help-docs/15-crafts.md +127 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Re-export host's React instance to crafts (avoids two-React problem).
|
|
2
|
+
// CraftTabs sets `window.__forge_react` before any craft module is imported.
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const code = `
|
|
6
|
+
const R = (typeof window !== 'undefined' && window.__forge_react) || null;
|
|
7
|
+
if (!R) throw new Error('Forge React shim not initialized');
|
|
8
|
+
export default R;
|
|
9
|
+
export const useState = R.useState;
|
|
10
|
+
export const useEffect = R.useEffect;
|
|
11
|
+
export const useCallback = R.useCallback;
|
|
12
|
+
export const useMemo = R.useMemo;
|
|
13
|
+
export const useRef = R.useRef;
|
|
14
|
+
export const useContext = R.useContext;
|
|
15
|
+
export const useReducer = R.useReducer;
|
|
16
|
+
export const useLayoutEffect = R.useLayoutEffect;
|
|
17
|
+
export const createContext = R.createContext;
|
|
18
|
+
export const createElement = R.createElement;
|
|
19
|
+
export const Fragment = R.Fragment;
|
|
20
|
+
export const memo = R.memo;
|
|
21
|
+
export const lazy = R.lazy;
|
|
22
|
+
export const Suspense = R.Suspense;
|
|
23
|
+
export const forwardRef = R.forwardRef;
|
|
24
|
+
`;
|
|
25
|
+
return new Response(code, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } });
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// jsx-runtime shim — re-exports from host's React.
|
|
2
|
+
export async function GET() {
|
|
3
|
+
const code = `
|
|
4
|
+
const J = (typeof window !== 'undefined' && window.__forge_jsx) || null;
|
|
5
|
+
if (!J) throw new Error('Forge JSX runtime shim not initialized');
|
|
6
|
+
export const jsx = J.jsx;
|
|
7
|
+
export const jsxs = J.jsxs;
|
|
8
|
+
export const Fragment = J.Fragment;
|
|
9
|
+
`;
|
|
10
|
+
return new Response(code, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } });
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SDK shim — re-exports the SDK that the host page injected on window.__forge_sdk.
|
|
2
|
+
export async function GET() {
|
|
3
|
+
const code = `
|
|
4
|
+
const S = (typeof window !== 'undefined' && window.__forge_sdk) || null;
|
|
5
|
+
if (!S) throw new Error('Forge SDK shim not initialized');
|
|
6
|
+
export const useProject = S.useProject;
|
|
7
|
+
export const useForgeFetch = S.useForgeFetch;
|
|
8
|
+
export const useInject = S.useInject;
|
|
9
|
+
export const useTask = S.useTask;
|
|
10
|
+
export const useStore = S.useStore;
|
|
11
|
+
export const useOpenAPI = S.useOpenAPI;
|
|
12
|
+
export const useFile = S.useFile;
|
|
13
|
+
export const useShell = S.useShell;
|
|
14
|
+
export const useGit = S.useGit;
|
|
15
|
+
export const useToast = S.useToast;
|
|
16
|
+
`;
|
|
17
|
+
return new Response(code, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } });
|
|
18
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import * as YAML from 'yaml';
|
|
8
|
+
import { listAgents } from '@/lib/agents';
|
|
9
|
+
|
|
10
|
+
interface ScaffoldRequest {
|
|
11
|
+
projectPath: string;
|
|
12
|
+
projectName?: string;
|
|
13
|
+
name: string; // kebab-case craft id (also dir name)
|
|
14
|
+
displayName?: string;
|
|
15
|
+
description: string; // user's natural-language description
|
|
16
|
+
agentId?: string; // 'claude' | 'codex' | 'aider' | custom — defaults to first detected
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const NAME_RE = /^[a-z][a-z0-9-]{1,40}$/;
|
|
20
|
+
|
|
21
|
+
// Tmux name prefix for craft-builder sessions; lives alongside Forge's "mw-" prefix
|
|
22
|
+
const SESSION_PREFIX = 'mw-craft-';
|
|
23
|
+
|
|
24
|
+
function buildScaffoldPrompt(displayName: string, description: string, name: string): string {
|
|
25
|
+
return [
|
|
26
|
+
`# Build the Forge Craft \`${name}\``,
|
|
27
|
+
'',
|
|
28
|
+
`**Display name**: ${displayName}`,
|
|
29
|
+
'',
|
|
30
|
+
'You are working inside the craft directory directly (cwd is `<project>/.forge/crafts/' + name + '/`).',
|
|
31
|
+
'Use the `craft-builder` skill loaded in your environment for the full SDK reference and rules.',
|
|
32
|
+
'',
|
|
33
|
+
'## Files to write',
|
|
34
|
+
'',
|
|
35
|
+
'- `ui.tsx` — React component (default export). Import only from `@forge/craft`.',
|
|
36
|
+
'- `server.ts` — optional. Only if you need server-side work. Import from `@forge/craft/server`.',
|
|
37
|
+
'- Update `craft.yaml` if you need to change displayName / icon / showWhen.',
|
|
38
|
+
'- Update `README.md` with a 1-paragraph description.',
|
|
39
|
+
'- Append your work notes to `prompt.md` so future refines have context.',
|
|
40
|
+
'',
|
|
41
|
+
'## User\'s request',
|
|
42
|
+
'',
|
|
43
|
+
'```',
|
|
44
|
+
description,
|
|
45
|
+
'```',
|
|
46
|
+
'',
|
|
47
|
+
'## After you finish',
|
|
48
|
+
'',
|
|
49
|
+
'The craft will hot-reload in Forge as soon as the files exist. Tell the user to switch to the new tab in Forge to test it.',
|
|
50
|
+
'Refer to the "Minimum viable example" in the `craft-builder` skill for the file shape and SDK use.',
|
|
51
|
+
'',
|
|
52
|
+
'Begin by reading `prompt.md` (this file) and the craft-builder skill, then write `ui.tsx`. End with `[FORGE_DONE]`.',
|
|
53
|
+
].join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tmuxSessionName(projectPath: string, craftName: string): string {
|
|
57
|
+
const projHash = createHash('md5').update(projectPath).digest('hex').slice(0, 6);
|
|
58
|
+
return `${SESSION_PREFIX}${projHash}-${craftName}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function POST(req: Request) {
|
|
62
|
+
const body = await req.json() as ScaffoldRequest;
|
|
63
|
+
const { projectPath, projectName, name, displayName, description, agentId } = body;
|
|
64
|
+
|
|
65
|
+
if (!projectPath || !name || !description) {
|
|
66
|
+
return NextResponse.json({ error: 'projectPath, name, description required' }, { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
if (!NAME_RE.test(name)) {
|
|
69
|
+
return NextResponse.json({ error: 'name must be kebab-case: starts with letter, lowercase + digits + hyphens, 2-41 chars' }, { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const craftDir = join(projectPath, '.forge', 'crafts', name);
|
|
73
|
+
if (existsSync(craftDir)) {
|
|
74
|
+
return NextResponse.json({ error: `craft "${name}" already exists at ${craftDir} — pick a different name or delete it first` }, { status: 409 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resolve agent
|
|
78
|
+
const agents = listAgents();
|
|
79
|
+
const enabled = agents.filter(a => a.enabled);
|
|
80
|
+
if (enabled.length === 0) {
|
|
81
|
+
return NextResponse.json({ error: 'no agents detected; install Claude Code, Codex, or Aider first' }, { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
const agent = (agentId && enabled.find(a => a.id === agentId)) || enabled[0];
|
|
84
|
+
|
|
85
|
+
// ── Scaffold files ────────────────────────────────────
|
|
86
|
+
mkdirSync(craftDir, { recursive: true });
|
|
87
|
+
mkdirSync(join(craftDir, 'data'), { recursive: true });
|
|
88
|
+
|
|
89
|
+
const manifest = {
|
|
90
|
+
name,
|
|
91
|
+
displayName: displayName || `🛠 ${name}`,
|
|
92
|
+
description: description.split('\n')[0].slice(0, 120),
|
|
93
|
+
version: '0.1.0',
|
|
94
|
+
ui: { tab: 'ui.tsx' },
|
|
95
|
+
};
|
|
96
|
+
writeFileSync(join(craftDir, 'craft.yaml'), YAML.stringify(manifest));
|
|
97
|
+
|
|
98
|
+
// Stub UI so the tab appears immediately while the agent is still working
|
|
99
|
+
writeFileSync(join(craftDir, 'ui.tsx'), `import { useProject } from '@forge/craft';
|
|
100
|
+
|
|
101
|
+
export default function Tab() {
|
|
102
|
+
const { projectName } = useProject();
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex-1 flex items-center justify-center p-8 text-xs text-[var(--text-secondary)]">
|
|
105
|
+
<div className="text-center">
|
|
106
|
+
<div className="text-2xl mb-2">🛠</div>
|
|
107
|
+
<div>Craft <b className="text-[var(--text-primary)]">${name}</b> is being generated by ${agent.name || agent.id}.</div>
|
|
108
|
+
<div className="mt-2 opacity-60">Once the agent writes ui.tsx, this tab will hot-reload.</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
`);
|
|
114
|
+
|
|
115
|
+
const prompt = buildScaffoldPrompt(manifest.displayName, description, name);
|
|
116
|
+
writeFileSync(join(craftDir, 'prompt.md'), prompt);
|
|
117
|
+
|
|
118
|
+
writeFileSync(join(craftDir, 'README.md'), `# ${manifest.displayName}\n\n_Generation pending — agent ${agent.id} is working in tmux session ${tmuxSessionName(projectPath, name)}._\n`);
|
|
119
|
+
|
|
120
|
+
// ── Launch tmux session + agent ───────────────────────
|
|
121
|
+
const sessionName = tmuxSessionName(projectPath, name);
|
|
122
|
+
try {
|
|
123
|
+
// Kill any stale session with the same name (defensive)
|
|
124
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 2000 }); } catch {}
|
|
125
|
+
|
|
126
|
+
execSync(`tmux new-session -d -s "${sessionName}" -c "${craftDir}" -x 200 -y 50`, { timeout: 5000 });
|
|
127
|
+
|
|
128
|
+
// Build CLI command — start the agent
|
|
129
|
+
const cli = (agent as any).command || (agent as any).path || agent.id;
|
|
130
|
+
let launchCmd = '';
|
|
131
|
+
if (agent.id === 'claude') {
|
|
132
|
+
launchCmd = `${cli} --dangerously-skip-permissions`;
|
|
133
|
+
} else if (agent.id === 'codex') {
|
|
134
|
+
launchCmd = `${cli}`;
|
|
135
|
+
} else if (agent.id === 'aider') {
|
|
136
|
+
launchCmd = `${cli} --yes`;
|
|
137
|
+
} else {
|
|
138
|
+
launchCmd = cli;
|
|
139
|
+
}
|
|
140
|
+
execSync(`tmux send-keys -t "${sessionName}" '${launchCmd}' Enter`, { timeout: 3000 });
|
|
141
|
+
|
|
142
|
+
// Wait a moment for CLI to boot, then paste prompt + Enter
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
try {
|
|
145
|
+
const buf = join(tmpdir(), `forge-craft-prompt-${Date.now()}.txt`);
|
|
146
|
+
writeFileSync(buf, prompt);
|
|
147
|
+
execSync(`tmux load-buffer -t "${sessionName}" "${buf}" && tmux paste-buffer -t "${sessionName}" && sleep 0.3 && tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
|
|
148
|
+
try { require('node:fs').unlinkSync(buf); } catch {}
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.warn('[craft scaffold] failed to inject prompt', e);
|
|
151
|
+
}
|
|
152
|
+
}, 2500);
|
|
153
|
+
} catch (e: any) {
|
|
154
|
+
return NextResponse.json({ error: `failed to launch tmux session: ${e?.message || String(e)}` }, { status: 500 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return NextResponse.json({
|
|
158
|
+
ok: true,
|
|
159
|
+
name,
|
|
160
|
+
craftDir,
|
|
161
|
+
sessionName,
|
|
162
|
+
agent: { id: agent.id, displayName: agent.name || agent.id },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { projectPathToClaudeDir } from '@/lib/claude-sessions';
|
|
5
|
+
|
|
6
|
+
// GET /api/craft-system/sessions?cwd=<absolute path>
|
|
7
|
+
// Returns the Claude sessions stored under that cwd's project encoding.
|
|
8
|
+
// Used by CraftTerminalPicker so the user picks among sessions valid in
|
|
9
|
+
// the craft's cwd (not the project root's, which are scoped differently).
|
|
10
|
+
export async function GET(req: Request) {
|
|
11
|
+
const u = new URL(req.url);
|
|
12
|
+
const cwd = u.searchParams.get('cwd');
|
|
13
|
+
if (!cwd) return NextResponse.json([], { status: 400 });
|
|
14
|
+
|
|
15
|
+
const dir = projectPathToClaudeDir(cwd);
|
|
16
|
+
if (!existsSync(dir)) return NextResponse.json([]);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
20
|
+
const sessions = files.map(f => {
|
|
21
|
+
const sessionId = f.replace('.jsonl', '');
|
|
22
|
+
const fp = join(dir, f);
|
|
23
|
+
const stat = statSync(fp);
|
|
24
|
+
return { id: sessionId, modified: stat.mtime.toISOString(), size: stat.size };
|
|
25
|
+
}).sort((a, b) => b.modified.localeCompare(a.modified));
|
|
26
|
+
|
|
27
|
+
// Optional: enrich with sessions-index.json metadata if present
|
|
28
|
+
const idx = join(dir, 'sessions-index.json');
|
|
29
|
+
if (existsSync(idx)) {
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(readFileSync(idx, 'utf-8'));
|
|
32
|
+
const map = new Map<string, any>();
|
|
33
|
+
for (const e of (data.entries || [])) if (e.sessionId) map.set(e.sessionId, e);
|
|
34
|
+
for (const s of sessions) {
|
|
35
|
+
const m = map.get(s.id);
|
|
36
|
+
if (m?.firstPrompt) (s as any).firstPrompt = m.firstPrompt;
|
|
37
|
+
if (m?.summary) (s as any).summary = m.summary;
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
return NextResponse.json(sessions);
|
|
42
|
+
} catch {
|
|
43
|
+
return NextResponse.json([]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function storageDir(projectPath: string, craft: string): string {
|
|
6
|
+
return join(projectPath, '.forge', 'crafts', craft, 'data');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeFile(name: string): string | null {
|
|
10
|
+
// Block path traversal; allow alphanumeric + dashes + underscores + .json suffix
|
|
11
|
+
if (!/^[\w.\-]+$/.test(name)) return null;
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// GET /api/crafts/_storage?projectPath=...&craft=...&file=...
|
|
16
|
+
export async function GET(req: Request) {
|
|
17
|
+
const u = new URL(req.url);
|
|
18
|
+
const projectPath = u.searchParams.get('projectPath');
|
|
19
|
+
const craft = u.searchParams.get('craft');
|
|
20
|
+
const file = u.searchParams.get('file');
|
|
21
|
+
if (!projectPath || !craft || !file) return NextResponse.json({ error: 'missing args' }, { status: 400 });
|
|
22
|
+
const safe = safeFile(file);
|
|
23
|
+
if (!safe) return NextResponse.json({ error: 'invalid file name' }, { status: 400 });
|
|
24
|
+
const f = join(storageDir(projectPath, craft), safe);
|
|
25
|
+
if (!existsSync(f)) return NextResponse.json({ value: null });
|
|
26
|
+
try { return NextResponse.json({ value: JSON.parse(readFileSync(f, 'utf8')) }); }
|
|
27
|
+
catch { return NextResponse.json({ value: null }); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// POST /api/crafts/_storage?projectPath=...&craft=...&file=... body: { value }
|
|
31
|
+
export async function POST(req: Request) {
|
|
32
|
+
const u = new URL(req.url);
|
|
33
|
+
const projectPath = u.searchParams.get('projectPath');
|
|
34
|
+
const craft = u.searchParams.get('craft');
|
|
35
|
+
const file = u.searchParams.get('file');
|
|
36
|
+
if (!projectPath || !craft || !file) return NextResponse.json({ error: 'missing args' }, { status: 400 });
|
|
37
|
+
const safe = safeFile(file);
|
|
38
|
+
if (!safe) return NextResponse.json({ error: 'invalid file name' }, { status: 400 });
|
|
39
|
+
const dir = storageDir(projectPath, craft);
|
|
40
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
41
|
+
const { value } = await req.json();
|
|
42
|
+
writeFileSync(join(dir, safe), JSON.stringify(value, null, 2), 'utf8');
|
|
43
|
+
return NextResponse.json({ ok: true });
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
// GET /api/craft-system/tmux-sessions?projectPath=...
|
|
6
|
+
// Lists active mw-* tmux sessions and partitions by whether their
|
|
7
|
+
// pane_current_path falls under projectPath. Used by CraftTerminal to let
|
|
8
|
+
// the user pick a session to attach to.
|
|
9
|
+
|
|
10
|
+
interface SessionInfo { name: string; cwd: string; windows: number; attached: boolean; }
|
|
11
|
+
|
|
12
|
+
function listMwSessions(): { name: string; windows: number; attached: boolean }[] {
|
|
13
|
+
try {
|
|
14
|
+
const out = execSync(
|
|
15
|
+
`tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_attached}'`,
|
|
16
|
+
{ timeout: 2000, encoding: 'utf8' }
|
|
17
|
+
);
|
|
18
|
+
return out.trim().split('\n').filter(Boolean).map(line => {
|
|
19
|
+
const [name, w, att] = line.split('|');
|
|
20
|
+
return { name, windows: Number(w) || 1, attached: att === '1' };
|
|
21
|
+
}).filter(s => /^mw[a-z0-9]*-/.test(s.name));
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function paneCwd(name: string): string | null {
|
|
28
|
+
try {
|
|
29
|
+
const out = execSync(`tmux display-message -p -t '${name}' '#{pane_current_path}'`, {
|
|
30
|
+
timeout: 2000, encoding: 'utf8',
|
|
31
|
+
});
|
|
32
|
+
return out.trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalize(p: string): string {
|
|
39
|
+
try { return realpathSync(p); } catch { return p; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function GET(req: Request) {
|
|
43
|
+
const u = new URL(req.url);
|
|
44
|
+
const projectPath = u.searchParams.get('projectPath');
|
|
45
|
+
if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
|
|
46
|
+
|
|
47
|
+
const target = normalize(projectPath).replace(/\/+$/, '');
|
|
48
|
+
const all = listMwSessions();
|
|
49
|
+
const matches: SessionInfo[] = [];
|
|
50
|
+
const others: SessionInfo[] = [];
|
|
51
|
+
|
|
52
|
+
for (const s of all) {
|
|
53
|
+
const cwd = paneCwd(s.name);
|
|
54
|
+
if (!cwd) continue;
|
|
55
|
+
const real = normalize(cwd);
|
|
56
|
+
const info: SessionInfo = { name: s.name, cwd: real, windows: s.windows, attached: s.attached };
|
|
57
|
+
if (real === target || real.startsWith(target + '/')) matches.push(info);
|
|
58
|
+
else others.push(info);
|
|
59
|
+
}
|
|
60
|
+
matches.sort((a, b) => Number(b.attached) - Number(a.attached) || a.name.localeCompare(b.name));
|
|
61
|
+
return NextResponse.json({ matches, others });
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getCraft } from '@/lib/crafts/loader';
|
|
3
|
+
import { transpileUi } from '@/lib/crafts/runtime';
|
|
4
|
+
|
|
5
|
+
// GET /api/crafts/_ui?projectPath=...&name=... → returns transpiled JS module
|
|
6
|
+
export async function GET(req: Request) {
|
|
7
|
+
const url = new URL(req.url);
|
|
8
|
+
const projectPath = url.searchParams.get('projectPath');
|
|
9
|
+
const name = url.searchParams.get('name');
|
|
10
|
+
if (!projectPath || !name) return new NextResponse('projectPath + name required', { status: 400 });
|
|
11
|
+
|
|
12
|
+
const craft = getCraft(projectPath, name);
|
|
13
|
+
if (!craft) return new NextResponse('craft not found', { status: 404 });
|
|
14
|
+
if (!craft.hasUi) return new NextResponse('craft has no UI', { status: 404 });
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const code = await transpileUi(craft);
|
|
18
|
+
return new NextResponse(code, {
|
|
19
|
+
headers: {
|
|
20
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
21
|
+
'Cache-Control': 'no-cache',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
} catch (e: any) {
|
|
25
|
+
return new NextResponse(`/* craft transpile error */\nconsole.error(${JSON.stringify('Craft ' + name + ' transpile failed: ' + (e?.message || String(e)))});\nexport default function ErrorTab() { return null; }`, {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { 'Content-Type': 'text/javascript; charset=utf-8' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getCraft } from '@/lib/crafts/loader';
|
|
3
|
+
import { loadServer, findHandler, buildForgeApi } from '@/lib/crafts/runtime';
|
|
4
|
+
|
|
5
|
+
// /api/crafts/<name>/<...route> — dispatches to craft.server's matching handler.
|
|
6
|
+
// projectPath comes from query param (so the same URL works across multiple projects).
|
|
7
|
+
|
|
8
|
+
async function handle(req: Request, ctx: { params: Promise<{ name: string; route: string[] }> }, method: string) {
|
|
9
|
+
const { name, route } = await ctx.params;
|
|
10
|
+
const url = new URL(req.url);
|
|
11
|
+
const projectPath = url.searchParams.get('projectPath');
|
|
12
|
+
if (!projectPath) return NextResponse.json({ error: 'projectPath query param required' }, { status: 400 });
|
|
13
|
+
|
|
14
|
+
const craft = getCraft(projectPath, name);
|
|
15
|
+
if (!craft) return NextResponse.json({ error: `craft "${name}" not found` }, { status: 404 });
|
|
16
|
+
if (!craft.hasServer) return NextResponse.json({ error: `craft "${name}" has no server` }, { status: 404 });
|
|
17
|
+
|
|
18
|
+
const def = await loadServer(craft);
|
|
19
|
+
if (!def) return NextResponse.json({ error: 'failed to load craft server' }, { status: 500 });
|
|
20
|
+
|
|
21
|
+
const path = '/' + (route || []).join('/');
|
|
22
|
+
const m = findHandler(def, method, path);
|
|
23
|
+
if (!m) return NextResponse.json({ error: `no handler for ${method} ${path}` }, { status: 404 });
|
|
24
|
+
|
|
25
|
+
let body: any;
|
|
26
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
27
|
+
try { body = await req.json(); } catch { body = undefined; }
|
|
28
|
+
}
|
|
29
|
+
const query: Record<string, string> = {};
|
|
30
|
+
url.searchParams.forEach((v, k) => { if (k !== 'projectPath') query[k] = v; });
|
|
31
|
+
const headers: Record<string, string> = {};
|
|
32
|
+
req.headers.forEach((v, k) => { headers[k] = v; });
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const forge = buildForgeApi(craft, projectPath);
|
|
36
|
+
const result = await m.handler({ projectPath, query, params: m.params, body, headers, forge });
|
|
37
|
+
if (result instanceof Response) return result;
|
|
38
|
+
return NextResponse.json(result ?? {});
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
return NextResponse.json({ error: e?.message || String(e), stack: e?.stack }, { status: 500 });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function GET(req: Request, ctx: any) { return handle(req, ctx, 'GET'); }
|
|
45
|
+
export async function POST(req: Request, ctx: any) { return handle(req, ctx, 'POST'); }
|
|
46
|
+
export async function PUT(req: Request, ctx: any) { return handle(req, ctx, 'PUT'); }
|
|
47
|
+
export async function DELETE(req: Request, ctx: any) { return handle(req, ctx, 'DELETE'); }
|
|
48
|
+
export async function PATCH(req: Request, ctx: any) { return handle(req, ctx, 'PATCH'); }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { listProjectCrafts, shouldShow } from '@/lib/crafts/loader';
|
|
4
|
+
|
|
5
|
+
function tmuxSessionName(projectPath: string, craftName: string): string {
|
|
6
|
+
const projHash = createHash('md5').update(projectPath).digest('hex').slice(0, 6);
|
|
7
|
+
return `mw-craft-${projHash}-${craftName}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// GET /api/crafts?projectPath=...
|
|
11
|
+
export async function GET(req: Request) {
|
|
12
|
+
const url = new URL(req.url);
|
|
13
|
+
const projectPath = url.searchParams.get('projectPath');
|
|
14
|
+
if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
|
|
15
|
+
const all = listProjectCrafts(projectPath);
|
|
16
|
+
const visible = all.filter(c => shouldShow(c, projectPath));
|
|
17
|
+
return NextResponse.json({ crafts: visible.map(c => ({
|
|
18
|
+
name: c.name,
|
|
19
|
+
displayName: c.displayName || c.name,
|
|
20
|
+
icon: c.icon,
|
|
21
|
+
description: c.description,
|
|
22
|
+
version: c.version,
|
|
23
|
+
scope: c.__scope,
|
|
24
|
+
hasUi: c.hasUi,
|
|
25
|
+
hasServer: c.hasServer,
|
|
26
|
+
dir: c.__dir,
|
|
27
|
+
preferredSessionName: tmuxSessionName(projectPath, c.name),
|
|
28
|
+
})) });
|
|
29
|
+
}
|