@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.
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/cli/audit.mjs +144 -0
- package/cli/banner.mjs +41 -0
- package/cli/bin.mjs +186 -0
- package/cli/copy.mjs +508 -0
- package/cli/export.mjs +87 -0
- package/cli/init.mjs +147 -0
- package/cli/install.mjs +390 -0
- package/cli/plan-templates.mjs +523 -0
- package/cli/plan.mjs +2087 -0
- package/cli/prompts.mjs +163 -0
- package/cli/update.mjs +273 -0
- package/cli/utils.mjs +153 -0
- package/config/AGENTS.md +282 -0
- package/config/agents/baldr.md +148 -0
- package/config/agents/forseti.md +112 -0
- package/config/agents/frigg.md +101 -0
- package/config/agents/heimdall.md +157 -0
- package/config/agents/hermod.md +144 -0
- package/config/agents/mimir.md +115 -0
- package/config/agents/odin.md +309 -0
- package/config/agents/quick.md +78 -0
- package/config/agents/semble-search.md +44 -0
- package/config/agents/thor.md +97 -0
- package/config/agents/tyr.md +96 -0
- package/config/agents/vidarr.md +100 -0
- package/config/agents/vor.md +140 -0
- package/config/commands/audit.md +1 -0
- package/config/commands/explain.md +1 -0
- package/config/commands/init.md +1 -0
- package/config/commands/learn.md +1 -0
- package/config/commands/pr-review.md +1 -0
- package/config/commands/tailscale-serve.md +96 -0
- package/config/hooks/README.md +29 -0
- package/config/hooks/post-tool-use.md +16 -0
- package/config/hooks/pre-tool-use.md +16 -0
- package/config/opencode.json +52 -0
- package/config/opencode.json.template +52 -0
- package/config/rules/general.md +8 -0
- package/config/rules/git.md +11 -0
- package/config/rules/javascript.md +10 -0
- package/config/rules/python.md +10 -0
- package/config/rules/testing.md +10 -0
- package/config/skills/bizar/README.md +9 -0
- package/config/skills/bizar/SKILL.md +187 -0
- package/config/skills/cpp-coding-standards/README.md +28 -0
- package/config/skills/cpp-coding-standards/SKILL.md +634 -0
- package/config/skills/cpp-coding-standards/agents/openai.yaml +4 -0
- package/config/skills/cpp-coding-standards/references/concurrency.md +320 -0
- package/config/skills/cpp-coding-standards/references/error-handling.md +229 -0
- package/config/skills/cpp-coding-standards/references/memory-safety.md +216 -0
- package/config/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
- package/config/skills/cpp-coding-standards/references/review-checklist.md +96 -0
- package/config/skills/cpp-testing/README.md +28 -0
- package/config/skills/cpp-testing/SKILL.md +304 -0
- package/config/skills/cpp-testing/agents/openai.yaml +4 -0
- package/config/skills/cpp-testing/references/coverage.md +370 -0
- package/config/skills/cpp-testing/references/framework-compare.md +175 -0
- package/config/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
- package/config/skills/cpp-testing/references/mocking.md +364 -0
- package/config/skills/cpp-testing/references/tdd-workflow.md +308 -0
- package/config/skills/embedded-esp-idf/README.md +41 -0
- package/config/skills/embedded-esp-idf/SKILL.md +439 -0
- package/config/skills/embedded-esp-idf/agents/openai.yaml +4 -0
- package/config/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
- package/config/skills/embedded-esp-idf/references/host-tests.md +164 -0
- package/config/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
- package/config/skills/embedded-esp-idf/references/kconfig.md +159 -0
- package/config/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
- package/config/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
- package/config/skills/embedded-esp-idf/references/nvs.md +121 -0
- package/config/skills/embedded-esp-idf/references/packed-structs.md +192 -0
- package/config/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
- package/config/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
- package/config/skills/self-improvement/SKILL.md +64 -0
- package/package.json +47 -0
- package/templates/plan/htmx.min.js +1 -0
- package/templates/plan/library/bug-investigation.mdx +79 -0
- package/templates/plan/library/decision-record.mdx +71 -0
- package/templates/plan/library/feature-design.mdx +92 -0
- package/templates/plan/meta.json.template +8 -0
- package/templates/plan/plan.canvas.template +1711 -0
- package/templates/plan/plan.html.template +937 -0
- 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('');
|
|
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, '&')
|
|
730
|
+
.replace(/</g, '<')
|
|
731
|
+
.replace(/>/g, '>')
|
|
732
|
+
.replace(/"/g, '"')
|
|
733
|
+
.replace(/'/g, ''');
|
|
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§ionId=... → 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
|
+
};
|