@polderlabs/bizar 4.5.2 → 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.
Files changed (71) hide show
  1. package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
  2. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
  3. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-CWqPoGaT.js} +18 -18
  4. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-i4Uv9eW8.js} +1 -1
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-i4Uv9eW8.js.map} +1 -1
  7. package/bizar-dash/dist/index.html +2 -2
  8. package/bizar-dash/dist/mobile.html +2 -2
  9. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  10. package/bizar-dash/src/server/logger.mjs +71 -0
  11. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  12. package/bizar-dash/src/server/metrics.mjs +193 -0
  13. package/bizar-dash/src/server/routes/chat.mjs +6 -3
  14. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  15. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  16. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  17. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  18. package/bizar-dash/src/server/server.mjs +62 -0
  19. package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
  20. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  21. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  22. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  23. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  24. package/bizar-dash/src/web/locales/en.json +52 -0
  25. package/bizar-dash/src/web/main.tsx +5 -0
  26. package/bizar-dash/src/web/views/Activity.tsx +24 -17
  27. package/bizar-dash/src/web/views/History.tsx +90 -75
  28. package/bizar-dash/src/web/views/Overview.tsx +11 -8
  29. package/bizar-dash/src/web/views/Settings.tsx +7 -1
  30. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  31. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  32. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  33. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  34. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  35. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  36. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  37. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  38. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  39. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  40. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  41. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  42. package/bizar-dash/tests/logger.test.mjs +207 -0
  43. package/bizar-dash/tests/metrics.test.mjs +183 -0
  44. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  45. package/bizar-dash/tests/setup.ts +7 -0
  46. package/bizar-dash/vitest.config.ts +13 -0
  47. package/cli/artifact-cli.mjs +605 -0
  48. package/cli/artifact-render.mjs +621 -0
  49. package/cli/artifact-server.mjs +847 -0
  50. package/cli/artifact.mjs +38 -2096
  51. package/cli/bg.mjs +5 -13
  52. package/cli/bin.mjs +149 -1361
  53. package/cli/commands/artifact.mjs +20 -0
  54. package/cli/commands/dash.mjs +160 -0
  55. package/cli/commands/headroom.mjs +204 -0
  56. package/cli/commands/install.mjs +169 -0
  57. package/cli/commands/memory.mjs +25 -0
  58. package/cli/commands/minimax.mjs +265 -0
  59. package/cli/commands/mod.mjs +185 -0
  60. package/cli/commands/service.mjs +65 -0
  61. package/cli/commands/usage.mjs +109 -0
  62. package/cli/commands/util.mjs +306 -0
  63. package/cli/doctor.mjs +1 -13
  64. package/cli/provision.mjs +2 -13
  65. package/cli/service-controller.mjs +1 -11
  66. package/cli/service.mjs +1 -11
  67. package/cli/utils.mjs +41 -1
  68. package/package.json +6 -1
  69. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  70. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  71. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
package/cli/artifact.mjs CHANGED
@@ -1,2103 +1,31 @@
1
1
  /**
2
- * bizar artifact <subcommand>
2
+ * cli/artifact.mjs
3
3
  *
4
- * Subcommands:
5
- * new <slug> Create a new artifact
6
- * Flags: --template <name> | --template <path>
7
- * open <slug> Open an existing artifact in the browser
8
- * list List all artifacts
9
- * delete <slug> Delete a artifact (with confirmation)
10
- * export <slug> Export artifact.mdx to stdout
11
- * templates List available artifact templates
12
- * template save <name> <artifact-slug> Save a artifact as a library template
13
- * template delete <name> Delete a user-added library template
14
- * help Show this help
4
+ * v4.6 — Re-export shell for the artifact module split.
5
+ * All functionality moved to:
6
+ * artifact-cli.mjs — CLI dispatch + flag parsing + help
7
+ * artifact-server.mjs HTTP server + routing
8
+ * artifact-render.mjs HTML rendering + canvas helpers + MDX export
15
9
  *
16
- * The local server runs in the SAME process as the CLI (no child process).
17
- * The CLI keeps the process alive by waiting on a Promise that never resolves
18
- * until the user presses Ctrl-C (the server is stopped on SIGINT).
10
+ * This file re-exports everything from the original API for backward
11
+ * compatibility. New code should import directly from the split files.
19
12
  */
13
+ export {
14
+ // From artifact-cli.mjs
15
+ runArtifact as runPlan, // backward-compat alias (test imports runPlan)
16
+ runArtifact,
17
+ default,
18
+ showHelp,
19
+ regenerateHtml,
20
+ } from './artifact-cli.mjs';
20
21
 
21
- import { createServer } from 'node:http';
22
- import {
23
- readFileSync,
24
- writeFileSync,
25
- existsSync,
26
- mkdirSync,
27
- renameSync,
28
- readdirSync,
29
- rmSync,
30
- } from 'node:fs';
31
- import { join, resolve, isAbsolute } from 'node:path';
32
- import { spawn, spawnSync } from 'node:child_process';
33
- import { createReadStream, createWriteStream } from 'node:fs';
34
- import { readFile } from 'node:fs/promises';
35
- import { dirname } from 'node:path';
36
- import { fileURLToPath } from 'node:url';
37
-
38
- import {
39
- getTemplate,
40
- getTemplateNames,
41
- listTemplates,
42
- printTemplates,
43
- substitute,
44
- buildVars,
45
- saveTemplate as saveTemplateToLibrary,
46
- deleteTemplate as deleteLibraryTemplate,
47
- } from './artifact-templates.mjs';
48
-
49
- const __dirname = dirname(fileURLToPath(import.meta.url));
50
- const PROJECT_ROOT = resolve(__dirname, '..');
51
- const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', 'artifact');
52
- const PLANS_DIR = join(PROJECT_ROOT, 'artifacts');
53
- const MAX_REQUEST_BODY_BYTES = 5 * 1024 * 1024;
54
-
55
- // ─── Flag parsing ────────────────────────────────────────────────────────────
56
-
57
- /**
58
- * Parse a string of CLI args into { positional, flags }.
59
- * Supports --flag value and --flag=value styles. Booleans (no value)
60
- * are stored as true.
61
- */
62
- function parseArgs(argv) {
63
- const positional = [];
64
- const flags = {};
65
- for (let i = 0; i < argv.length; i++) {
66
- const a = argv[i];
67
- if (a.startsWith('--')) {
68
- const eq = a.indexOf('=');
69
- if (eq !== -1) {
70
- flags[a.slice(2, eq)] = a.slice(eq + 1);
71
- } else {
72
- const key = a.slice(2);
73
- const next = argv[i + 1];
74
- if (next != null && !next.startsWith('--')) {
75
- flags[key] = next;
76
- i++;
77
- } else {
78
- flags[key] = true;
79
- }
80
- }
81
- } else {
82
- positional.push(a);
83
- }
84
- }
85
- return { positional, flags };
86
- }
87
-
88
- // ─── Slug validation ─────────────────────────────────────────────────────────
89
-
90
- // As per spec: ^[a-z0-9][a-z0-9-]{0,63}$
91
- // 1-64 chars, lowercase, hyphens allowed, must start with alphanumeric
92
- const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
93
-
94
- function validateSlug(slug) {
95
- if (!slug || !SLUG_REGEX.test(slug)) {
96
- console.error(
97
- ` ✗ Invalid slug "${slug}". Slug must be lowercase, may contain hyphens,\n` +
98
- ` must start with an alphanumeric character, and be 1–64 characters.`
99
- );
100
- return false;
101
- }
102
- return true;
103
- }
104
-
105
- // ─── Title case ──────────────────────────────────────────────────────────────
106
-
107
- function titleCase(str) {
108
- return str
109
- .split('-')
110
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
111
- .join(' ');
112
- }
113
-
114
- // ─── Template replacement ────────────────────────────────────────────────────
115
-
116
- function replaceTemplate(content, vars) {
117
- let result = content;
118
- for (const [key, value] of Object.entries(vars)) {
119
- result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
120
- }
121
- return result;
122
- }
123
-
124
- // ─── File helpers ────────────────────────────────────────────────────────────
125
-
126
- async function readTemplate(name) {
127
- const path = join(TEMPLATES_DIR, name);
128
- if (!existsSync(path)) {
129
- throw new Error(`Template not found: ${path}`);
130
- }
131
- return readFile(path, 'utf-8');
132
- }
133
-
134
- function atomicWriteText(filePath, content) {
135
- mkdirSync(dirname(filePath), { recursive: true });
136
- const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
137
- try {
138
- writeFileSync(tmpPath, content, 'utf-8');
139
- renameSync(tmpPath, filePath);
140
- } catch (error) {
141
- try {
142
- rmSync(tmpPath, { force: true });
143
- } catch {
144
- // ignore cleanup failure
145
- }
146
- throw error;
147
- }
148
- }
149
-
150
- function atomicWriteJson(filePath, value) {
151
- atomicWriteText(filePath, JSON.stringify(value, null, 2));
152
- }
153
-
154
- function writePlanFile(slug, filename, content) {
155
- const dir = join(PLANS_DIR, slug);
156
- mkdirSync(dir, { recursive: true });
157
- atomicWriteText(join(dir, filename), content);
158
- }
159
-
160
- function readPlanFile(slug, filename) {
161
- return readFileSync(join(PLANS_DIR, slug, filename), 'utf-8');
162
- }
163
-
164
- // ─── Canvas state (v2) ──────────────────────────────────────────────────────
165
-
166
- /**
167
- * The current canvas schema. v2 introduces elements, connections, viewport,
168
- * and threaded comments. The single source of truth for new artifacts.
169
- */
170
- const CANVAS_SCHEMA_VERSION = 2;
171
-
172
- function emptyCanvas(title) {
173
- return {
174
- schemaVersion: CANVAS_SCHEMA_VERSION,
175
- title: title || 'Untitled artifact',
176
- elements: [],
177
- connections: [],
178
- comments: [],
179
- viewport: { x: 0, y: 0, zoom: 1 },
180
- };
181
- }
182
-
183
- function readCanvasFile(planDir) {
184
- const path = join(planDir, 'artifact.json');
185
- if (!existsSync(path)) return null;
186
- try {
187
- return JSON.parse(readFileSync(path, 'utf-8'));
188
- } catch {
189
- return null;
190
- }
191
- }
192
-
193
- function writeCanvasFile(planDir, canvas) {
194
- atomicWriteJson(join(planDir, 'artifact.json'), canvas);
195
- }
196
-
197
- /**
198
- * Migration shim: if a v1 artifact (artifact.mdx) exists but no artifact.json, build
199
- * a v2 canvas with the mdx content as a single "text" element on the
200
- * canvas. Idempotent — won't overwrite an existing artifact.json.
201
- *
202
- * Returns the resulting canvas.
203
- */
204
- function loadOrMigrateCanvas(planDir, fallbackTitle) {
205
- const existing = readCanvasFile(planDir);
206
- if (existing) {
207
- // Backfill defaults for older v2 files that may not have every field.
208
- if (!Array.isArray(existing.elements)) existing.elements = [];
209
- if (!Array.isArray(existing.connections)) existing.connections = [];
210
- if (!Array.isArray(existing.comments)) existing.comments = [];
211
- if (!existing.viewport || typeof existing.viewport !== 'object') {
212
- existing.viewport = { x: 0, y: 0, zoom: 1 };
213
- }
214
- if (typeof existing.title !== 'string') existing.title = fallbackTitle || 'Untitled artifact';
215
- if (existing.schemaVersion !== CANVAS_SCHEMA_VERSION) existing.schemaVersion = CANVAS_SCHEMA_VERSION;
216
- return existing;
217
- }
218
-
219
- const mdxPath = join(planDir, 'artifact.mdx');
220
- if (existsSync(mdxPath)) {
221
- const mdx = readFileSync(mdxPath, 'utf-8');
222
- // If the mdx is empty or just whitespace, return a blank canvas.
223
- if (!mdx || !mdx.trim()) {
224
- const c = emptyCanvas(fallbackTitle);
225
- writeCanvasFile(planDir, c);
226
- return c;
227
- }
228
- // Wrap the entire mdx into a single "text" element centered on the canvas.
229
- const elId = 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
230
- const canvas = emptyCanvas(fallbackTitle);
231
- canvas.elements.push({
232
- id: elId,
233
- type: 'text',
234
- x: 80,
235
- y: 80,
236
- width: 560,
237
- height: 420,
238
- title: 'Migrated content',
239
- content: mdx,
240
- });
241
- writeCanvasFile(planDir, canvas);
242
- return canvas;
243
- }
244
-
245
- // No artifact.json and no artifact.mdx — start fresh.
246
- const c = emptyCanvas(fallbackTitle);
247
- writeCanvasFile(planDir, c);
248
- return c;
249
- }
250
-
251
- /**
252
- * Convert a v2 canvas state to a derived markdown document.
253
- * Used by:
254
- * - the /api/<slug>/markdown-export endpoint
255
- * - the `bizar artifact export` subcommand (for backwards compat)
256
- *
257
- * Strategy: emit each element as a section, in a stable order (top-to-bottom
258
- * by y, then left-to-right by x). Connections are not represented in the
259
- * markdown — they're a visual concept.
260
- */
261
- function canvasToMarkdown(canvas) {
262
- const lines = [];
263
- const title = (canvas && canvas.title) || 'Untitled artifact';
264
- lines.push('# ' + title);
265
- lines.push('');
266
- lines.push('*Exported from canvas on ' + new Date().toISOString() + '*');
267
- lines.push('');
268
-
269
- const elements = (canvas && Array.isArray(canvas.elements)) ? canvas.elements.slice() : [];
270
- // Sort by y, then x. Elements without y/x are placed at the end.
271
- elements.sort(function (a, b) {
272
- const ay = typeof a.y === 'number' ? a.y : 99999;
273
- const by = typeof b.y === 'number' ? b.y : 99999;
274
- if (ay !== by) return ay - by;
275
- const ax = typeof a.x === 'number' ? a.x : 0;
276
- const bx = typeof b.x === 'number' ? b.x : 0;
277
- return ax - bx;
278
- });
279
-
280
- for (let i = 0; i < elements.length; i++) {
281
- const el = elements[i];
282
- if (el.title) {
283
- lines.push('## ' + String(el.title));
284
- lines.push('');
285
- }
286
- if (el.type === 'text') {
287
- const content = typeof el.content === 'string' ? el.content : '';
288
- if (content) {
289
- lines.push(content);
290
- lines.push('');
291
- }
292
- } else if (el.type === 'code') {
293
- const lang = (el.language || '').trim();
294
- lines.push('```' + lang);
295
- lines.push(typeof el.content === 'string' ? el.content : '');
296
- lines.push('```');
297
- lines.push('');
298
- } else if (el.type === 'image') {
299
- const url = typeof el.content === 'string' ? el.content : '';
300
- if (url) {
301
- lines.push('![' + (el.title || 'image') + '](' + url + ')');
302
- lines.push('');
303
- }
304
- } else if (el.type === 'diagram') {
305
- const content = typeof el.content === 'string' ? el.content : '';
306
- if (content) {
307
- lines.push('```mermaid');
308
- lines.push(content);
309
- lines.push('```');
310
- lines.push('');
311
- }
312
- } else if (el.type === 'ui-mockup') {
313
- const component = (el.component || '').toLowerCase();
314
- if (component === 'button') {
315
- lines.push('> [' + (el.label || 'Button') + ']');
316
- lines.push('');
317
- } else if (component === 'input') {
318
- lines.push('> Input(' + (el.placeholder || 'placeholder') + ') = "' + (el.value || '') + '"');
319
- lines.push('');
320
- } else if (component === 'card') {
321
- lines.push('> **' + (el.title || 'Card') + '**');
322
- if (el.body) lines.push('> ' + String(el.body).replace(/\n/g, '\n> '));
323
- lines.push('');
324
- } else {
325
- // Unknown component — emit a placeholder so it isn't lost.
326
- lines.push('> [ui-mockup: ' + component + ']');
327
- lines.push('');
328
- }
329
- } else {
330
- const content = typeof el.content === 'string' ? el.content : '';
331
- if (content) {
332
- lines.push(content);
333
- lines.push('');
334
- }
335
- }
336
- }
337
-
338
- // Comment summary at the end.
339
- const comments = (canvas && Array.isArray(canvas.comments)) ? canvas.comments : [];
340
- if (comments.length > 0) {
341
- lines.push('## Notes');
342
- lines.push('');
343
- for (let i = 0; i < comments.length; i++) {
344
- const c = comments[i];
345
- const author = c.author || 'Anonymous';
346
- const text = c.text || '';
347
- lines.push('- **' + author + '**: ' + text);
348
- if (Array.isArray(c.thread)) {
349
- for (let j = 0; j < c.thread.length; j++) {
350
- const reply = c.thread[j];
351
- lines.push(' - **' + (reply.author || 'ai') + '**: ' + (reply.text || ''));
352
- }
353
- }
354
- }
355
- lines.push('');
356
- }
357
-
358
- return lines.join('\n');
359
- }
360
-
361
- // ─── ID generators ──────────────────────────────────────────────────────────
362
-
363
- function makeElementId() {
364
- return 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
365
- }
366
- function makeConnectionId() {
367
- return 'conn_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
368
- }
369
- function makeCommentId() {
370
- return 'c_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
371
- }
372
- function makeReplyId() {
373
- return 'r_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
374
- }
375
-
376
- /** Best-effort read of artifacts/<slug>/meta.json. Returns null on missing or
377
- * invalid JSON. Used by the canvas endpoints to surface the artifact title. */
378
- function readPlanMeta(planDir) {
379
- const metaPath = join(planDir, 'meta.json');
380
- if (!existsSync(metaPath)) return null;
381
- try {
382
- return JSON.parse(readFileSync(metaPath, 'utf-8'));
383
- } catch {
384
- return null;
385
- }
386
- }
387
-
388
- // ─── new <slug> flow ─────────────────────────────────────────────────────────
389
-
390
- /**
391
- * Resolve the content for a new artifact from the template system.
392
- * - If template is the string "blank" or null → use artifact.mdx.template (the v1 default)
393
- * - If template is a built-in name (feature-design, etc.) → use that template
394
- * - If template is an absolute path to a .mdx file → use that file
395
- * - Otherwise → throw with a helpful error
396
- *
397
- * Returns the MDX content with {{vars}} already substituted.
398
- */
399
- async function resolveTemplateContent(template, vars) {
400
- if (template == null || template === '' || template === 'blank') {
401
- const tpl = await readTemplate('artifact.mdx.template');
402
- return { content: replaceTemplate(tpl, vars), templateName: 'blank', source: 'built-in' };
403
- }
404
-
405
- // Absolute path to a user file
406
- if (isAbsolute(template) || template.endsWith('.mdx') || template.startsWith('.')) {
407
- let p = template;
408
- if (!isAbsolute(p)) p = resolve(p);
409
- if (existsSync(p)) {
410
- const fileContent = readFileSync(p, 'utf-8');
411
- return {
412
- content: substitute(fileContent, vars),
413
- templateName: basename(p).replace(/\.mdx$/, ''),
414
- source: 'file',
415
- };
416
- }
417
- }
418
-
419
- // Built-in name
420
- const tpl = getTemplate(template);
421
- if (!tpl) {
422
- const available = getTemplateNames().join(', ');
423
- throw new Error(
424
- `Unknown template "${template}".\n` +
425
- ` Built-in templates: ${available}.\n` +
426
- ` Or pass an absolute path to a .mdx file.`
427
- );
428
- }
429
-
430
- if (tpl.content == null) {
431
- // "blank" via getTemplate — same as the default branch
432
- const blankTpl = await readTemplate('artifact.mdx.template');
433
- return { content: replaceTemplate(blankTpl, vars), templateName: 'blank', source: 'built-in' };
434
- }
435
-
436
- return { content: substitute(tpl.content, vars), templateName: tpl.name, source: tpl.source };
437
- }
438
-
439
- async function createArtifact(slug, { template = null } = {}) {
440
- const planDir = join(PLANS_DIR, slug);
441
-
442
- // Step 1: validate
443
- if (!validateSlug(slug)) return false;
444
-
445
- // Step 2: check existence
446
- if (existsSync(planDir)) {
447
- console.error(` ✗ Artifact "${slug}" already exists at ${planDir}`);
448
- console.error(` Use "bizar artifact open ${slug}" to open it.`);
449
- return false;
450
- }
451
-
452
- // Step 3: create directory
453
- mkdirSync(planDir, { recursive: true });
454
-
455
- // Step 4: generate values
456
- const now = new Date().toISOString();
457
- const title = titleCase(slug);
458
- const author = process.env.USER || 'unknown';
459
- const vars = {
460
- title,
461
- slug,
462
- author,
463
- created: now,
464
- lastEdited: now,
465
- };
466
-
467
- // Step 5: artifact.mdx (from template if --template was given)
468
- let mdxContent;
469
- let templateName = 'blank';
470
- try {
471
- const resolved = await resolveTemplateContent(template, vars);
472
- mdxContent = resolved.content;
473
- templateName = resolved.templateName;
474
- } catch (err) {
475
- console.error(` ✗ ${err.message}`);
476
- // Clean up the empty artifact directory we just created
477
- rmSync(planDir, { recursive: true, force: true });
478
- return false;
479
- }
480
- writePlanFile(slug, 'artifact.mdx', mdxContent);
481
-
482
- // Step 6: meta.json
483
- const metaTemplate = await readTemplate('meta.json.template');
484
- const metaContent = replaceTemplate(metaTemplate, vars);
485
- writePlanFile(slug, 'meta.json', metaContent);
486
-
487
- // Step 7: comments.json (stored as empty array for comments)
488
- writePlanFile(slug, 'comments.json', JSON.stringify([], null, 2));
489
-
490
- // Step 8: artifact.html (regenerate from template)
491
- await regenerateHtml(slug);
492
-
493
- console.log(` ✓ Created artifact "${title}" (slug: ${slug})`);
494
- if (templateName !== 'blank') {
495
- console.log(` Template: ${templateName}`);
496
- }
497
- console.log(` → ${planDir}`);
498
-
499
- return true;
500
- }
501
-
502
- // ─── regenerateHtml ───────────────────────────────────────────────────────────
503
-
504
- export async function regenerateHtml(slug) {
505
- const planDir = join(PLANS_DIR, slug);
506
- if (!existsSync(planDir)) return;
507
-
508
- // Prefer the v2 canvas template. Fall back to the v1 template (kept for
509
- // backwards compat with artifacts that don't have artifact.json).
510
- let htmlTemplate;
511
- let templateName = 'artifact.html.template';
512
- try {
513
- htmlTemplate = await readTemplate('artifact.canvas.template');
514
- templateName = 'artifact.canvas.template';
515
- } catch {
516
- try {
517
- htmlTemplate = await readTemplate('artifact.html.template');
518
- templateName = 'artifact.html.template';
519
- } catch {
520
- // No HTML template yet — Tyr is working on it in parallel
521
- return;
522
- }
523
- }
524
-
525
- const planMdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
526
- const metaJson = readFileSync(join(planDir, 'meta.json'), 'utf-8');
527
- const commentsJson = readFileSync(join(planDir, 'comments.json'), 'utf-8');
528
-
529
- const meta = JSON.parse(metaJson);
530
-
531
- // Bake in current state via string replacements
532
- // Note: We don't do full MDX parsing here — the HTML template handles rendering
533
- const planJson = JSON.stringify(planMdx);
534
-
535
- // For the v2 canvas template, also bake in the canvas JSON. Auto-migrate
536
- // mdx → canvas on the fly so the viewer has the right state from the
537
- // very first paint. The on-disk file is written by GET /api/<slug>/canvas
538
- // (or the first PUT), so this is just a temporary bake.
539
- let canvasJson = 'null';
540
- try {
541
- const canvas = loadOrMigrateCanvas(planDir, meta.title || slug);
542
- canvasJson = JSON.stringify(canvas);
543
- } catch {
544
- // ignore — leave canvasJson as null and let the client fetch via API
545
- }
546
-
547
- const vars = {
548
- title: meta.title || slug,
549
- slug: meta.slug || slug,
550
- status: meta.status || 'draft',
551
- created: meta.created || new Date().toISOString(),
552
- lastEdited: meta.lastEdited || new Date().toISOString(),
553
- author: meta.author || 'unknown',
554
- planJson,
555
- commentsJson,
556
- metaJson,
557
- canvasJson,
558
- templateName,
559
- };
560
-
561
- const htmlContent = replaceTemplate(htmlTemplate, vars);
562
- atomicWriteText(join(planDir, 'artifact.html'), htmlContent);
563
- }
564
-
565
- // ─── open <slug> flow ────────────────────────────────────────────────────────
566
-
567
- async function openArtifact(slug) {
568
- if (!validateSlug(slug)) return false;
569
-
570
- const planDir = join(PLANS_DIR, slug);
571
- if (!existsSync(planDir)) {
572
- console.error(` ✗ Artifact "${slug}" not found at ${planDir}`);
573
- console.error(` Use "bizar artifact new ${slug}" to create it.`);
574
- return false;
575
- }
576
-
577
- // Regenerate HTML to bake in current state
578
- await regenerateHtml(slug);
579
-
580
- // Start server
581
- const { port, close } = await startServer(slug, planDir);
582
-
583
- const url = `http://localhost:${port}/${slug}/`;
584
- console.log(` ✓ Opening artifact "${slug}" at ${url}`);
585
- openBrowser(url);
586
-
587
- // Keep process alive until Ctrl-C
588
- await waitForSignal(close);
589
-
590
- return true;
591
- }
592
-
593
- // ─── list flow ───────────────────────────────────────────────────────────────
594
-
595
- async function listPlans() {
596
- if (!existsSync(PLANS_DIR)) {
597
- console.log(' No artifacts found. Run `bizar artifact new <slug>` to create one.');
598
- return true;
599
- }
600
-
601
- const dirs = readdirSync(PLANS_DIR).filter((d) => {
602
- return existsSync(join(PLANS_DIR, d, 'meta.json'));
603
- });
604
-
605
- if (dirs.length === 0) {
606
- console.log(' No artifacts found. Run `bizar artifact new <slug>` to create one.');
607
- return true;
608
- }
609
-
610
- // Read meta for each
611
- const artifacts = [];
612
- for (const dir of dirs) {
613
- try {
614
- const meta = JSON.parse(readFileSync(join(PLANS_DIR, dir, 'meta.json'), 'utf-8'));
615
- artifacts.push(meta);
616
- } catch {
617
- // Skip invalid meta.json
618
- }
619
- }
620
-
621
- // Sort by lastEdited newest first
622
- artifacts.sort((a, b) => new Date(b.lastEdited) - new Date(a.lastEdited));
623
-
624
- // Print table
625
- console.log(' Slug Title Status Last Edited Author');
626
- console.log(' ────────────── ────────────────────── ──────── ──────────────────────── ───────────────');
627
-
628
- for (const artifact of artifacts) {
629
- const slug = (artifact.slug || '').padEnd(15);
630
- const title = (artifact.title || '').substring(0, 23).padEnd(23);
631
- const status = (artifact.status || 'draft').padEnd(9);
632
- const lastEdited = (artifact.lastEdited || '').substring(0, 27).padEnd(27);
633
- const author = (artifact.author || 'unknown').substring(0, 20);
634
- console.log(` ${slug} ${title} ${status} ${lastEdited} ${author}`);
635
- }
636
- console.log();
637
-
638
- return true;
639
- }
640
-
641
- // ─── delete <slug> flow ─────────────────────────────────────────────────────
642
-
643
- async function deleteArtifact(slug) {
644
- if (!validateSlug(slug)) return false;
645
-
646
- const planDir = join(PLANS_DIR, slug);
647
- if (!existsSync(planDir)) {
648
- console.error(` ✗ Artifact "${slug}" not found.`);
649
- return false;
650
- }
651
-
652
- let title = slug;
653
- try {
654
- const meta = JSON.parse(readFileSync(join(planDir, 'meta.json'), 'utf-8'));
655
- title = meta.title || slug;
656
- } catch { /* use slug as fallback */ }
657
-
658
- // Read confirm from stdin
659
- const answer = await question(` Delete artifact "${title}" (slug: ${slug})? This is irreversible. [y/N] `);
660
-
661
- if (answer.trim().toLowerCase() === 'y') {
662
- rmSync(planDir, { recursive: true });
663
- console.log(` ✓ Deleted artifact "${slug}".`);
664
- return true;
665
- } else {
666
- console.log(' Cancelled.');
667
- return true;
668
- }
669
- }
670
-
671
- // ─── export <slug> flow ──────────────────────────────────────────────────────
672
-
673
- async function exportArtifact(slug) {
674
- if (!validateSlug(slug)) return false;
675
-
676
- const planDir = join(PLANS_DIR, slug);
677
- if (!existsSync(planDir)) {
678
- console.error(` ✗ Artifact "${slug}" not found.`);
679
- return false;
680
- }
681
-
682
- // Prefer the v2 canvas-derived markdown when artifact.json exists. Fall back
683
- // to the raw mdx for v1 artifacts.
684
- const canvasFile = join(planDir, 'artifact.json');
685
- if (existsSync(canvasFile)) {
686
- try {
687
- const canvas = JSON.parse(readFileSync(canvasFile, 'utf-8'));
688
- process.stdout.write(canvasToMarkdown(canvas));
689
- return true;
690
- } catch {
691
- // fall through to mdx export
692
- }
693
- }
694
-
695
- const planFile = join(planDir, 'artifact.mdx');
696
- if (!existsSync(planFile)) {
697
- console.error(` ✗ Artifact "${slug}" not found.`);
698
- return false;
699
- }
700
-
701
- const content = readFileSync(planFile, 'utf-8');
702
- process.stdout.write(content);
703
- return true;
704
- }
705
-
706
- // ─── help ───────────────────────────────────────────────────────────────────
707
-
708
- function showHelp() {
709
- console.log(`
710
- bizar artifact <subcommand> [options]
711
-
712
- Subcommands:
713
- new <slug> [--template <name>] Create a new artifact (default: blank template)
714
- open <slug> Open an existing artifact in the browser
715
- list List all artifacts
716
- delete <slug> Delete a artifact (with confirmation)
717
- export <slug> Export artifact.mdx to stdout
718
- templates List available artifact templates
719
- template save <name> <artifact-slug> Save a artifact as a library template
720
- template delete <name> Delete a user-added library template
721
- help Show this help
722
-
723
- Plans are stored in artifacts/<slug>/ as:
724
- - artifact.mdx source content (in git)
725
- - artifact.html viewer/editor (gitignored)
726
- - comments.json comments array (gitignored)
727
- - meta.json metadata (in git)
728
-
729
- Built-in templates:
730
- blank Empty starter (the v1 default)
731
- feature-design For designing a new feature
732
- bug-investigation For investigating a bug
733
- decision-record Architecture Decision Record (ADR)
734
-
735
- Examples:
736
- bizar artifact new my-feature
737
- bizar artifact new auth-v2 --template feature-design
738
- bizar artifact new oops --template bug-investigation
739
- bizar artifact templates
740
- bizar artifact open my-feature
741
- bizar artifact list
742
- bizar artifact export my-feature > my-feature.mdx
743
- `);
744
- }
745
-
746
- // ─── HTML fragment helpers (for htmx) ────────────────────────────────────────
747
-
748
- /** Minimal HTML escaper — same rules as the client-side renderer. */
749
- function escapeHtml(s) {
750
- return String(s)
751
- .replace(/&/g, '&amp;')
752
- .replace(/</g, '&lt;')
753
- .replace(/>/g, '&gt;')
754
- .replace(/"/g, '&quot;')
755
- .replace(/'/g, '&#39;');
756
- }
757
-
758
- function formatDate(iso) {
759
- if (!iso) return '';
760
- const d = new Date(iso);
761
- if (isNaN(d.getTime())) return String(iso);
762
- return d.toLocaleString();
763
- }
764
-
765
- /**
766
- * Content negotiation: an htmx request prefers HTML fragments. JSON requests
767
- * (explicit Accept: application/json, or no Accept header at all) get JSON
768
- * for backwards compatibility with the AI tool, tests, and CLI scripts.
769
- *
770
- * Detection order:
771
- * - If `HX-Request: true` is present → htmx (HTML)
772
- * - If Accept header includes text/html (and not application/json) → htmx
773
- * - Otherwise → JSON
774
- */
775
- function isHtmxRequest(req) {
776
- if ((req.headers['hx-request'] || '').toLowerCase() === 'true') return true;
777
- const accept = (req.headers['accept'] || '').toLowerCase();
778
- if (!accept) return false;
779
- if (accept.includes('application/json') && !accept.includes('text/html')) return false;
780
- if (accept.includes('text/html')) return true;
781
- return false;
782
- }
783
-
784
- /**
785
- * htmx form-encodes nested objects via JSON.stringify (see htmx 2.x `qn`
786
- * function). When the body is form-encoded with nested-stringified values,
787
- * walk the top-level keys and JSON-parse any value that looks like JSON
788
- * (starts with `[` or `{`). This lets htmx POST/PUT complex state (the
789
- * full canvas) without needing the `json-enc` extension.
790
- */
791
- function decodeHtmxFormBody(data) {
792
- if (!data || typeof data !== 'object') return data;
793
- for (const k of Object.keys(data)) {
794
- const v = data[k];
795
- if (typeof v === 'string' && v.length > 0) {
796
- const c = v.charAt(0);
797
- if (c === '[' || c === '{') {
798
- try { data[k] = JSON.parse(v); } catch { /* keep as string */ }
799
- }
800
- }
801
- }
802
- return data;
803
- }
804
-
805
- /** Short label for the element type, matches the client-side typeLabel(). */
806
- function elementTypeBadge(type) {
807
- const t = (type || '').toLowerCase();
808
- if (t === 'ui-mockup') return 'UI';
809
- if (t === 'text') return 'TXT';
810
- if (t === 'image') return 'IMG';
811
- if (t === 'code') return 'CODE';
812
- if (t === 'diagram') return 'DIAG';
813
- return (type || '').toUpperCase();
814
- }
815
-
816
- /** Render the inner body of an element (text/image/code/diagram/ui-mockup). */
817
- function renderElementBody(e) {
818
- if (e.type === 'text') {
819
- return '<pre>' + escapeHtml(e.content || '') + '</pre>';
820
- }
821
- if (e.type === 'image') {
822
- if (e.content) {
823
- return '<img src="' + escapeHtml(e.content) + '" alt="' + escapeHtml(e.title || 'image') + '">';
824
- }
825
- return '<div class="muted">(no image URL set — double-click to edit)</div>';
826
- }
827
- if (e.type === 'code') {
828
- const lang = e.language ? ' class="language-' + escapeHtml(e.language) + '"' : '';
829
- return '<pre><code' + lang + '>' + escapeHtml(e.content || '') + '</code></pre>';
830
- }
831
- if (e.type === 'diagram') {
832
- return '<pre class="mermaid">' + escapeHtml(e.content || '') + '</pre>';
833
- }
834
- if (e.type === 'ui-mockup') {
835
- const comp = (e.component || '').toLowerCase();
836
- if (comp === 'button') {
837
- return '<button class="ui-mockup-button">' + escapeHtml(e.label || 'Button') + '</button>';
838
- }
839
- if (comp === 'input') {
840
- const ph = escapeHtml(e.placeholder || '');
841
- const val = escapeHtml(e.value || '');
842
- return '<input class="ui-mockup-input" type="text" placeholder="' + ph + '" value="' + val + '">';
843
- }
844
- if (comp === 'card') {
845
- const title = escapeHtml(e.title || 'Card');
846
- const body = escapeHtml(e.body || '');
847
- return '<div class="ui-mockup-card"><h4>' + title + '</h4><p>' + body + '</p></div>';
848
- }
849
- return '<div>Unknown ui-mockup component: ' + escapeHtml(comp) + '</div>';
850
- }
851
- return '<pre>' + escapeHtml(e.content || '') + '</pre>';
852
- }
853
-
854
- /**
855
- * Render a v2 canvas element as an HTML fragment. The fragment is meant to
856
- * be appended to `#elements-layer` by htmx; its data attributes carry the
857
- * full state needed by the client-side controller to re-attach handlers
858
- * via event delegation.
859
- */
860
- function renderElementHTML(e) {
861
- if (!e || !e.id) return '';
862
- const id = escapeHtml(e.id);
863
- const type = escapeHtml(e.type || '');
864
- const title = escapeHtml(e.title || '');
865
- const x = typeof e.x === 'number' ? e.x : 0;
866
- const y = typeof e.y === 'number' ? e.y : 0;
867
- const w = typeof e.width === 'number' ? e.width : 240;
868
- const h = typeof e.height === 'number' ? e.height : 160;
869
- const badge = escapeHtml(elementTypeBadge(e.type));
870
- return '<div class="element"'
871
- + ' data-element-id="' + id + '"'
872
- + ' data-element-type="' + type + '"'
873
- + ' style="left:' + x + 'px;top:' + y + 'px;width:' + w + 'px;height:' + h + 'px">'
874
- + '<div class="element-header">'
875
- + '<span class="type-badge">' + badge + '</span>'
876
- + '<span class="title">' + title + '</span>'
877
- + '<div class="actions">'
878
- + '<button type="button" title="Edit content" data-action="edit-element">✎</button>'
879
- + '<button type="button" title="Delete element" data-action="delete-element">🗑</button>'
880
- + '</div>'
881
- + '</div>'
882
- + '<div class="element-body">' + renderElementBody(e) + '</div>'
883
- + '<div class="resize-handle" title="Resize"></div>'
884
- + '</div>';
885
- }
886
-
887
- /**
888
- * Render a v2 canvas connection as an SVG fragment. The fragment contains
889
- * the hit-path (transparent, wide stroke for easier clicking) and the
890
- * visible line/path with the connection arrow marker.
891
- */
892
- function renderConnectionHTML(conn) {
893
- if (!conn || !conn.id) return '';
894
- // The server doesn't have the element geometry at this point — the client
895
- // knows the endpoints. We emit the connection with placeholder coords
896
- // and let the client reconcile via a `data-connection` attribute. The
897
- // client-side rerender reads the data and draws the real line. We mark
898
- // it with a class so the client can find and replace it.
899
- return '<g class="connection" data-connection-id="' + escapeHtml(conn.id) + '"'
900
- + ' data-from="' + escapeHtml(conn.from) + '"'
901
- + ' data-to="' + escapeHtml(conn.to) + '"'
902
- + ' data-type="' + escapeHtml(conn.type || 'arrow') + '"'
903
- + (conn.label ? ' data-label="' + escapeHtml(conn.label) + '"' : '')
904
- + '></g>';
905
- }
906
-
907
- /**
908
- * Render a v2 canvas comment pin as an HTML fragment. The pin's number
909
- * is its 1-based index in chronological order. We compute that server-side
910
- * by reading the existing canvas (passed in via the caller).
911
- */
912
- function renderCommentPinHTML(c, indexHint) {
913
- if (!c || !c.id) return '';
914
- const id = escapeHtml(c.id);
915
- const x = typeof c.x === 'number' ? c.x : 0;
916
- const y = typeof c.y === 'number' ? c.y : 0;
917
- const label = typeof indexHint === 'number' ? (indexHint + 1) : '?';
918
- const tip = escapeHtml((c.text || '').slice(0, 80));
919
- return '<div class="comment-pin"'
920
- + ' data-comment-id="' + id + '"'
921
- + ' title="' + tip + '"'
922
- + ' style="left:' + x + 'px;top:' + y + 'px">' + label + '</div>';
923
- }
924
-
925
- /** Render a single reply as an HTML fragment (used in the side panel). */
926
- function renderReplyHTML(r) {
927
- if (!r) return '';
928
- const author = escapeHtml(r.author || 'ai');
929
- const created = escapeHtml(formatDate(r.created));
930
- const text = escapeHtml(r.text || '');
931
- const rid = r.id ? ' id="reply-' + escapeHtml(r.id) + '"' : '';
932
- return '<li class="reply"' + rid + '>'
933
- + '<div class="reply-meta">' + author + ' · ' + created + '</div>'
934
- + '<div class="reply-text">' + text + '</div>'
935
- + '</li>';
936
- }
937
-
938
- /**
939
- * Render the entire comment (header + thread) as a fragment for the side
940
- * panel. Used by PUT /api/<slug>/comments/<id> when a reply is added —
941
- * htmx replaces the panel content with the latest thread state.
942
- */
943
- function renderCommentThreadHTML(c) {
944
- if (!c) return '';
945
- const author = escapeHtml(c.author || 'Anonymous');
946
- const created = escapeHtml(formatDate(c.created));
947
- const text = escapeHtml(c.text || '');
948
- const thread = Array.isArray(c.thread) ? c.thread : [];
949
- const replies = thread.map(renderReplyHTML).join('');
950
- return '<li class="comment" id="comment-' + escapeHtml(c.id) + '">'
951
- + '<div class="comment-meta">' + author + ' · ' + created + '</div>'
952
- + '<div class="comment-text">' + text + '</div>'
953
- + replies
954
- + '</li>';
955
- }
956
-
957
- /** Render a single comment as an <li> fragment (no wrapper). */
958
- function renderCommentLi(c) {
959
- return '<li class="comment" id="comment-' + escapeHtml(c.id) + '">'
960
- + '<div class="comment-meta">' + escapeHtml(c.author || 'Anonymous')
961
- + ' · ' + escapeHtml(formatDate(c.timestamp || c.created)) + '</div>'
962
- + '<div class="comment-text">' + escapeHtml(c.text || '') + '</div>'
963
- + '</li>';
964
- }
965
-
966
- /** Render a comments list as <li> elements. If empty, returns the empty marker. */
967
- function renderCommentListHtml(comments, sectionId) {
968
- const items = comments
969
- .filter((c) => !sectionId || c.sectionId === sectionId)
970
- .sort((a, b) => String(a.timestamp || a.created || '').localeCompare(String(b.timestamp || b.created || '')));
971
- if (items.length === 0) {
972
- return '<li class="empty" data-empty>No comments yet — be the first.</li>';
973
- }
974
- return items.map(renderCommentLi).join('');
975
- }
976
-
977
- /** Render just the count badge for a section, used to update the comment button. */
978
- function renderCommentCountHtml(count) {
979
- return '<span class="count">' + count + '</span>';
980
- }
981
-
982
- /** Parse an HTTP request body. Supports:
983
- * - application/x-www-form-urlencoded (htmx default for <form>)
984
- * - application/json (legacy / direct API)
985
- * - text/plain (raw MDX for artifact save)
986
- * - anything else: returns raw string
987
- */
988
- function readRequestBody(req) {
989
- return new Promise((resolve, reject) => {
990
- let body = '';
991
- let bodySize = 0;
992
- let tooLarge = false;
993
- req.on('data', (chunk) => {
994
- if (tooLarge) return;
995
- bodySize += chunk.length;
996
- if (bodySize > MAX_REQUEST_BODY_BYTES) {
997
- tooLarge = true;
998
- reject(new Error(`Request body too large (max ${MAX_REQUEST_BODY_BYTES} bytes)`));
999
- return;
1000
- }
1001
- body += chunk;
1002
- });
1003
- req.on('end', () => {
1004
- if (tooLarge) return;
1005
- const ct = (req.headers['content-type'] || '').toLowerCase();
1006
- try {
1007
- if (ct.includes('application/x-www-form-urlencoded')) {
1008
- const params = new URLSearchParams(body);
1009
- const obj = {};
1010
- for (const [k, v] of params) obj[k] = v;
1011
- resolve({ kind: 'form', data: obj, raw: body });
1012
- } else if (ct.includes('application/json')) {
1013
- resolve({ kind: 'json', data: body ? JSON.parse(body) : {}, raw: body });
1014
- } else {
1015
- resolve({ kind: 'raw', data: body, raw: body });
1016
- }
1017
- } catch (err) {
1018
- reject(err);
1019
- }
1020
- });
1021
- req.on('error', reject);
1022
- });
1023
- }
1024
-
1025
- /** Update the meta.json's lastEdited timestamp; ignores errors so a single bad
1026
- * meta doesn't block the rest of the save. */
1027
- function bumpLastEdited(planDir) {
1028
- const metaPath = join(planDir, 'meta.json');
1029
- if (!existsSync(metaPath)) return;
1030
- try {
1031
- const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
1032
- meta.lastEdited = new Date().toISOString();
1033
- atomicWriteJson(metaPath, meta);
1034
- } catch { /* swallow */ }
1035
- }
1036
-
1037
- // ─── Local HTTP server ───────────────────────────────────────────────────────
1038
-
1039
- /**
1040
- * Starts a local HTTP server in the SAME process as the CLI.
1041
- * Tries port 4321 first, falls back to 4322, 4323, etc. (max 10 attempts).
1042
- * Returns { port, close } where close() stops the server.
1043
- */
1044
- export async function startServer(slug, planDir, startPort = 4321) {
1045
- let port = startPort;
1046
- let maxAttempts = 10;
1047
- let server;
1048
- let closeFn;
1049
-
1050
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
1051
- port = startPort + attempt;
1052
- try {
1053
- server = await new Promise((resolve, reject) => {
1054
- const srv = createServer((req, res) => {
1055
- // Fire-and-forget: handleRequest is async because PUT/POST bodies
1056
- // are streamed, but Node's HTTP server is happy to wait for res.end().
1057
- handleRequest(req, res, slug, planDir, port).catch((err) => {
1058
- console.error(`[${new Date().toISOString()}] ERROR: ${err.stack || err.message}`);
1059
- if (!res.headersSent) {
1060
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1061
- }
1062
- try { res.end('Server error: ' + (err.message || String(err))); } catch { /* already closed */ }
1063
- });
1064
- });
1065
- srv.on('error', reject);
1066
- srv.listen(port, '127.0.0.1', () => resolve(srv));
1067
- });
1068
- break;
1069
- } catch (err) {
1070
- if (err.code === 'EADDRINUSE' && attempt < maxAttempts - 1) {
1071
- continue; // try next port
1072
- }
1073
- throw err;
1074
- }
1075
- }
1076
-
1077
- if (!server) throw new Error(`Could not find available port in range ${startPort}–${startPort + maxAttempts - 1}`);
1078
-
1079
- const actualPort = server.address().port;
1080
-
1081
- const close = () =>
1082
- new Promise((resolve) => {
1083
- server.close(() => resolve());
1084
- });
1085
-
1086
- return { port: actualPort, close };
1087
- }
1088
-
1089
- /**
1090
- * Handle incoming HTTP requests.
1091
- * @param {import('node:http').IncomingMessage} req
1092
- * @param {import('node:http').ServerResponse} res
1093
- */
1094
- async function handleRequest(req, res, slug, planDir, serverPort) {
1095
- const now = new Date().toISOString();
1096
- // Use path-only URL parsing to avoid host-header injection
1097
- const requestUrl = typeof req.url === 'string' ? req.url : '/';
1098
- const pathname = requestUrl.split('?')[0].split('#')[0];
1099
-
1100
- // Log request to stderr
1101
- console.error(`[${now}] ${req.method} ${pathname}`);
1102
-
1103
- // CORS — allow from any origin (user may be on a different port)
1104
- res.setHeader('Access-Control-Allow-Origin', '*');
1105
- res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, OPTIONS');
1106
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1107
-
1108
- if (req.method === 'OPTIONS') {
1109
- res.writeHead(204);
1110
- res.end();
1111
- return;
1112
- }
1113
-
1114
- try {
1115
- if (pathname === `/${slug}/` || pathname === `/${slug}` || pathname === '/') {
1116
- // Serve artifact.html
1117
- const htmlPath = join(planDir, 'artifact.html');
1118
- if (!existsSync(htmlPath)) {
1119
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1120
- res.end('artifact.html not found. Run `bizar artifact new ${slug}` first.');
1121
- return;
1122
- }
1123
- const html = readFileSync(htmlPath, 'utf-8');
1124
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1125
- res.end(html);
1126
- return;
1127
- }
1128
-
1129
- // ── Self-hosted htmx — served from templates/artifact/htmx.min.js ────────────
1130
- if (pathname === '/htmx.min.js' && req.method === 'GET') {
1131
- const htmxPath = join(TEMPLATES_DIR, 'htmx.min.js');
1132
- if (!existsSync(htmxPath)) {
1133
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1134
- res.end('htmx.min.js not found in templates/artifact/');
1135
- return;
1136
- }
1137
- const buf = readFileSync(htmxPath);
1138
- res.writeHead(200, {
1139
- 'Content-Type': 'application/javascript; charset=utf-8',
1140
- 'Cache-Control': 'public, max-age=3600',
1141
- });
1142
- res.end(buf);
1143
- return;
1144
- }
1145
-
1146
- // ── New RESTful, slug-scoped routes (preferred for htmx) ─────────────────
1147
- // GET /api/<slug>/artifact → MDX text/plain
1148
- // PUT /api/<slug>/artifact → save MDX, returns empty 200
1149
- // GET /api/<slug>/comments → JSON (default) or HTML (?format=html or ?sectionId=)
1150
- // POST /api/<slug>/comments → add a comment, returns the new <li> HTML
1151
- // PUT /api/<slug>/comments → replace the whole comments array (JSON)
1152
- // GET /api/<slug>/count → comment count for a section (?sectionId=)
1153
- // We validate the slug in the URL against the bound slug so the server can't
1154
- // be tricked into serving data for a different artifact.
1155
-
1156
- if (pathname.startsWith('/api/') && pathname.split('/').length >= 4) {
1157
- const parts = pathname.split('/').filter(Boolean); // ['api', '<urlSlug>', '<resource>']
1158
- const urlSlug = parts[1];
1159
- const resource = parts[2];
1160
-
1161
- if (urlSlug !== slug) {
1162
- res.writeHead(403, { 'Content-Type': 'text/plain' });
1163
- res.end(`Forbidden: this server is bound to artifact "${slug}"`);
1164
- return;
1165
- }
1166
-
1167
- // ── v2 canvas endpoints (preferred) ─────────────────────────────────
1168
- // GET /api/<slug>/canvas → full canvas JSON
1169
- // PUT /api/<slug>/canvas → save full canvas JSON
1170
- // GET /api/<slug>/markdown-export → derived markdown
1171
- // GET /api/<slug>/elements → just the elements array
1172
- // POST /api/<slug>/elements → add element
1173
- // PUT /api/<slug>/elements/<id> → update element
1174
- // DELETE /api/<slug>/elements/<id> → remove element + cleanup
1175
- // GET /api/<slug>/connections → connections array
1176
- // POST /api/<slug>/connections → add connection
1177
- // DELETE /api/<slug>/connections/<id> → remove connection
1178
- // GET /api/<slug>/comments?elementId= → canvas comments
1179
- // POST /api/<slug>/comments → add canvas comment (JSON body)
1180
- // PUT /api/<slug>/comments/<id> → update comment (add reply)
1181
- // DELETE /api/<slug>/comments/<id> → remove comment
1182
- //
1183
- // The v2 endpoints and the v1 endpoints share the URL space
1184
- // (e.g. /api/<slug>/comments is both v1 and v2). To discriminate
1185
- // we look at the request body / query for v2-specific fields:
1186
- // - v2 GET: ?elementId=... (v1 uses ?sectionId=... or ?format=html)
1187
- // - v2 POST: body has x/y/elementId fields (v1 has sectionId only)
1188
- // - v2 PUT: body has reply field (v1 has a flat array)
1189
- // If the discriminator says "not v2", the v2 handler falls through
1190
- // (returns without writing a response) and the v1 handler picks it
1191
- // up below. This keeps both API surfaces working.
1192
- const subParts = parts.slice(2);
1193
- const subPath = subParts.join('/');
1194
-
1195
- // GET /api/<slug>/canvas
1196
- if (subPath === 'canvas' && req.method === 'GET') {
1197
- const meta = readPlanMeta(planDir);
1198
- const title = (meta && meta.title) || slug;
1199
- const canvas = loadOrMigrateCanvas(planDir, title);
1200
- res.writeHead(200, { 'Content-Type': 'application/json' });
1201
- res.end(JSON.stringify(canvas));
1202
- return;
1203
- }
1204
-
1205
- // PUT /api/<slug>/canvas
1206
- if (subPath === 'canvas' && req.method === 'PUT') {
1207
- let parsed;
1208
- try {
1209
- parsed = await readRequestBody(req);
1210
- } catch (err) {
1211
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1212
- res.end('Invalid JSON body: ' + (err && err.message ? err.message : String(err)));
1213
- return;
1214
- }
1215
- let next;
1216
- try {
1217
- if (parsed.kind === 'raw' && typeof parsed.data === 'string') {
1218
- next = JSON.parse(parsed.data);
1219
- } else if (parsed.kind === 'form') {
1220
- // htmx form-encodes nested values as JSON strings; decode them.
1221
- next = decodeHtmxFormBody(parsed.data);
1222
- } else {
1223
- next = parsed.data;
1224
- }
1225
- } catch (err) {
1226
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1227
- res.end('Invalid JSON: ' + (err && err.message ? err.message : String(err)));
1228
- return;
1229
- }
1230
- if (!next || typeof next !== 'object') {
1231
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1232
- res.end('Expected a JSON object');
1233
- return;
1234
- }
1235
- if (!Array.isArray(next.elements)) next.elements = [];
1236
- if (!Array.isArray(next.connections)) next.connections = [];
1237
- if (!Array.isArray(next.comments)) next.comments = [];
1238
- if (!next.viewport || typeof next.viewport !== 'object') {
1239
- next.viewport = { x: 0, y: 0, zoom: 1 };
1240
- }
1241
- if (typeof next.title !== 'string') next.title = 'Untitled artifact';
1242
- next.schemaVersion = CANVAS_SCHEMA_VERSION;
1243
- writeCanvasFile(planDir, next);
1244
- bumpLastEdited(planDir);
1245
- if (isHtmxRequest(req)) {
1246
- // Autosave via htmx — return the new save-status badge HTML so
1247
- // hx-target="#save-status" + hx-swap="innerHTML" updates the UI.
1248
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1249
- res.end('<span class="save-status saved">Saved</span>');
1250
- } else {
1251
- res.writeHead(200, { 'Content-Type': 'application/json' });
1252
- res.end(JSON.stringify({ ok: true }));
1253
- }
1254
- return;
1255
- }
1256
-
1257
- // GET /api/<slug>/markdown-export → derived markdown from canvas
1258
- if (subPath === 'markdown-export' && req.method === 'GET') {
1259
- const meta = readPlanMeta(planDir);
1260
- const title = (meta && meta.title) || slug;
1261
- const canvas = loadOrMigrateCanvas(planDir, title);
1262
- const md = canvasToMarkdown(canvas);
1263
- res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
1264
- res.end(md);
1265
- return;
1266
- }
1267
-
1268
- // GET /api/<slug>/elements
1269
- if (subPath === 'elements' && req.method === 'GET') {
1270
- const meta = readPlanMeta(planDir);
1271
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1272
- res.writeHead(200, { 'Content-Type': 'application/json' });
1273
- res.end(JSON.stringify(canvas.elements));
1274
- return;
1275
- }
1276
-
1277
- // POST /api/<slug>/elements
1278
- if (subPath === 'elements' && req.method === 'POST') {
1279
- const parsed = await readRequestBody(req);
1280
- const body = parsed.data;
1281
- if (!body || typeof body !== 'object') {
1282
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1283
- res.end('Expected a JSON object');
1284
- return;
1285
- }
1286
- const meta = readPlanMeta(planDir);
1287
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1288
- const el = {
1289
- id: makeElementId(),
1290
- type: typeof body.type === 'string' ? body.type : 'text',
1291
- x: typeof body.x === 'number' ? body.x : 100,
1292
- y: typeof body.y === 'number' ? body.y : 100,
1293
- width: typeof body.width === 'number' ? body.width : 240,
1294
- height: typeof body.height === 'number' ? body.height : 160,
1295
- };
1296
- if (typeof body.title === 'string') el.title = body.title;
1297
- if (typeof body.content === 'string') el.content = body.content;
1298
- if (typeof body.language === 'string') el.language = body.language;
1299
- if (typeof body.component === 'string') el.component = body.component;
1300
- if (typeof body.label === 'string') el.label = body.label;
1301
- if (typeof body.placeholder === 'string') el.placeholder = body.placeholder;
1302
- if (typeof body.value === 'string') el.value = body.value;
1303
- if (typeof body.body === 'string') el.body = body.body;
1304
- canvas.elements.push(el);
1305
- writeCanvasFile(planDir, canvas);
1306
- bumpLastEdited(planDir);
1307
- if (isHtmxRequest(req)) {
1308
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1309
- res.end(renderElementHTML(el));
1310
- } else {
1311
- res.writeHead(200, { 'Content-Type': 'application/json' });
1312
- res.end(JSON.stringify(el));
1313
- }
1314
- return;
1315
- }
1316
-
1317
- // /api/<slug>/elements/<id> (PUT, DELETE)
1318
- const elementMatch = subPath.match(/^elements\/([^\/]+)$/);
1319
- if (elementMatch) {
1320
- const elementId = elementMatch[1];
1321
- const meta = readPlanMeta(planDir);
1322
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1323
- const idx = canvas.elements.findIndex(function (e) { return e.id === elementId; });
1324
-
1325
- if (req.method === 'PUT') {
1326
- if (idx < 0) {
1327
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1328
- res.end('Element not found');
1329
- return;
1330
- }
1331
- const parsed = await readRequestBody(req);
1332
- const body = parsed.data;
1333
- if (!body || typeof body !== 'object') {
1334
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1335
- res.end('Expected a JSON object');
1336
- return;
1337
- }
1338
- const cur = canvas.elements[idx];
1339
- const allowedKeys = ['type', 'x', 'y', 'width', 'height', 'title', 'content',
1340
- 'language', 'component', 'label', 'placeholder', 'value', 'body'];
1341
- for (let i = 0; i < allowedKeys.length; i++) {
1342
- const k = allowedKeys[i];
1343
- if (k in body) cur[k] = body[k];
1344
- }
1345
- writeCanvasFile(planDir, canvas);
1346
- bumpLastEdited(planDir);
1347
- if (isHtmxRequest(req)) {
1348
- // htmx will swap this with the existing #elements-layer child
1349
- // (hx-swap="outerHTML" is used by the caller for drag-saves)
1350
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1351
- res.end(renderElementHTML(cur));
1352
- } else {
1353
- res.writeHead(200, { 'Content-Type': 'application/json' });
1354
- res.end(JSON.stringify(cur));
1355
- }
1356
- return;
1357
- }
1358
-
1359
- if (req.method === 'DELETE') {
1360
- if (idx < 0) {
1361
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1362
- res.end('Element not found');
1363
- return;
1364
- }
1365
- canvas.elements.splice(idx, 1);
1366
- canvas.connections = canvas.connections.filter(function (c) {
1367
- return c.from !== elementId && c.to !== elementId;
1368
- });
1369
- for (let i = 0; i < canvas.comments.length; i++) {
1370
- if (canvas.comments[i].elementId === elementId) {
1371
- canvas.comments[i].elementId = null;
1372
- }
1373
- }
1374
- writeCanvasFile(planDir, canvas);
1375
- bumpLastEdited(planDir);
1376
- if (isHtmxRequest(req)) {
1377
- // Empty 200 — htmx removes the DOM element on 200 with no body
1378
- // (per the responseHandling config: "{code:"[23]..",swap:true}").
1379
- // We return an empty string to keep the swap a no-op for callers
1380
- // that don't specify hx-swap="delete".
1381
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1382
- res.end('');
1383
- } else {
1384
- res.writeHead(200, { 'Content-Type': 'application/json' });
1385
- res.end(JSON.stringify({ ok: true, removed: elementId }));
1386
- }
1387
- return;
1388
- }
1389
- }
1390
-
1391
- // GET /api/<slug>/connections
1392
- if (subPath === 'connections' && req.method === 'GET') {
1393
- const meta = readPlanMeta(planDir);
1394
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1395
- res.writeHead(200, { 'Content-Type': 'application/json' });
1396
- res.end(JSON.stringify(canvas.connections));
1397
- return;
1398
- }
1399
-
1400
- // POST /api/<slug>/connections
1401
- if (subPath === 'connections' && req.method === 'POST') {
1402
- const parsed = await readRequestBody(req);
1403
- const body = parsed.data;
1404
- if (!body || typeof body !== 'object') {
1405
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1406
- res.end('Expected a JSON object');
1407
- return;
1408
- }
1409
- if (typeof body.from !== 'string' || typeof body.to !== 'string') {
1410
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1411
- res.end('Missing from or to');
1412
- return;
1413
- }
1414
- const meta = readPlanMeta(planDir);
1415
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1416
- const elementIds = new Set(canvas.elements.map(function (e) { return e.id; }));
1417
- if (!elementIds.has(body.from) || !elementIds.has(body.to)) {
1418
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1419
- res.end('from/to element not found');
1420
- return;
1421
- }
1422
- const conn = {
1423
- id: makeConnectionId(),
1424
- from: body.from,
1425
- to: body.to,
1426
- type: ['arrow', 'line', 'dependency'].indexOf(body.type) >= 0 ? body.type : 'arrow',
1427
- };
1428
- if (typeof body.label === 'string') conn.label = body.label;
1429
- canvas.connections.push(conn);
1430
- writeCanvasFile(planDir, canvas);
1431
- bumpLastEdited(planDir);
1432
- if (isHtmxRequest(req)) {
1433
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1434
- res.end(renderConnectionHTML(conn));
1435
- } else {
1436
- res.writeHead(200, { 'Content-Type': 'application/json' });
1437
- res.end(JSON.stringify(conn));
1438
- }
1439
- return;
1440
- }
1441
-
1442
- // DELETE /api/<slug>/connections/<id>
1443
- const connectionMatch = subPath.match(/^connections\/([^\/]+)$/);
1444
- if (connectionMatch && req.method === 'DELETE') {
1445
- const connectionId = connectionMatch[1];
1446
- const meta = readPlanMeta(planDir);
1447
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1448
- const before = canvas.connections.length;
1449
- canvas.connections = canvas.connections.filter(function (c) { return c.id !== connectionId; });
1450
- const removed = before !== canvas.connections.length;
1451
- writeCanvasFile(planDir, canvas);
1452
- bumpLastEdited(planDir);
1453
- if (!removed) {
1454
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1455
- res.end('Connection not found');
1456
- return;
1457
- }
1458
- if (isHtmxRequest(req)) {
1459
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1460
- res.end('');
1461
- } else {
1462
- res.writeHead(200, { 'Content-Type': 'application/json' });
1463
- res.end(JSON.stringify({ ok: true, removed: connectionId }));
1464
- }
1465
- return;
1466
- }
1467
-
1468
- // GET /api/<slug>/comments?elementId=... → canvas comments array
1469
- // The v2 viewer always passes `?elementId=...` to filter (or
1470
- // ?elementId= for "all"). The presence of the elementId KEY
1471
- // (even with empty value) signals "this is a v2 request". When
1472
- // the elementId key is absent, the request falls through to the
1473
- // v1 handler which reads from comments.json. This preserves
1474
- // backwards compat with the v1 viewer.
1475
- if (subPath === 'comments' && req.method === 'GET') {
1476
- const query = requestUrl.split('?')[1] || '';
1477
- const params = new URLSearchParams(query);
1478
- const format = (params.get('format') || '').toLowerCase();
1479
- const sectionId = params.get('sectionId');
1480
- const hasElementId = params.has('elementId');
1481
- const elementId = params.get('elementId') || '';
1482
- // v1 discriminator: format=html or sectionId=, OR no elementId key
1483
- if (!hasElementId && (format === 'html' || sectionId !== null)) {
1484
- // fall through to v1 handler below
1485
- } else if (!hasElementId) {
1486
- // ambiguous: fall through to v1 (back-compat for v1 viewer)
1487
- } else {
1488
- const meta = readPlanMeta(planDir);
1489
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1490
- let result = canvas.comments;
1491
- if (elementId === 'nil' || elementId === 'null') {
1492
- // canvas-pinned only
1493
- result = canvas.comments.filter(function (c) { return !c.elementId; });
1494
- } else if (elementId !== '') {
1495
- // specific element
1496
- result = canvas.comments.filter(function (c) { return c.elementId === elementId; });
1497
- }
1498
- // else: elementId === '' means "all comments"
1499
- res.writeHead(200, { 'Content-Type': 'application/json' });
1500
- res.end(JSON.stringify(result));
1501
- return;
1502
- }
1503
- }
1504
-
1505
- // POST /api/<slug>/comments
1506
- // v2 path (canvas): JSON body with text + x/y/elementId, writes to artifact.json
1507
- // v1 path (legacy): body has sectionId (form OR JSON), writes to comments.json
1508
- // v1 returns HTML <li>; v2 returns JSON.
1509
- // We dispatch on body shape: if sectionId is present, it's v1.
1510
- if (subPath === 'comments' && req.method === 'POST') {
1511
- const parsed = await readRequestBody(req);
1512
- const body = parsed.data;
1513
- if (!body || typeof body !== 'object') {
1514
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1515
- res.end('Expected a JSON or form object');
1516
- return;
1517
- }
1518
- // v1 discriminator: sectionId is a non-empty string. v2 never uses
1519
- // sectionId; it uses elementId. If a request has both, treat as v1
1520
- // (back-compat).
1521
- const isV1 = typeof body.sectionId === 'string' && body.sectionId;
1522
- if (isV1) {
1523
- const sectionId = body.sectionId;
1524
- const text = body.text;
1525
- const author = body.author;
1526
- if (!text) {
1527
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1528
- res.end('Missing text');
1529
- return;
1530
- }
1531
- const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
1532
- const newComment = {
1533
- id: makeCommentId(),
1534
- sectionId,
1535
- text,
1536
- author: author || process.env.USER || 'anonymous',
1537
- timestamp: new Date().toISOString(),
1538
- };
1539
- comments.push(newComment);
1540
- atomicWriteJson(join(planDir, 'comments.json'), comments);
1541
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1542
- res.end(renderCommentLi(newComment));
1543
- return;
1544
- }
1545
- // v2 path
1546
- if (typeof body.text !== 'string' || !body.text) {
1547
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1548
- res.end('Missing text');
1549
- return;
1550
- }
1551
- const meta = readPlanMeta(planDir);
1552
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1553
- const c = {
1554
- id: makeCommentId(),
1555
- x: typeof body.x === 'number' ? body.x : 0,
1556
- y: typeof body.y === 'number' ? body.y : 0,
1557
- elementId: typeof body.elementId === 'string' ? body.elementId : null,
1558
- author: (typeof body.author === 'string' && body.author) || process.env.USER || 'anonymous',
1559
- text: body.text,
1560
- created: new Date().toISOString(),
1561
- thread: [],
1562
- };
1563
- canvas.comments.push(c);
1564
- writeCanvasFile(planDir, canvas);
1565
- bumpLastEdited(planDir);
1566
- if (isHtmxRequest(req)) {
1567
- // htmx callers can use hx-swap="beforeend" on the pin layer (target
1568
- // #comments-layer) AND hx-swap="outerHTML" on the side panel (target
1569
- // #comment-thread) by chaining via hx-on or two-step. To keep things
1570
- // simple we return the pin fragment; the side-panel refresh is
1571
- // handled by the client-side openCommentPanel() call which is
1572
- // already triggered on add.
1573
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1574
- res.end(renderCommentPinHTML(c, canvas.comments.length - 1));
1575
- } else {
1576
- res.writeHead(200, { 'Content-Type': 'application/json' });
1577
- res.end(JSON.stringify(c));
1578
- }
1579
- return;
1580
- }
1581
-
1582
- // PUT /api/<slug>/comments/<id> → update canvas comment (add reply)
1583
- // v1 doesn't have a PUT-to-id endpoint; only v2 supports this URL.
1584
- if (subPath.match(/^comments\/[^\/]+$/) && req.method === 'PUT') {
1585
- const commentMatch = subPath.match(/^comments\/([^\/]+)$/);
1586
- const commentId = commentMatch[1];
1587
- const parsed = await readRequestBody(req);
1588
- const body = parsed.data;
1589
- if (!body || typeof body !== 'object' || Array.isArray(body)) {
1590
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1591
- res.end('Expected a JSON object');
1592
- return;
1593
- }
1594
- const meta = readPlanMeta(planDir);
1595
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1596
- const idx = canvas.comments.findIndex(function (c) { return c.id === commentId; });
1597
- if (idx < 0) {
1598
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1599
- res.end('Comment not found');
1600
- return;
1601
- }
1602
- const cur = canvas.comments[idx];
1603
- if (typeof body.text === 'string') cur.text = body.text;
1604
- if (typeof body.x === 'number') cur.x = body.x;
1605
- if (typeof body.y === 'number') cur.y = body.y;
1606
- if (typeof body.elementId === 'string' || body.elementId === null) {
1607
- cur.elementId = body.elementId;
1608
- }
1609
- if (typeof body.reply === 'string' && body.reply) {
1610
- if (!Array.isArray(cur.thread)) cur.thread = [];
1611
- cur.thread.push({
1612
- id: makeReplyId(),
1613
- author: (typeof body.replyAuthor === 'string' && body.replyAuthor) || 'ai',
1614
- text: body.reply,
1615
- created: new Date().toISOString(),
1616
- });
1617
- }
1618
- writeCanvasFile(planDir, canvas);
1619
- bumpLastEdited(planDir);
1620
- if (isHtmxRequest(req)) {
1621
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1622
- res.end(renderCommentThreadHTML(cur));
1623
- } else {
1624
- res.writeHead(200, { 'Content-Type': 'application/json' });
1625
- res.end(JSON.stringify(cur));
1626
- }
1627
- return;
1628
- }
1629
-
1630
- // DELETE /api/<slug>/comments/<id> → v2 has the same URL. The v1 routes
1631
- // don't define DELETE so the v2 handler always runs.
1632
- if (subPath.match(/^comments\/[^\/]+$/) && req.method === 'DELETE') {
1633
- const commentMatch = subPath.match(/^comments\/([^\/]+)$/);
1634
- const commentId = commentMatch[1];
1635
- const meta = readPlanMeta(planDir);
1636
- const canvas = loadOrMigrateCanvas(planDir, (meta && meta.title) || slug);
1637
- const idx = canvas.comments.findIndex(function (c) { return c.id === commentId; });
1638
- if (idx < 0) {
1639
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1640
- res.end('Comment not found');
1641
- return;
1642
- }
1643
- canvas.comments.splice(idx, 1);
1644
- writeCanvasFile(planDir, canvas);
1645
- bumpLastEdited(planDir);
1646
- if (isHtmxRequest(req)) {
1647
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1648
- res.end('');
1649
- } else {
1650
- res.writeHead(200, { 'Content-Type': 'application/json' });
1651
- res.end(JSON.stringify({ ok: true, removed: commentId }));
1652
- }
1653
- return;
1654
- }
1655
-
1656
- // ── v1 routes (legacy, kept for backwards compat) ───────────────
1657
- // GET /api/<slug>/artifact → MDX text/plain
1658
- // PUT /api/<slug>/artifact → save MDX
1659
- // GET /api/<slug>/comments → JSON (default) or HTML (?format=html)
1660
- // POST /api/<slug>/comments → add a v1 comment (form data with sectionId)
1661
- // PUT /api/<slug>/comments → replace the whole v1 array (JSON)
1662
- // GET /api/<slug>/count → comment count for a section
1663
-
1664
- // GET /api/<slug>/artifact
1665
- if (resource === 'artifact' && req.method === 'GET') {
1666
- const mdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
1667
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1668
- res.end(mdx);
1669
- return;
1670
- }
1671
-
1672
- // PUT /api/<slug>/artifact
1673
- if (resource === 'artifact' && req.method === 'PUT') {
1674
- const parsed = await readRequestBody(req);
1675
- const content = parsed.kind === 'form' ? (parsed.data.content || '') : parsed.data;
1676
- if (typeof content !== 'string') {
1677
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1678
- res.end('Expected a "content" form field or a raw text body');
1679
- return;
1680
- }
1681
- try {
1682
- atomicWriteText(join(planDir, 'artifact.mdx'), content);
1683
- bumpLastEdited(planDir);
1684
- res.writeHead(200, { 'Content-Type': 'text/plain' });
1685
- res.end('saved');
1686
- } catch (err) {
1687
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1688
- res.end('Save failed: ' + err.message);
1689
- }
1690
- return;
1691
- }
1692
-
1693
- // GET /api/<slug>/comments?format=html&sectionId=... → HTML <li> list
1694
- // GET /api/<slug>/comments → JSON array
1695
- // v1 fallback — runs only if the v2 GET handler above didn't match
1696
- // (e.g. ?format=html or ?sectionId= query params).
1697
- if (resource === 'comments' && req.method === 'GET') {
1698
- const query = requestUrl.split('?')[1] || '';
1699
- const params = new URLSearchParams(query);
1700
- const format = (params.get('format') || 'json').toLowerCase();
1701
- const sectionId = params.get('sectionId') || '';
1702
- const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
1703
-
1704
- if (format === 'html') {
1705
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1706
- res.end(renderCommentListHtml(comments, sectionId));
1707
- } else {
1708
- res.writeHead(200, { 'Content-Type': 'application/json' });
1709
- res.end(JSON.stringify(comments));
1710
- }
1711
- return;
1712
- }
1713
-
1714
- // (v1 POST /api/<slug>/comments is now handled by the merged v2/v1
1715
- // handler above, which dispatches on body shape — sectionId → v1
1716
- // writes to comments.json; otherwise v2 writes to artifact.json.)
1717
-
1718
- // PUT /api/<slug>/comments → v1: replace the whole array (JSON body)
1719
- if (resource === 'comments' && req.method === 'PUT') {
1720
- const parsed = await readRequestBody(req);
1721
- let arr;
1722
- try {
1723
- arr = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
1724
- } catch {
1725
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1726
- res.end('Invalid JSON');
1727
- return;
1728
- }
1729
- if (!Array.isArray(arr)) {
1730
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1731
- res.end('Expected a JSON array of comments');
1732
- return;
1733
- }
1734
- atomicWriteJson(join(planDir, 'comments.json'), arr);
1735
- res.writeHead(200, { 'Content-Type': 'text/plain' });
1736
- res.end('ok');
1737
- return;
1738
- }
1739
-
1740
- // GET /api/<slug>/count?sectionId=... → HTML <span class="count">N</span>
1741
- if (resource === 'count' && req.method === 'GET') {
1742
- const query = requestUrl.split('?')[1] || '';
1743
- const params = new URLSearchParams(query);
1744
- const sectionId = params.get('sectionId') || '';
1745
- const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
1746
- const n = comments.filter((c) => !sectionId || c.sectionId === sectionId).length;
1747
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1748
- res.end(renderCommentCountHtml(n));
1749
- return;
1750
- }
1751
-
1752
- // (v2 endpoints are handled above; this block is the v1 fallback for
1753
- // the /api/<slug>/comments routes that didn't match a v2 discriminator.)
1754
- }
1755
-
1756
- if (pathname === '/api/artifact' && req.method === 'GET') {
1757
- const mdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
1758
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1759
- res.end(mdx);
1760
- return;
1761
- }
1762
-
1763
- if (pathname === '/api/artifact' && req.method === 'PUT') {
1764
- let body = '';
1765
- req.on('data', (chunk) => { body += chunk; });
1766
- req.on('end', () => {
1767
- try {
1768
- atomicWriteText(join(planDir, 'artifact.mdx'), body);
1769
- // Update lastEdited in meta.json
1770
- const meta = JSON.parse(readFileSync(join(planDir, 'meta.json'), 'utf-8'));
1771
- meta.lastEdited = new Date().toISOString();
1772
- atomicWriteJson(join(planDir, 'meta.json'), meta);
1773
- res.writeHead(200, { 'Content-Type': 'application/json' });
1774
- res.end(JSON.stringify({ ok: true }));
1775
- } catch (err) {
1776
- res.writeHead(500, { 'Content-Type': 'application/json' });
1777
- res.end(JSON.stringify({ error: err.message }));
1778
- }
1779
- });
1780
- return;
1781
- }
1782
-
1783
- if (pathname === '/api/comments' && req.method === 'GET') {
1784
- const comments = readFileSync(join(planDir, 'comments.json'), 'utf-8');
1785
- res.writeHead(200, { 'Content-Type': 'application/json' });
1786
- res.end(comments);
1787
- return;
1788
- }
1789
-
1790
- if (pathname === '/api/comments' && req.method === 'PUT') {
1791
- let body = '';
1792
- req.on('data', (chunk) => { body += chunk; });
1793
- req.on('end', () => {
1794
- try {
1795
- // Validate JSON
1796
- JSON.parse(body);
1797
- atomicWriteText(join(planDir, 'comments.json'), body);
1798
- res.writeHead(200, { 'Content-Type': 'application/json' });
1799
- res.end(JSON.stringify({ ok: true }));
1800
- } catch (err) {
1801
- res.writeHead(400, { 'Content-Type': 'application/json' });
1802
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1803
- }
1804
- });
1805
- return;
1806
- }
1807
-
1808
- if (pathname === '/api/comments' && req.method === 'POST') {
1809
- let body = '';
1810
- req.on('data', (chunk) => { body += chunk; });
1811
- req.on('end', () => {
1812
- try {
1813
- const { sectionId, text, author } = JSON.parse(body);
1814
- if (!sectionId || !text) {
1815
- res.writeHead(400, { 'Content-Type': 'application/json' });
1816
- res.end(JSON.stringify({ error: 'Missing sectionId or text' }));
1817
- return;
1818
- }
1819
- const comments = JSON.parse(readFileSync(join(planDir, 'comments.json'), 'utf-8'));
1820
- comments.push({
1821
- id: Date.now().toString(),
1822
- sectionId,
1823
- text,
1824
- author: author || process.env.USER || 'anonymous',
1825
- created: new Date().toISOString(),
1826
- });
1827
- atomicWriteJson(join(planDir, 'comments.json'), comments);
1828
- res.writeHead(200, { 'Content-Type': 'application/json' });
1829
- res.end(JSON.stringify({ ok: true, comments }));
1830
- } catch (err) {
1831
- res.writeHead(400, { 'Content-Type': 'application/json' });
1832
- res.end(JSON.stringify({ error: err.message }));
1833
- }
1834
- });
1835
- return;
1836
- }
1837
-
1838
- if (pathname === '/api/regenerate' && req.method === 'POST') {
1839
- try {
1840
- // Read current artifact.mdx and regenerate HTML
1841
- const planMdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
1842
- const metaJson = readFileSync(join(planDir, 'meta.json'), 'utf-8');
1843
- const commentsJson = readFileSync(join(planDir, 'comments.json'), 'utf-8');
1844
- const meta = JSON.parse(metaJson);
1845
-
1846
- const planHtmlTemplate = readFileSync(join(TEMPLATES_DIR, 'artifact.html.template'), 'utf-8');
1847
- const planJson = JSON.stringify(planMdx);
1848
-
1849
- const vars = {
1850
- title: meta.title || slug,
1851
- slug: meta.slug || slug,
1852
- status: meta.status || 'draft',
1853
- created: meta.created || new Date().toISOString(),
1854
- lastEdited: meta.lastEdited || new Date().toISOString(),
1855
- author: meta.author || 'unknown',
1856
- planJson,
1857
- commentsJson,
1858
- metaJson,
1859
- };
1860
-
1861
- let htmlContent = planHtmlTemplate;
1862
- for (const [key, value] of Object.entries(vars)) {
1863
- htmlContent = htmlContent.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
1864
- }
1865
- atomicWriteText(join(planDir, 'artifact.html'), htmlContent);
1866
-
1867
- res.writeHead(200, { 'Content-Type': 'application/json' });
1868
- res.end(JSON.stringify({ ok: true }));
1869
- } catch (err) {
1870
- res.writeHead(500, { 'Content-Type': 'application/json' });
1871
- res.end(JSON.stringify({ error: err.message }));
1872
- }
1873
- return;
1874
- }
1875
-
1876
- // 404 for unknown routes
1877
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1878
- res.end('Not found');
1879
- } catch (err) {
1880
- console.error(`[${new Date().toISOString()}] ERROR: ${err.message}`);
1881
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1882
- res.end(`Server error: ${err.message}`);
1883
- }
1884
- }
1885
-
1886
- // ─── Browser opening ─────────────────────────────────────────────────────────
1887
-
1888
- function isWSL() {
1889
- try {
1890
- const version = readFileSync('/proc/version', 'utf8');
1891
- if (version.toLowerCase().includes('microsoft')) return true;
1892
- } catch { /* not WSL */ }
1893
- return !!process.env.WSL_INTEROP;
1894
- }
1895
-
1896
- function openBrowser(url) {
1897
- const platform = process.platform;
1898
- const wsl = isWSL();
1899
- let cmd, args;
1900
- if (platform === 'darwin') {
1901
- cmd = 'open';
1902
- args = [url];
1903
- } else if (platform === 'win32' || wsl) {
1904
- cmd = 'cmd';
1905
- args = ['/c', 'start', '""', url];
1906
- } else {
1907
- cmd = 'xdg-open';
1908
- args = [url];
1909
- }
1910
-
1911
- // Check if the command exists
1912
- const probe = spawnSync('which', [cmd], { stdio: 'ignore' });
1913
- if (probe.status !== 0) {
1914
- console.log(` ℹ Open ${url} in your browser (no ${cmd} available)`);
1915
- return false;
1916
- }
1917
-
1918
- try {
1919
- const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
1920
- child.on('error', (err) => {
1921
- console.log(` ℹ Could not open browser: ${err.message}`);
1922
- console.log(` Open manually: ${url}`);
1923
- });
1924
- child.unref();
1925
- return true;
1926
- } catch (err) {
1927
- console.log(` ℹ Could not open browser: ${err.message}`);
1928
- console.log(` Open manually: ${url}`);
1929
- return false;
1930
- }
1931
- }
1932
-
1933
- // ─── Stdin question helper ───────────────────────────────────────────────────
1934
-
1935
- function question(prompt) {
1936
- return new Promise((resolve) => {
1937
- process.stdout.write(prompt);
1938
- process.stdin.once('data', (data) => resolve(data.toString()));
1939
- });
1940
- }
1941
-
1942
- // ─── Signal wait helper ──────────────────────────────────────────────────────
1943
-
1944
- function waitForSignal(closeFn) {
1945
- return new Promise((resolve) => {
1946
- const finish = async () => {
1947
- await closeFn();
1948
- process.off('SIGINT', finish);
1949
- process.off('SIGTERM', finish);
1950
- resolve();
1951
- };
1952
- process.on('SIGINT', finish);
1953
- process.on('SIGTERM', finish);
1954
- });
1955
- }
1956
-
1957
- // ─── Main dispatcher ─────────────────────────────────────────────────────────
1958
-
1959
- /**
1960
- * Main entry point. Accepts a flat argv array (or a split {positional, flags}
1961
- * object). Flag forms supported: --flag value, --flag=value.
1962
- *
1963
- * Subcommands:
1964
- * new <slug> [--template <name>]
1965
- * open <slug>
1966
- * list
1967
- * delete <slug>
1968
- * export <slug>
1969
- * templates
1970
- * template save <name> <artifact-slug>
1971
- * template delete <name>
1972
- * help
1973
- */
1974
- export async function runArtifact(argsOrPositional, legacyFlags) {
1975
- // Normalize the input — accept either a flat argv array (current bin.mjs
1976
- // style) or an already-parsed { positional, flags } object (test style).
1977
- let positional;
1978
- let flags;
1979
- if (Array.isArray(argsOrPositional)) {
1980
- const parsed = parseArgs(argsOrPositional);
1981
- positional = parsed.positional;
1982
- flags = { ...(legacyFlags || {}), ...parsed.flags };
1983
- } else if (argsOrPositional && Array.isArray(argsOrPositional.positional)) {
1984
- positional = argsOrPositional.positional;
1985
- flags = { ...(legacyFlags || {}), ...(argsOrPositional.flags || {}) };
1986
- } else {
1987
- positional = [];
1988
- flags = legacyFlags || {};
1989
- }
1990
-
1991
- // Special-case: "template save <name> <artifact-slug>" / "template delete <name>"
1992
- if (positional[0] === 'template') {
1993
- const action = positional[1];
1994
- if (action === 'save') {
1995
- const name = positional[2];
1996
- const planSlug = positional[3];
1997
- if (!name || !planSlug) {
1998
- console.error(' ✗ Usage: bizar artifact template save <name> <artifact-slug>');
1999
- return false;
2000
- }
2001
- try {
2002
- const target = saveTemplateToLibrary(name, planSlug);
2003
- console.log(` ✓ Saved template "${name}" → ${target}`);
2004
- return true;
2005
- } catch (err) {
2006
- console.error(` ✗ ${err.message}`);
2007
- return false;
2008
- }
2009
- }
2010
- if (action === 'delete' || action === 'rm') {
2011
- const name = positional[2];
2012
- if (!name) {
2013
- console.error(' ✗ Usage: bizar artifact template delete <name>');
2014
- return false;
2015
- }
2016
- try {
2017
- const removed = deleteLibraryTemplate(name);
2018
- console.log(` ✓ Removed template "${name}" from library (${removed})`);
2019
- return true;
2020
- } catch (err) {
2021
- console.error(` ✗ ${err.message}`);
2022
- return false;
2023
- }
2024
- }
2025
- if (action === 'list' || action === undefined) {
2026
- printTemplates();
2027
- return true;
2028
- }
2029
- console.error(` ✗ Unknown template action: "${action}"`);
2030
- console.error(' Use: save <name> <artifact-slug>, delete <name>, or list');
2031
- return false;
2032
- }
2033
-
2034
- const [subcommand, slug] = positional;
2035
-
2036
- switch (subcommand) {
2037
- case 'new': {
2038
- if (!slug) {
2039
- console.error(' ✗ Usage: bizar artifact new <slug> [--template <name>]');
2040
- return false;
2041
- }
2042
- const created = await createArtifact(slug, { template: flags.template || null });
2043
- if (!created) return false;
2044
- // Start server and open browser
2045
- return await openArtifact(slug);
2046
- }
2047
-
2048
- case 'open': {
2049
- if (!slug) {
2050
- console.error(' ✗ Usage: bizar artifact open <slug>');
2051
- return false;
2052
- }
2053
- return await openArtifact(slug);
2054
- }
2055
-
2056
- case 'list': {
2057
- return await listPlans();
2058
- }
2059
-
2060
- case 'delete': {
2061
- if (!slug) {
2062
- console.error(' ✗ Usage: bizar artifact delete <slug>');
2063
- return false;
2064
- }
2065
- return await deleteArtifact(slug);
2066
- }
2067
-
2068
- case 'export': {
2069
- if (!slug) {
2070
- console.error(' ✗ Usage: bizar artifact export <slug>');
2071
- return false;
2072
- }
2073
- return await exportArtifact(slug);
2074
- }
2075
-
2076
- case 'templates': {
2077
- printTemplates();
2078
- return true;
2079
- }
2080
-
2081
- case 'help':
2082
- case undefined: {
2083
- showHelp();
2084
- return true;
2085
- }
2086
-
2087
- default: {
2088
- console.error(` ✗ Unknown subcommand: "${subcommand}"`);
2089
- console.error(` Run "bizar artifact help" for usage.`);
2090
- return false;
2091
- }
2092
- }
2093
- }
2094
-
2095
- // Default export for CLI entrypoint
2096
- export default runArtifact;
22
+ export {
23
+ // From artifact-server.mjs
24
+ startServer,
25
+ } from './artifact-server.mjs';
2097
26
 
2098
- // Named exports for the v2 canvas helpers (used by tests and the AI tool).
2099
- // These are the public surface of the canvas subsystem.
2100
27
  export {
28
+ // From artifact-render.mjs
2101
29
  CANVAS_SCHEMA_VERSION,
2102
30
  emptyCanvas,
2103
31
  readCanvasFile,
@@ -2109,8 +37,6 @@ export {
2109
37
  makeCommentId,
2110
38
  makeReplyId,
2111
39
  readPlanMeta,
2112
- // HTML fragment renderers (used by tests and external integrations that
2113
- // need to produce htmx-compatible HTML for the v2 endpoints).
2114
40
  renderElementHTML,
2115
41
  renderConnectionHTML,
2116
42
  renderCommentPinHTML,
@@ -2118,4 +44,20 @@ export {
2118
44
  renderReplyHTML,
2119
45
  escapeHtml,
2120
46
  formatDate,
2121
- };
47
+ isHtmxRequest,
48
+ decodeHtmxFormBody,
49
+ elementTypeBadge,
50
+ renderElementBody,
51
+ renderCommentLi,
52
+ renderCommentListHtml,
53
+ renderCommentCountHtml,
54
+ readRequestBody,
55
+ bumpLastEdited,
56
+ openBrowser,
57
+ replaceTemplate,
58
+ readTemplate,
59
+ atomicWriteText,
60
+ atomicWriteJson,
61
+ writePlanFile,
62
+ readPlanFile,
63
+ } from './artifact-render.mjs';