@polderlabs/bizar 4.5.1 → 4.7.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.
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-O6ANdD4W.js → mobile-CWqPoGaT.js} +18 -19
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js → mobile-i4Uv9eW8.js} +1 -2
- package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js.map → mobile-i4Uv9eW8.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/bizar-dash/src/server/headroom.mjs +37 -35
- package/bizar-dash/src/server/logger.mjs +71 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +84 -37
- package/bizar-dash/src/server/metrics.mjs +193 -0
- package/bizar-dash/src/server/mods-loader.mjs +13 -5
- package/bizar-dash/src/server/providers-store.mjs +73 -1
- package/bizar-dash/src/server/routes/chat.mjs +41 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +7 -2
- package/bizar-dash/src/server/routes/memory.mjs +13 -4
- package/bizar-dash/src/server/routes/misc.mjs +2 -1
- package/bizar-dash/src/server/routes/overview.mjs +2 -1
- package/bizar-dash/src/server/schedules-runner.mjs +4 -4
- package/bizar-dash/src/server/server.mjs +101 -37
- package/bizar-dash/src/server/watcher.mjs +2 -2
- package/bizar-dash/src/web/App.tsx +40 -6
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
- package/bizar-dash/src/web/components/Toast.tsx +1 -1
- package/bizar-dash/src/web/components/Topbar.tsx +1 -1
- package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
- package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
- package/bizar-dash/src/web/lib/api.ts +12 -11
- package/bizar-dash/src/web/lib/i18n.ts +25 -0
- package/bizar-dash/src/web/locales/en.json +52 -0
- package/bizar-dash/src/web/main.tsx +5 -0
- package/bizar-dash/src/web/styles/chat.css +2 -0
- package/bizar-dash/src/web/styles/main.css +52 -23
- package/bizar-dash/src/web/styles/tasks.css +2 -0
- package/bizar-dash/src/web/views/Activity.tsx +24 -17
- package/bizar-dash/src/web/views/History.tsx +90 -75
- package/bizar-dash/src/web/views/Memory.tsx +4 -3
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
- package/bizar-dash/src/web/views/Overview.tsx +15 -10
- package/bizar-dash/src/web/views/Settings.tsx +9 -2
- package/bizar-dash/src/web/views/Skills.tsx +3 -2
- package/bizar-dash/src/web/views/Tasks.tsx +4 -3
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +8 -6
- package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
- package/bizar-dash/tests/components/Button.test.tsx +41 -0
- package/bizar-dash/tests/components/Card.test.tsx +42 -0
- package/bizar-dash/tests/components/Modal.test.tsx +104 -0
- package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
- package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
- package/bizar-dash/tests/components/Toast.test.tsx +108 -0
- package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
- package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
- package/bizar-dash/tests/lib/i18n.test.ts +46 -0
- package/bizar-dash/tests/lib/utils.test.ts +194 -0
- package/bizar-dash/tests/logger.test.mjs +207 -0
- package/bizar-dash/tests/metrics.test.mjs +183 -0
- package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
- package/bizar-dash/tests/setup.ts +7 -0
- package/bizar-dash/vitest.config.ts +13 -0
- package/cli/artifact-cli.mjs +605 -0
- package/cli/artifact-render.mjs +621 -0
- package/cli/artifact-server.mjs +847 -0
- package/cli/artifact.mjs +38 -2089
- package/cli/bg.mjs +5 -13
- package/cli/bin.mjs +170 -1348
- package/cli/commands/artifact.mjs +20 -0
- package/cli/commands/dash.mjs +160 -0
- package/cli/commands/headroom.mjs +204 -0
- package/cli/commands/install.mjs +169 -0
- package/cli/commands/memory.mjs +25 -0
- package/cli/commands/minimax.mjs +265 -0
- package/cli/commands/mod.mjs +185 -0
- package/cli/commands/service.mjs +65 -0
- package/cli/commands/usage.mjs +109 -0
- package/cli/commands/util.mjs +306 -0
- package/cli/doctor.mjs +6 -16
- package/cli/memory.mjs +65 -21
- package/cli/provision.mjs +10 -15
- package/cli/service-controller.mjs +1 -11
- package/cli/service.mjs +1 -11
- package/cli/utils.mjs +41 -1
- package/install.sh +1 -2
- package/package.json +8 -3
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +0 -1
- package/bizar-dash/dist/assets/main-usWhlPWa.js +0 -362
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +0 -1
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/artifact-server.mjs
|
|
3
|
+
*
|
|
4
|
+
* HTTP server and request routing for artifact viewing/editing.
|
|
5
|
+
* Extracted from artifact.mjs to separate server logic from CLI and rendering.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import {
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { join, resolve, dirname, basename, isAbsolute } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
getTemplate,
|
|
22
|
+
getTemplateNames,
|
|
23
|
+
listTemplates,
|
|
24
|
+
printTemplates,
|
|
25
|
+
substitute,
|
|
26
|
+
buildVars,
|
|
27
|
+
saveTemplate as saveTemplateToLibrary,
|
|
28
|
+
deleteTemplate as deleteLibraryTemplate,
|
|
29
|
+
} from './artifact-templates.mjs';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
CANVAS_SCHEMA_VERSION,
|
|
33
|
+
emptyCanvas,
|
|
34
|
+
readCanvasFile,
|
|
35
|
+
writeCanvasFile,
|
|
36
|
+
loadOrMigrateCanvas,
|
|
37
|
+
canvasToMarkdown,
|
|
38
|
+
makeElementId,
|
|
39
|
+
makeConnectionId,
|
|
40
|
+
makeCommentId,
|
|
41
|
+
makeReplyId,
|
|
42
|
+
readPlanMeta,
|
|
43
|
+
renderElementHTML,
|
|
44
|
+
renderConnectionHTML,
|
|
45
|
+
renderCommentPinHTML,
|
|
46
|
+
renderCommentThreadHTML,
|
|
47
|
+
renderReplyHTML,
|
|
48
|
+
escapeHtml,
|
|
49
|
+
formatDate,
|
|
50
|
+
isHtmxRequest,
|
|
51
|
+
decodeHtmxFormBody,
|
|
52
|
+
renderElementBody,
|
|
53
|
+
renderCommentLi,
|
|
54
|
+
renderCommentListHtml,
|
|
55
|
+
renderCommentCountHtml,
|
|
56
|
+
readRequestBody,
|
|
57
|
+
bumpLastEdited,
|
|
58
|
+
openBrowser,
|
|
59
|
+
replaceTemplate,
|
|
60
|
+
readTemplate,
|
|
61
|
+
atomicWriteText,
|
|
62
|
+
atomicWriteJson,
|
|
63
|
+
} from './artifact-render.mjs';
|
|
64
|
+
|
|
65
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const PROJECT_ROOT = resolve(__dirname, '..');
|
|
67
|
+
const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', 'artifact');
|
|
68
|
+
const PLANS_DIR = join(PROJECT_ROOT, 'artifacts');
|
|
69
|
+
const MAX_REQUEST_BODY_BYTES = 5 * 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
// ─── Stdin question helper ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function question(prompt) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
process.stdout.write(prompt);
|
|
76
|
+
process.stdin.once('data', (data) => resolve(data.toString()));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Signal wait helper ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function waitForSignal(closeFn) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const finish = async () => {
|
|
85
|
+
await closeFn();
|
|
86
|
+
process.off('SIGINT', finish);
|
|
87
|
+
process.off('SIGTERM', finish);
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
process.on('SIGINT', finish);
|
|
91
|
+
process.on('SIGTERM', finish);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Local HTTP server ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Starts a local HTTP server in the SAME process as the CLI.
|
|
99
|
+
* Tries port 4321 first, falls back to 4322, 4323, etc. (max 10 attempts).
|
|
100
|
+
* Returns { port, close } where close() stops the server.
|
|
101
|
+
*/
|
|
102
|
+
export async function startServer(slug, planDir, startPort = 4321) {
|
|
103
|
+
let port = startPort;
|
|
104
|
+
let maxAttempts = 10;
|
|
105
|
+
let server;
|
|
106
|
+
|
|
107
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
108
|
+
port = startPort + attempt;
|
|
109
|
+
try {
|
|
110
|
+
server = await new Promise((resolve, reject) => {
|
|
111
|
+
const srv = createServer((req, res) => {
|
|
112
|
+
// Fire-and-forget: handleRequest is async because PUT/POST bodies
|
|
113
|
+
// are streamed, but Node's HTTP server is happy to wait for res.end().
|
|
114
|
+
handleRequest(req, res, slug, planDir, port).catch((err) => {
|
|
115
|
+
console.error(`[${new Date().toISOString()}] ERROR: ${err.stack || err.message}`);
|
|
116
|
+
if (!res.headersSent) {
|
|
117
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
118
|
+
}
|
|
119
|
+
try { res.end('Server error: ' + (err.message || String(err))); } catch { /* already closed */ }
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
srv.on('error', reject);
|
|
123
|
+
srv.listen(port, '127.0.0.1', () => resolve(srv));
|
|
124
|
+
});
|
|
125
|
+
break;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err.code === 'EADDRINUSE' && attempt < maxAttempts - 1) {
|
|
128
|
+
continue; // try next port
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!server) throw new Error(`Could not find available port in range ${startPort}–${startPort + maxAttempts - 1}`);
|
|
135
|
+
|
|
136
|
+
const actualPort = server.address().port;
|
|
137
|
+
|
|
138
|
+
const close = () =>
|
|
139
|
+
new Promise((resolve) => {
|
|
140
|
+
server.close(() => resolve());
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { port: actualPort, close };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Request handler ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Handle incoming HTTP requests.
|
|
150
|
+
*/
|
|
151
|
+
async function handleRequest(req, res, slug, planDir, serverPort) {
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
// Use path-only URL parsing to avoid host-header injection
|
|
154
|
+
const requestUrl = typeof req.url === 'string' ? req.url : '/';
|
|
155
|
+
const pathname = requestUrl.split('?')[0].split('#')[0];
|
|
156
|
+
|
|
157
|
+
// Log request to stderr
|
|
158
|
+
console.error(`[${now}] ${req.method} ${pathname}`);
|
|
159
|
+
|
|
160
|
+
// CORS — allow from any origin (user may be on a different port)
|
|
161
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
162
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, OPTIONS');
|
|
163
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
164
|
+
|
|
165
|
+
if (req.method === 'OPTIONS') {
|
|
166
|
+
res.writeHead(204);
|
|
167
|
+
res.end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// ── Serve artifact.html ─────────────────────────────────────────────────
|
|
173
|
+
if (pathname === `/${slug}/` || pathname === `/${slug}` || pathname === '/') {
|
|
174
|
+
const htmlPath = join(planDir, 'artifact.html');
|
|
175
|
+
if (!existsSync(htmlPath)) {
|
|
176
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
177
|
+
res.end('artifact.html not found. Run `bizar artifact new ${slug}` first.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const html = readFileSync(htmlPath, 'utf-8');
|
|
181
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
182
|
+
res.end(html);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Self-hosted htmx — served from templates/artifact/htmx.min.js ──────
|
|
187
|
+
if (pathname === '/htmx.min.js' && req.method === 'GET') {
|
|
188
|
+
const htmxPath = join(TEMPLATES_DIR, 'htmx.min.js');
|
|
189
|
+
if (!existsSync(htmxPath)) {
|
|
190
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
191
|
+
res.end('htmx.min.js not found in templates/artifact/');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const buf = readFileSync(htmxPath);
|
|
195
|
+
res.writeHead(200, {
|
|
196
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
197
|
+
'Cache-Control': 'public, max-age=3600',
|
|
198
|
+
});
|
|
199
|
+
res.end(buf);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── RESTful, slug-scoped routes ──────────────────────────────────────────
|
|
204
|
+
if (pathname.startsWith('/api/') && pathname.split('/').length >= 4) {
|
|
205
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
206
|
+
const urlSlug = parts[1];
|
|
207
|
+
const resource = parts[2];
|
|
208
|
+
|
|
209
|
+
if (urlSlug !== slug) {
|
|
210
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
211
|
+
res.end(`Forbidden: this server is bound to artifact "${slug}"`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const subParts = parts.slice(2);
|
|
216
|
+
const subPath = subParts.join('/');
|
|
217
|
+
|
|
218
|
+
// ── v2 canvas endpoints ─────────────────────────────────────────────────
|
|
219
|
+
// GET /api/<slug>/canvas
|
|
220
|
+
if (subPath === 'canvas' && req.method === 'GET') {
|
|
221
|
+
const meta = readPlanMeta(planDir);
|
|
222
|
+
const title = (meta && meta.title) || slug;
|
|
223
|
+
const canvas = loadOrMigrateCanvas(planDir, title);
|
|
224
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
225
|
+
res.end(JSON.stringify(canvas));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// PUT /api/<slug>/canvas
|
|
230
|
+
if (subPath === 'canvas' && req.method === 'PUT') {
|
|
231
|
+
let parsed;
|
|
232
|
+
try {
|
|
233
|
+
parsed = await readRequestBody(req);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
236
|
+
res.end('Invalid JSON body: ' + (err && err.message ? err.message : String(err)));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
let next;
|
|
240
|
+
try {
|
|
241
|
+
if (parsed.kind === 'raw' && typeof parsed.data === 'string') {
|
|
242
|
+
next = JSON.parse(parsed.data);
|
|
243
|
+
} else if (parsed.kind === 'form') {
|
|
244
|
+
next = decodeHtmxFormBody(parsed.data);
|
|
245
|
+
} else {
|
|
246
|
+
next = parsed.data;
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
250
|
+
res.end('Invalid JSON: ' + (err && err.message ? err.message : String(err)));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!next || typeof next !== 'object') {
|
|
254
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
255
|
+
res.end('Expected a JSON object');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!Array.isArray(next.elements)) next.elements = [];
|
|
259
|
+
if (!Array.isArray(next.connections)) next.connections = [];
|
|
260
|
+
if (!Array.isArray(next.comments)) next.comments = [];
|
|
261
|
+
if (!next.viewport || typeof next.viewport !== 'object') {
|
|
262
|
+
next.viewport = { x: 0, y: 0, zoom: 1 };
|
|
263
|
+
}
|
|
264
|
+
if (typeof next.title !== 'string') next.title = 'Untitled artifact';
|
|
265
|
+
next.schemaVersion = CANVAS_SCHEMA_VERSION;
|
|
266
|
+
writeCanvasFile(planDir, next);
|
|
267
|
+
bumpLastEdited(planDir);
|
|
268
|
+
if (isHtmxRequest(req)) {
|
|
269
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
270
|
+
res.end('<span class="save-status saved">Saved</span>');
|
|
271
|
+
} else {
|
|
272
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
273
|
+
res.end(JSON.stringify({ ok: true }));
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// GET /api/<slug>/markdown-export
|
|
279
|
+
if (subPath === 'markdown-export' && req.method === 'GET') {
|
|
280
|
+
const meta = readPlanMeta(planDir);
|
|
281
|
+
const title = (meta && meta.title) || slug;
|
|
282
|
+
const canvas = loadOrMigrateCanvas(planDir, title);
|
|
283
|
+
const md = canvasToMarkdown(canvas);
|
|
284
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
|
|
285
|
+
res.end(md);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// GET /api/<slug>/elements
|
|
290
|
+
if (subPath === 'elements' && req.method === 'GET') {
|
|
291
|
+
const meta = readPlanMeta(planDir);
|
|
292
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
293
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify(canvas.elements));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// POST /api/<slug>/elements
|
|
299
|
+
if (subPath === 'elements' && req.method === 'POST') {
|
|
300
|
+
const parsed = await readRequestBody(req);
|
|
301
|
+
const body = parsed.data;
|
|
302
|
+
if (!body || typeof body !== 'object') {
|
|
303
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
304
|
+
res.end('Expected a JSON object');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const meta = readPlanMeta(planDir);
|
|
308
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
309
|
+
const el = {
|
|
310
|
+
id: makeElementId(),
|
|
311
|
+
type: typeof body.type === 'string' ? body.type : 'text',
|
|
312
|
+
x: typeof body.x === 'number' ? body.x : 100,
|
|
313
|
+
y: typeof body.y === 'number' ? body.y : 100,
|
|
314
|
+
width: typeof body.width === 'number' ? body.width : 240,
|
|
315
|
+
height: typeof body.height === 'number' ? body.height : 160,
|
|
316
|
+
};
|
|
317
|
+
if (typeof body.title === 'string') el.title = body.title;
|
|
318
|
+
if (typeof body.content === 'string') el.content = body.content;
|
|
319
|
+
if (typeof body.language === 'string') el.language = body.language;
|
|
320
|
+
if (typeof body.component === 'string') el.component = body.component;
|
|
321
|
+
if (typeof body.label === 'string') el.label = body.label;
|
|
322
|
+
if (typeof body.placeholder === 'string') el.placeholder = body.placeholder;
|
|
323
|
+
if (typeof body.value === 'string') el.value = body.value;
|
|
324
|
+
if (typeof body.body === 'string') el.body = body.body;
|
|
325
|
+
canvas.elements.push(el);
|
|
326
|
+
writeCanvasFile(planDir, canvas);
|
|
327
|
+
bumpLastEdited(planDir);
|
|
328
|
+
if (isHtmxRequest(req)) {
|
|
329
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
330
|
+
res.end(renderElementHTML(el));
|
|
331
|
+
} else {
|
|
332
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
333
|
+
res.end(JSON.stringify(el));
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// /api/<slug>/elements/<id> (PUT, DELETE)
|
|
339
|
+
const elementMatch = subPath.match(/^elements\/([^\/]+)$/);
|
|
340
|
+
if (elementMatch) {
|
|
341
|
+
const elementId = elementMatch[1];
|
|
342
|
+
const meta = readPlanMeta(planDir);
|
|
343
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
344
|
+
const idx = canvas.elements.findIndex(function (e) { return e.id === elementId; });
|
|
345
|
+
|
|
346
|
+
if (req.method === 'PUT') {
|
|
347
|
+
if (idx < 0) {
|
|
348
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
349
|
+
res.end('Element not found');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const parsed = await readRequestBody(req);
|
|
353
|
+
const body = parsed.data;
|
|
354
|
+
if (!body || typeof body !== 'object') {
|
|
355
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
356
|
+
res.end('Expected a JSON object');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const cur = canvas.elements[idx];
|
|
360
|
+
const allowedKeys = ['type', 'x', 'y', 'width', 'height', 'title', 'content',
|
|
361
|
+
'language', 'component', 'label', 'placeholder', 'value', 'body'];
|
|
362
|
+
for (let i = 0; i < allowedKeys.length; i++) {
|
|
363
|
+
const k = allowedKeys[i];
|
|
364
|
+
if (k in body) cur[k] = body[k];
|
|
365
|
+
}
|
|
366
|
+
writeCanvasFile(planDir, canvas);
|
|
367
|
+
bumpLastEdited(planDir);
|
|
368
|
+
if (isHtmxRequest(req)) {
|
|
369
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
370
|
+
res.end(renderElementHTML(cur));
|
|
371
|
+
} else {
|
|
372
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
373
|
+
res.end(JSON.stringify(cur));
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (req.method === 'DELETE') {
|
|
379
|
+
if (idx < 0) {
|
|
380
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
381
|
+
res.end('Element not found');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
canvas.elements.splice(idx, 1);
|
|
385
|
+
canvas.connections = canvas.connections.filter(function (c) {
|
|
386
|
+
return c.from !== elementId && c.to !== elementId;
|
|
387
|
+
});
|
|
388
|
+
for (let i = 0; i < canvas.comments.length; i++) {
|
|
389
|
+
if (canvas.comments[i].elementId === elementId) {
|
|
390
|
+
canvas.comments[i].elementId = null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
writeCanvasFile(planDir, canvas);
|
|
394
|
+
bumpLastEdited(planDir);
|
|
395
|
+
if (isHtmxRequest(req)) {
|
|
396
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
397
|
+
res.end('');
|
|
398
|
+
} else {
|
|
399
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
400
|
+
res.end(JSON.stringify({ ok: true, removed: elementId }));
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// GET /api/<slug>/connections
|
|
407
|
+
if (subPath === 'connections' && req.method === 'GET') {
|
|
408
|
+
const meta = readPlanMeta(planDir);
|
|
409
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
410
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
411
|
+
res.end(JSON.stringify(canvas.connections));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// POST /api/<slug>/connections
|
|
416
|
+
if (subPath === 'connections' && req.method === 'POST') {
|
|
417
|
+
const parsed = await readRequestBody(req);
|
|
418
|
+
const body = parsed.data;
|
|
419
|
+
if (!body || typeof body !== 'object') {
|
|
420
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
421
|
+
res.end('Expected a JSON object');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (typeof body.from !== 'string' || typeof body.to !== 'string') {
|
|
425
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
426
|
+
res.end('Missing from or to');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const meta = readPlanMeta(planDir);
|
|
430
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
431
|
+
const elementIds = new Set(canvas.elements.map(function (e) { return e.id; }));
|
|
432
|
+
if (!elementIds.has(body.from) || !elementIds.has(body.to)) {
|
|
433
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
434
|
+
res.end('from/to element not found');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const conn = {
|
|
438
|
+
id: makeConnectionId(),
|
|
439
|
+
from: body.from,
|
|
440
|
+
to: body.to,
|
|
441
|
+
type: ['arrow', 'line', 'dependency'].indexOf(body.type) >= 0 ? body.type : 'arrow',
|
|
442
|
+
};
|
|
443
|
+
if (typeof body.label === 'string') conn.label = body.label;
|
|
444
|
+
canvas.connections.push(conn);
|
|
445
|
+
writeCanvasFile(planDir, canvas);
|
|
446
|
+
bumpLastEdited(planDir);
|
|
447
|
+
if (isHtmxRequest(req)) {
|
|
448
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
449
|
+
res.end(renderConnectionHTML(conn));
|
|
450
|
+
} else {
|
|
451
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
452
|
+
res.end(JSON.stringify(conn));
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// DELETE /api/<slug>/connections/<id>
|
|
458
|
+
const connectionMatch = subPath.match(/^connections\/([^\/]+)$/);
|
|
459
|
+
if (connectionMatch && req.method === 'DELETE') {
|
|
460
|
+
const connectionId = connectionMatch[1];
|
|
461
|
+
const meta = readPlanMeta(planDir);
|
|
462
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
463
|
+
const before = canvas.connections.length;
|
|
464
|
+
canvas.connections = canvas.connections.filter(function (c) { return c.id !== connectionId; });
|
|
465
|
+
const removed = before !== canvas.connections.length;
|
|
466
|
+
writeCanvasFile(planDir, canvas);
|
|
467
|
+
bumpLastEdited(planDir);
|
|
468
|
+
if (!removed) {
|
|
469
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
470
|
+
res.end('Connection not found');
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (isHtmxRequest(req)) {
|
|
474
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
475
|
+
res.end('');
|
|
476
|
+
} else {
|
|
477
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
478
|
+
res.end(JSON.stringify({ ok: true, removed: connectionId }));
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// GET /api/<slug>/comments?elementId=...
|
|
484
|
+
if (subPath === 'comments' && req.method === 'GET') {
|
|
485
|
+
const query = requestUrl.split('?')[1] || '';
|
|
486
|
+
const params = new URLSearchParams(query);
|
|
487
|
+
const hasElementId = params.has('elementId');
|
|
488
|
+
const elementId = params.get('elementId') || '';
|
|
489
|
+
|
|
490
|
+
if (hasElementId) {
|
|
491
|
+
const meta = readPlanMeta(planDir);
|
|
492
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
493
|
+
let result = canvas.comments;
|
|
494
|
+
if (elementId === 'nil' || elementId === 'null') {
|
|
495
|
+
result = canvas.comments.filter(function (c) { return !c.elementId; });
|
|
496
|
+
} else if (elementId !== '') {
|
|
497
|
+
result = canvas.comments.filter(function (c) { return c.elementId === elementId; });
|
|
498
|
+
}
|
|
499
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
500
|
+
res.end(JSON.stringify(result));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Fall through to v1 handler
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// POST /api/<slug>/comments
|
|
507
|
+
if (subPath === 'comments' && req.method === 'POST') {
|
|
508
|
+
const parsed = await readRequestBody(req);
|
|
509
|
+
const body = parsed.data;
|
|
510
|
+
if (!body || typeof body !== 'object') {
|
|
511
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
512
|
+
res.end('Expected a JSON or form object');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// v1 discriminator: sectionId is a non-empty string
|
|
516
|
+
const isV1 = typeof body.sectionId === 'string' && body.sectionId;
|
|
517
|
+
if (isV1) {
|
|
518
|
+
const sectionId = body.sectionId;
|
|
519
|
+
const text = body.text;
|
|
520
|
+
const author = body.author;
|
|
521
|
+
if (!text) {
|
|
522
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
523
|
+
res.end('Missing text');
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
|
|
527
|
+
const newComment = {
|
|
528
|
+
id: makeCommentId(),
|
|
529
|
+
sectionId,
|
|
530
|
+
text,
|
|
531
|
+
author: author || process.env.USER || 'anonymous',
|
|
532
|
+
timestamp: new Date().toISOString(),
|
|
533
|
+
};
|
|
534
|
+
comments.push(newComment);
|
|
535
|
+
atomicWriteJson(join(planDir, 'comments.json'), comments);
|
|
536
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
537
|
+
res.end(renderCommentLi(newComment));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// v2 path
|
|
541
|
+
if (typeof body.text !== 'string' || !body.text) {
|
|
542
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
543
|
+
res.end('Missing text');
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const meta = readPlanMeta(planDir);
|
|
547
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
548
|
+
const c = {
|
|
549
|
+
id: makeCommentId(),
|
|
550
|
+
x: typeof body.x === 'number' ? body.x : 0,
|
|
551
|
+
y: typeof body.y === 'number' ? body.y : 0,
|
|
552
|
+
elementId: typeof body.elementId === 'string' ? body.elementId : null,
|
|
553
|
+
author: (typeof body.author === 'string' && body.author) || process.env.USER || 'anonymous',
|
|
554
|
+
text: body.text,
|
|
555
|
+
created: new Date().toISOString(),
|
|
556
|
+
thread: [],
|
|
557
|
+
};
|
|
558
|
+
canvas.comments.push(c);
|
|
559
|
+
writeCanvasFile(planDir, canvas);
|
|
560
|
+
bumpLastEdited(planDir);
|
|
561
|
+
if (isHtmxRequest(req)) {
|
|
562
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
563
|
+
res.end(renderCommentPinHTML(c, canvas.comments.length - 1));
|
|
564
|
+
} else {
|
|
565
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
566
|
+
res.end(JSON.stringify(c));
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// PUT /api/<slug>/comments/<id>
|
|
572
|
+
if (subPath.match(/^comments\/[^\/]+$/) && req.method === 'PUT') {
|
|
573
|
+
const commentMatch = subPath.match(/^comments\/([^\/]+)$/);
|
|
574
|
+
const commentId = commentMatch[1];
|
|
575
|
+
const parsed = await readRequestBody(req);
|
|
576
|
+
const body = parsed.data;
|
|
577
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
578
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
579
|
+
res.end('Expected a JSON object');
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const meta = readPlanMeta(planDir);
|
|
583
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
584
|
+
const idx = canvas.comments.findIndex(function (c) { return c.id === commentId; });
|
|
585
|
+
if (idx < 0) {
|
|
586
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
587
|
+
res.end('Comment not found');
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const cur = canvas.comments[idx];
|
|
591
|
+
if (typeof body.text === 'string') cur.text = body.text;
|
|
592
|
+
if (typeof body.x === 'number') cur.x = body.x;
|
|
593
|
+
if (typeof body.y === 'number') cur.y = body.y;
|
|
594
|
+
if (typeof body.elementId === 'string' || body.elementId === null) {
|
|
595
|
+
cur.elementId = body.elementId;
|
|
596
|
+
}
|
|
597
|
+
if (typeof body.reply === 'string' && body.reply) {
|
|
598
|
+
if (!Array.isArray(cur.thread)) cur.thread = [];
|
|
599
|
+
cur.thread.push({
|
|
600
|
+
id: makeReplyId(),
|
|
601
|
+
author: (typeof body.replyAuthor === 'string' && body.replyAuthor) || 'ai',
|
|
602
|
+
text: body.reply,
|
|
603
|
+
created: new Date().toISOString(),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
writeCanvasFile(planDir, canvas);
|
|
607
|
+
bumpLastEdited(planDir);
|
|
608
|
+
if (isHtmxRequest(req)) {
|
|
609
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
610
|
+
res.end(renderCommentThreadHTML(cur));
|
|
611
|
+
} else {
|
|
612
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
613
|
+
res.end(JSON.stringify(cur));
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// DELETE /api/<slug>/comments/<id>
|
|
619
|
+
if (subPath.match(/^comments\/[^\/]+$/) && req.method === 'DELETE') {
|
|
620
|
+
const commentMatch = subPath.match(/^comments\/[^\/]+$/);
|
|
621
|
+
const commentId = commentMatch[1];
|
|
622
|
+
const meta = readPlanMeta(planDir);
|
|
623
|
+
const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
|
|
624
|
+
const idx = canvas.comments.findIndex(function (c) { return c.id === commentId; });
|
|
625
|
+
if (idx < 0) {
|
|
626
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
627
|
+
res.end('Comment not found');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
canvas.comments.splice(idx, 1);
|
|
631
|
+
writeCanvasFile(planDir, canvas);
|
|
632
|
+
bumpLastEdited(planDir);
|
|
633
|
+
if (isHtmxRequest(req)) {
|
|
634
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
635
|
+
res.end('');
|
|
636
|
+
} else {
|
|
637
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
638
|
+
res.end(JSON.stringify({ ok: true, removed: commentId }));
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ── v1 routes (legacy) ───────────────────────────────────────────────
|
|
644
|
+
// GET /api/<slug>/artifact
|
|
645
|
+
if (resource === 'artifact' && req.method === 'GET') {
|
|
646
|
+
const mdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
|
|
647
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
648
|
+
res.end(mdx);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// PUT /api/<slug>/artifact
|
|
653
|
+
if (resource === 'artifact' && req.method === 'PUT') {
|
|
654
|
+
const parsed = await readRequestBody(req);
|
|
655
|
+
const content = parsed.kind === 'form' ? (parsed.data.content || '') : parsed.data;
|
|
656
|
+
if (typeof content !== 'string') {
|
|
657
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
658
|
+
res.end('Expected a "content" form field or a raw text body');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
atomicWriteText(join(planDir, 'artifact.mdx'), content);
|
|
663
|
+
bumpLastEdited(planDir);
|
|
664
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
665
|
+
res.end('saved');
|
|
666
|
+
} catch (err) {
|
|
667
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
668
|
+
res.end('Save failed: ' + err.message);
|
|
669
|
+
}
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// GET /api/<slug>/comments?format=html§ionId=...
|
|
674
|
+
if (resource === 'comments' && req.method === 'GET') {
|
|
675
|
+
const query = requestUrl.split('?')[1] || '';
|
|
676
|
+
const params = new URLSearchParams(query);
|
|
677
|
+
const format = (params.get('format') || 'json').toLowerCase();
|
|
678
|
+
const sectionId = params.get('sectionId') || '';
|
|
679
|
+
const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
|
|
680
|
+
if (format === 'html') {
|
|
681
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
682
|
+
res.end(renderCommentListHtml(comments, sectionId));
|
|
683
|
+
} else {
|
|
684
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
685
|
+
res.end(JSON.stringify(comments));
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// PUT /api/<slug>/comments
|
|
691
|
+
if (resource === 'comments' && req.method === 'PUT') {
|
|
692
|
+
const parsed = await readRequestBody(req);
|
|
693
|
+
let arr;
|
|
694
|
+
try {
|
|
695
|
+
arr = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
|
|
696
|
+
} catch {
|
|
697
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
698
|
+
res.end('Invalid JSON');
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (!Array.isArray(arr)) {
|
|
702
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
703
|
+
res.end('Expected a JSON array of comments');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
atomicWriteJson(join(planDir, 'comments.json'), arr);
|
|
707
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
708
|
+
res.end('ok');
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// GET /api/<slug>/count?sectionId=...
|
|
713
|
+
if (resource === 'count' && req.method === 'GET') {
|
|
714
|
+
const query = requestUrl.split('?')[1] || '';
|
|
715
|
+
const params = new URLSearchParams(query);
|
|
716
|
+
const sectionId = params.get('sectionId') || '';
|
|
717
|
+
const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
|
|
718
|
+
const n = comments.filter((c) => !sectionId || c.sectionId === sectionId).length;
|
|
719
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
720
|
+
res.end(renderCommentCountHtml(n));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Legacy routes (no slug prefix)
|
|
726
|
+
if (pathname === '/api/artifact' && req.method === 'GET') {
|
|
727
|
+
const mdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
|
|
728
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
729
|
+
res.end(mdx);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (pathname === '/api/artifact' && req.method === 'PUT') {
|
|
734
|
+
let body = '';
|
|
735
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
736
|
+
req.on('end', () => {
|
|
737
|
+
try {
|
|
738
|
+
atomicWriteText(join(planDir, 'artifact.mdx'), body);
|
|
739
|
+
const meta = JSON.parse(readFileSync(join(planDir, 'meta.json'), 'utf-8'));
|
|
740
|
+
meta.lastEdited = new Date().toISOString();
|
|
741
|
+
atomicWriteJson(join(planDir, 'meta.json'), meta);
|
|
742
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
743
|
+
res.end(JSON.stringify({ ok: true }));
|
|
744
|
+
} catch (err) {
|
|
745
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
746
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (pathname === '/api/comments' && req.method === 'GET') {
|
|
753
|
+
const comments = readFileSync(join(planDir, 'comments.json'), 'utf-8');
|
|
754
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
755
|
+
res.end(comments);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (pathname === '/api/comments' && req.method === 'PUT') {
|
|
760
|
+
let body = '';
|
|
761
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
762
|
+
req.on('end', () => {
|
|
763
|
+
try {
|
|
764
|
+
JSON.parse(body);
|
|
765
|
+
atomicWriteText(join(planDir, 'comments.json'), body);
|
|
766
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
767
|
+
res.end(JSON.stringify({ ok: true }));
|
|
768
|
+
} catch (err) {
|
|
769
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
770
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (pathname === '/api/comments' && req.method === 'POST') {
|
|
777
|
+
let body = '';
|
|
778
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
779
|
+
req.on('end', () => {
|
|
780
|
+
try {
|
|
781
|
+
const { sectionId, text, author } = JSON.parse(body);
|
|
782
|
+
if (!sectionId || !text) {
|
|
783
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
784
|
+
res.end(JSON.stringify({ error: 'Missing sectionId or text' }));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
|
|
788
|
+
comments.push({
|
|
789
|
+
id: Date.now().toString(),
|
|
790
|
+
sectionId,
|
|
791
|
+
text,
|
|
792
|
+
author: author || process.env.USER || 'anonymous',
|
|
793
|
+
created: new Date().toISOString(),
|
|
794
|
+
});
|
|
795
|
+
atomicWriteJson(join(planDir, 'comments.json'), comments);
|
|
796
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
797
|
+
res.end(JSON.stringify({ ok: true, comments }));
|
|
798
|
+
} catch (err) {
|
|
799
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
800
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (pathname === '/api/regenerate' && req.method === 'POST') {
|
|
807
|
+
try {
|
|
808
|
+
const planMdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
|
|
809
|
+
const metaJson = readFileSync(join(planDir, 'meta.json'), 'utf-8');
|
|
810
|
+
const commentsJson = readFileSync(join(planDir, 'comments.json'), 'utf-8');
|
|
811
|
+
const meta = JSON.parse(metaJson);
|
|
812
|
+
const planHtmlTemplate = readFileSync(join(TEMPLATES_DIR, 'artifact.html.template'), 'utf-8');
|
|
813
|
+
const planJson = JSON.stringify(planMdx);
|
|
814
|
+
const vars = {
|
|
815
|
+
title: meta.title || slug,
|
|
816
|
+
slug: meta.slug || slug,
|
|
817
|
+
status: meta.status || 'draft',
|
|
818
|
+
created: meta.created || new Date().toISOString(),
|
|
819
|
+
lastEdited: meta.lastEdited || new Date().toISOString(),
|
|
820
|
+
author: meta.author || 'unknown',
|
|
821
|
+
planJson,
|
|
822
|
+
commentsJson,
|
|
823
|
+
metaJson,
|
|
824
|
+
};
|
|
825
|
+
let htmlContent = planHtmlTemplate;
|
|
826
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
827
|
+
htmlContent = htmlContent.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
|
828
|
+
}
|
|
829
|
+
atomicWriteText(join(planDir, 'artifact.html'), htmlContent);
|
|
830
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
831
|
+
res.end(JSON.stringify({ ok: true }));
|
|
832
|
+
} catch (err) {
|
|
833
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
834
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// 404 for unknown routes
|
|
840
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
841
|
+
res.end('Not found');
|
|
842
|
+
} catch (err) {
|
|
843
|
+
console.error(`[${new Date().toISOString()}] ERROR: ${err.message}`);
|
|
844
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
845
|
+
res.end(`Server error: ${err.message}`);
|
|
846
|
+
}
|
|
847
|
+
}
|