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