@polderlabs/bizar 4.5.2 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. package/bizar-dash/dist/assets/mobile-lbH6szyX.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&sectionId=...
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
+ }