viteboard 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { evaluatePageFromUrl as evaluatePageFromUrlPortable, renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
7
8
 
8
9
  const WORKSPACE_DIR = ".workspace";
9
10
  const WORKSPACE_FILE = "workspace.json";
@@ -28,18 +29,80 @@ const MIME_EXT = {
28
29
  const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
29
30
  const ACTOR_TTL_MS = 30000;
30
31
  const LOCK_TTL_MS = 30000;
32
+ const CAPABILITY_CACHE_MS = 1000;
33
+ const WORKSPACE_CACHE_MS = 500;
34
+ const SKILL_BUNDLE_MAX_BYTES = 16 * 1024 * 1024;
35
+ const SKILL_SUPPORT_EXTENSIONS = new Set([
36
+ ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".csv", ".xml",
37
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".sh", ".py", ".sql",
38
+ ".html", ".css",
39
+ ]);
40
+ const SKILL_ASSET_TYPES = {
41
+ ".svg": "image/svg+xml",
42
+ ".png": "image/png",
43
+ ".jpg": "image/jpeg",
44
+ ".jpeg": "image/jpeg",
45
+ ".webp": "image/webp",
46
+ ".gif": "image/gif",
47
+ ".pdf": "application/pdf",
48
+ };
31
49
  const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
32
50
  const execFileAsync = promisify(execFile);
33
51
 
34
- export function createContentApiPlugin() {
35
- const cwd = process.cwd();
52
+ function boardExportFileName(title, format) {
53
+ const extension = format === "jpeg" ? "jpg" : format;
54
+ const base = String(title ?? "board")
55
+ .trim()
56
+ .replace(/[\\/:*?"<>|]+/g, "-")
57
+ .replace(/[\u0000-\u001f\u007f]/g, "")
58
+ .replace(/\s+/g, " ")
59
+ .replace(/\.(pdf|jpe?g|svg)$/i, "")
60
+ .slice(0, 120) || "board";
61
+ return `${base}.${extension}`;
62
+ }
63
+
64
+ function decodeExportDataUrl(dataUrl, expectedMimeType) {
65
+ if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) throw new Error("Board renderer returned invalid image data.");
66
+ const comma = dataUrl.indexOf(",");
67
+ if (comma < 0) throw new Error("Board renderer returned invalid image data.");
68
+ const metadata = dataUrl.slice(5, comma);
69
+ const mimeType = metadata.split(";")[0];
70
+ if (mimeType !== expectedMimeType) throw new Error("Board renderer returned an unexpected image format.");
71
+ const payload = dataUrl.slice(comma + 1);
72
+ if (metadata.split(";").includes("base64")) return Buffer.from(payload, "base64");
73
+ return Buffer.from(decodeURIComponent(payload), "utf-8");
74
+ }
75
+
76
+ export function createContentApiPlugin(options = {}) {
77
+ const cwd = path.resolve(options.cwd ?? process.cwd());
36
78
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
79
  const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
38
80
  const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
39
81
  const machineDir = path.join(packageRoot, "content", "machine");
40
82
  const actorSessions = new Map();
41
83
  const locks = new Map();
84
+ const mutationQueues = new Map();
42
85
  const sseClients = new Set();
86
+ const capabilityCaches = new Map();
87
+ const capabilityCachePromises = new Map();
88
+ let capabilityGeneration = 0;
89
+ let workspaceCache = null;
90
+ let workspaceCacheExpiresAt = 0;
91
+ let workspaceCachePromise = null;
92
+ let workspaceGeneration = 0;
93
+
94
+ function invalidateCapabilityCache() {
95
+ capabilityGeneration++;
96
+ capabilityCaches.clear();
97
+ capabilityCachePromises.clear();
98
+ }
99
+
100
+ function invalidateWorkspaceCache() {
101
+ workspaceGeneration++;
102
+ workspaceCache = null;
103
+ workspaceCacheExpiresAt = 0;
104
+ workspaceCachePromise = null;
105
+ }
43
106
 
44
107
  function send(res, status, body) {
45
108
  res.statusCode = status;
@@ -53,12 +116,30 @@ export function createContentApiPlugin() {
53
116
  res.end(body);
54
117
  }
55
118
 
119
+ function sendBinary(res, status, body, type, fileName) {
120
+ res.statusCode = status;
121
+ res.setHeader("Content-Type", type);
122
+ if (fileName) {
123
+ const value = String(fileName).replace(/[\r\n\\"]/g, "-");
124
+ const asciiName = value.replace(/[^\x20-\x7e]/g, "_");
125
+ const encodedName = encodeURIComponent(value).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
126
+ res.setHeader("Content-Disposition", `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
127
+ }
128
+ res.end(body);
129
+ }
130
+
56
131
  function sendSse(res, event, body) {
57
132
  res.write(`event: ${event}\n`);
58
133
  res.write(`data: ${JSON.stringify(body)}\n\n`);
59
134
  }
60
135
 
61
136
  function broadcast(event, body) {
137
+ if (event === "workspace-changed") {
138
+ invalidateCapabilityCache();
139
+ invalidateWorkspaceCache();
140
+ } else if (event === "file-changed" && [body?.path, body?.oldPath].some((filePath) => filePath?.startsWith("capabilities/"))) {
141
+ invalidateCapabilityCache();
142
+ }
62
143
  for (const client of sseClients) {
63
144
  try { sendSse(client, event, body); } catch { sseClients.delete(client); }
64
145
  }
@@ -77,6 +158,34 @@ export function createContentApiPlugin() {
77
158
  return `${target.targetType}:${target.rootId}:${suffix}`;
78
159
  }
79
160
 
161
+ function fileMutationKey(absPath) {
162
+ const normalized = path.normalize(absPath);
163
+ return `file:${process.platform === "win32" ? normalized.toLowerCase() : normalized}`;
164
+ }
165
+
166
+ async function withMutationLock(key, operation) {
167
+ const previous = mutationQueues.get(key) ?? Promise.resolve();
168
+ let release;
169
+ const gate = new Promise((resolve) => { release = resolve; });
170
+ const queued = previous.then(() => gate);
171
+ mutationQueues.set(key, queued);
172
+ await previous;
173
+ try {
174
+ return await operation();
175
+ } finally {
176
+ release();
177
+ if (mutationQueues.get(key) === queued) mutationQueues.delete(key);
178
+ }
179
+ }
180
+
181
+ async function withMutationLocks(keys, operation) {
182
+ const uniqueKeys = [...new Set(keys)].sort();
183
+ const acquire = (index) => index >= uniqueKeys.length
184
+ ? operation()
185
+ : withMutationLock(uniqueKeys[index], () => acquire(index + 1));
186
+ return acquire(0);
187
+ }
188
+
80
189
  function activeActors() { return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label)); }
81
190
  function activeLocks() { return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt); }
82
191
  function broadcastPresence() { broadcast("presence", { actors: activeActors(), locks: activeLocks() }); }
@@ -103,6 +212,15 @@ export function createContentApiPlugin() {
103
212
  }
104
213
 
105
214
  setInterval(pruneEphemeral, 10000).unref?.();
215
+ setInterval(() => {
216
+ for (const client of sseClients) {
217
+ if (client.destroyed || client.writableEnded) {
218
+ sseClients.delete(client);
219
+ continue;
220
+ }
221
+ try { client.write(": keep-alive\n\n"); } catch { sseClients.delete(client); }
222
+ }
223
+ }, 20000).unref?.();
106
224
 
107
225
  function revisionForText(content) { return createHash("sha1").update(content).digest("hex"); }
108
226
  function hashPath(value) {
@@ -136,6 +254,10 @@ export function createContentApiPlugin() {
136
254
  try { return path.resolve(await fs.realpath(resolved)); } catch { return resolved; }
137
255
  }
138
256
 
257
+ async function isDirectory(absPath) {
258
+ try { return (await fs.stat(absPath)).isDirectory(); } catch { return false; }
259
+ }
260
+
139
261
  function normalizeRelativePath(relPath) {
140
262
  return relPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
141
263
  }
@@ -246,9 +368,25 @@ export function createContentApiPlugin() {
246
368
  const raw = await fs.readFile(workspaceFile, "utf-8");
247
369
  const parsed = JSON.parse(raw);
248
370
  const rawProjects = Array.isArray(parsed.projects) ? parsed.projects : [];
371
+ const projects = [];
372
+ const seenIds = new Set();
373
+ const seenPaths = new Set();
374
+ for (const item of rawProjects) {
375
+ if (typeof item?.rootPath !== "string") continue;
376
+ const rootPath = await canonicalizeProjectRoot(item.rootPath);
377
+ const id = typeof item.id === "string" && item.id.trim() ? item.id.trim() : projectIdFor(rootPath);
378
+ if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
379
+ seenIds.add(id);
380
+ seenPaths.add(rootPath);
381
+ projects.push({
382
+ id,
383
+ label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
384
+ rootPath,
385
+ });
386
+ }
249
387
  return {
250
388
  version: 1,
251
- projects: rawProjects.filter((project) => typeof project?.id === "string" && typeof project?.label === "string" && typeof project?.rootPath === "string"),
389
+ projects,
252
390
  };
253
391
  } catch { return { version: 1, projects: [] }; }
254
392
  }
@@ -263,11 +401,22 @@ export function createContentApiPlugin() {
263
401
  return rel === "" ? "docs" : rel;
264
402
  }
265
403
 
266
- function docsRootId(projectId, absPath) { return `root-${hashPath(`${projectId}:${absPath}`)}`; }
404
+ // Preserve the path-only IDs used by earlier versions so saved routes and API references remain valid.
405
+ function docsRootId(absPath) { return `root-${hashPath(absPath)}`; }
267
406
  function projectIdFor(absRoot) { return `project-${hashPath(absRoot)}`; }
268
407
 
269
408
  async function discoverDocsRoots(project) {
270
409
  const roots = [];
410
+ if (path.basename(project.rootPath) === "docs") {
411
+ roots.push({
412
+ id: docsRootId(project.rootPath),
413
+ projectId: project.id,
414
+ projectLabel: project.label,
415
+ absPath: project.rootPath,
416
+ relativePath: "docs",
417
+ displayPath: "docs",
418
+ });
419
+ }
271
420
  const walk = async (dir) => {
272
421
  let entries;
273
422
  try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
@@ -278,7 +427,7 @@ export function createContentApiPlugin() {
278
427
  if (entry.name === "docs") {
279
428
  const relativePath = normalizeRelativePath(path.relative(project.rootPath, abs));
280
429
  roots.push({
281
- id: docsRootId(project.id, abs),
430
+ id: docsRootId(abs),
282
431
  projectId: project.id,
283
432
  projectLabel: project.label,
284
433
  absPath: abs,
@@ -293,13 +442,37 @@ export function createContentApiPlugin() {
293
442
  return roots.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
294
443
  }
295
444
 
296
- async function getWorkspace() {
445
+ async function loadWorkspace() {
297
446
  const manifest = await loadManifest();
298
447
  const projects = [];
299
448
  for (const project of manifest.projects) projects.push({ ...project, docsRoots: await discoverDocsRoots(project) });
300
449
  return projects;
301
450
  }
302
451
 
452
+ async function getWorkspace() {
453
+ if (workspaceCache && Date.now() < workspaceCacheExpiresAt) return workspaceCache;
454
+ if (workspaceCachePromise) return workspaceCachePromise;
455
+ const generation = workspaceGeneration;
456
+ const pending = loadWorkspace();
457
+ workspaceCachePromise = pending;
458
+ try {
459
+ const projects = await pending;
460
+ if (generation !== workspaceGeneration) return getWorkspace();
461
+ workspaceCache = projects;
462
+ workspaceCacheExpiresAt = Date.now() + WORKSPACE_CACHE_MS;
463
+ const expiresAt = workspaceCacheExpiresAt;
464
+ setTimeout(() => {
465
+ if (workspaceCache === projects && workspaceCacheExpiresAt === expiresAt) {
466
+ workspaceCache = null;
467
+ workspaceCacheExpiresAt = 0;
468
+ }
469
+ }, WORKSPACE_CACHE_MS).unref?.();
470
+ return projects;
471
+ } finally {
472
+ if (workspaceCachePromise === pending) workspaceCachePromise = null;
473
+ }
474
+ }
475
+
303
476
  function toWorkspaceResponse(projects) {
304
477
  return {
305
478
  projects: projects.map((project) => ({
@@ -323,6 +496,34 @@ export function createContentApiPlugin() {
323
496
  return abs;
324
497
  }
325
498
 
499
+ function isWithinPath(root, candidate) {
500
+ const relative = path.relative(root, candidate);
501
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
502
+ }
503
+
504
+ async function resolveSafeFilesystemPath(root, rel) {
505
+ const abs = safeResolve(root, rel);
506
+ if (!abs) return null;
507
+ let realRoot;
508
+ try { realRoot = await fs.realpath(root); } catch { return null; }
509
+
510
+ let existing = abs;
511
+ while (true) {
512
+ try {
513
+ const stat = await fs.lstat(existing);
514
+ if (stat.isSymbolicLink()) return null;
515
+ const realExisting = await fs.realpath(existing);
516
+ if (!isWithinPath(realRoot, realExisting)) return null;
517
+ return abs;
518
+ } catch (error) {
519
+ if (error.code !== "ENOENT") return null;
520
+ const parent = path.dirname(existing);
521
+ if (parent === existing) return null;
522
+ existing = parent;
523
+ }
524
+ }
525
+ }
526
+
326
527
  async function resolveRoot(rootId) {
327
528
  const projects = await getWorkspace();
328
529
  for (const project of projects) {
@@ -355,6 +556,363 @@ export function createContentApiPlugin() {
355
556
  return nodes;
356
557
  }
357
558
 
559
+ function capabilityBranches(tree) {
560
+ const capabilityRoot = tree.find((node) => node.type === "dir" && node.name === "capabilities");
561
+ if (!capabilityRoot || capabilityRoot.type !== "dir") return [];
562
+
563
+ const branches = [];
564
+ const walk = (nodes) => {
565
+ for (const node of nodes) {
566
+ if (node.type === "file") {
567
+ const relative = node.path.slice("capabilities/".length);
568
+ if (node.path.startsWith("capabilities/") && node.name.endsWith(".md") && !relative.includes("/")) {
569
+ branches.push({ branchPath: node.path, entryPath: node.path, node });
570
+ }
571
+ continue;
572
+ }
573
+
574
+ const entry = node.children
575
+ .filter((child) => child.type === "file" && child.name.toLowerCase().endsWith(".md"))
576
+ .sort((a, b) => (a.name === "index.md" ? -1 : b.name === "index.md" ? 1 : a.name.localeCompare(b.name)))[0];
577
+ if (entry) branches.push({ branchPath: node.path, entryPath: entry.path, node });
578
+ walk(node.children);
579
+ }
580
+ };
581
+ walk(capabilityRoot.children);
582
+ return branches.sort((a, b) => a.branchPath.localeCompare(b.branchPath));
583
+ }
584
+
585
+ function flattenTreeFiles(nodes) {
586
+ const files = [];
587
+ for (const node of nodes) {
588
+ if (node.type === "file") files.push(node);
589
+ else files.push(...flattenTreeFiles(node.children));
590
+ }
591
+ return files;
592
+ }
593
+
594
+ function skillFrontmatter(content) {
595
+ const match = String(content ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
596
+ const fields = {};
597
+ if (!match) return { fields, body: String(content ?? "") };
598
+ for (const line of match[1].split(/\r?\n/)) {
599
+ const field = line.match(/^([a-zA-Z][\w-]*):\s*(.*)$/);
600
+ if (!field) continue;
601
+ let value = field[2].trim();
602
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
603
+ value = value.slice(1, -1);
604
+ }
605
+ fields[field[1]] = value;
606
+ }
607
+ return { fields, body: String(content ?? "").slice(match[0].length).trim() };
608
+ }
609
+
610
+ function simpleMarkdownMetadata(content) {
611
+ const frontmatter = String(content ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] ?? "";
612
+ const fields = {};
613
+ for (const line of frontmatter.split(/\r?\n/)) {
614
+ const field = line.match(/^([a-zA-Z][\w-]*):\s*(.*)$/);
615
+ if (!field) continue;
616
+ fields[field[1]] = field[2].trim().replace(/^(?:"(.*)"|'(.*)')$/, (_, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted);
617
+ }
618
+ return fields;
619
+ }
620
+
621
+ function firstCapabilityParagraph(content) {
622
+ const body = String(content ?? "").replace(/^---\r?\n[\s\S]*?\r?\n---\s*/m, "");
623
+ const purpose = body.match(/^#{1,6}\s+purpose\s*\r?\n+([\s\S]*?)(?=\r?\n#{1,6}\s|$)/im)?.[1];
624
+ const source = purpose ?? body;
625
+ return source
626
+ .split(/\r?\n\s*\r?\n/)
627
+ .map((part) => part.trim().replace(/^#{1,6}\s+/, "").replace(/^>\s?/, ""))
628
+ .find((part) => part && !part.startsWith("-") && !part.startsWith("```"))
629
+ ?.replace(/\s+/g, " ") ?? "";
630
+ }
631
+
632
+ function firstCapabilityHeading(content) {
633
+ const body = String(content ?? "").replace(/^---\r?\n[\s\S]*?\r?\n---\s*/m, "");
634
+ return body.match(/^#\s+(.+?)\s*#*\s*$/m)?.[1]?.trim() ?? "";
635
+ }
636
+
637
+ function prettifyCapabilityName(value) {
638
+ const base = String(value ?? "Capability").replace(/\.md$/i, "").split(/[\\/]/).pop() ?? "Capability";
639
+ const title = base.replace(/[-_]+/g, " ").trim();
640
+ return title ? title.charAt(0).toUpperCase() + title.slice(1) : "Capability";
641
+ }
642
+
643
+ function portableSkillId(project, root, branchPath) {
644
+ const routePath = branchPath.replace(/^capabilities\//, "").replace(/\.md$/i, "");
645
+ const slug = `${project.label}-${root.displayPath}-${routePath}`
646
+ .normalize("NFKD")
647
+ .toLowerCase()
648
+ .replace(/[^a-z0-9]+/g, "-")
649
+ .replace(/^-+|-+$/g, "") || "capability";
650
+ const suffix = createHash("sha1").update(`${root.id}:${branchPath}`).digest("hex").slice(0, 8);
651
+ return `${slug.slice(0, 55).replace(/-+$/g, "")}-${suffix}`;
652
+ }
653
+
654
+ function renderAgentSkill(capability) {
655
+ const sourceSkill = capability.files.find((file) => file.relativePath === "SKILL.md")?.content ?? "";
656
+ const sourceBody = skillFrontmatter(sourceSkill).body;
657
+ const machineRules = capability.machineInstructions.trim();
658
+ const supportingFiles = capability.files
659
+ .filter((file) => file.relativePath !== "SKILL.md")
660
+ .map((file) => `- \`${file.relativePath}\` (source: \`${file.sourcePath}\`)`);
661
+ const omittedFiles = capability.omittedFiles.map((file) => `- \`${file}\``);
662
+ const body = [
663
+ `# ${capability.title}`,
664
+ capability.summary,
665
+ "",
666
+ "## ViteBoard source",
667
+ "",
668
+ `- Project: ${capability.projectLabel}`,
669
+ `- Docs root: ${capability.docsRoot} (${capability.rootId})`,
670
+ `- Branch: \`${capability.branchPath}\``,
671
+ `- Detail API: \`/__api/capability?rootId=${encodeURIComponent(capability.rootId)}&path=${encodeURIComponent(capability.branchPath)}\``,
672
+ "",
673
+ ...(sourceBody ? ["## Capability-specific skill instructions", "", sourceBody, ""] : []),
674
+ "## Capability rules",
675
+ "",
676
+ machineRules || `No llms.txt is present. Read ${capability.entryPath} and inspect the relevant supporting files before acting.`,
677
+ "",
678
+ "## Supporting files",
679
+ "",
680
+ ...(supportingFiles.length ? supportingFiles : ["No additional files are listed."]),
681
+ ...(omittedFiles.length ? ["", "Files not included in this skill bundle (for example, unsupported types or files over the size limit):", "", ...omittedFiles] : []),
682
+ "",
683
+ "The capability source of truth is the ViteBoard branch listed above. Preserve its rules and use the ViteBoard capability API or the project files to read current supporting material.",
684
+ ].join("\n").trim();
685
+ return `---\nname: ${capability.id}\ndescription: ${JSON.stringify(capability.description)}\n---\n\n${body}\n`;
686
+ }
687
+
688
+ async function buildCapabilityIndex(scope = {}) {
689
+ const projects = await getWorkspace();
690
+ const capabilities = [];
691
+ for (const project of projects) {
692
+ if (scope.projectId && project.id !== scope.projectId) continue;
693
+ for (const root of project.docsRoots) {
694
+ if (scope.rootId && root.id !== scope.rootId) continue;
695
+ const nestedRoots = new Set(project.docsRoots
696
+ .filter((item) => item.id !== root.id && item.absPath.startsWith(root.absPath + path.sep))
697
+ .map((item) => item.relativePath));
698
+ const tree = await buildTree(root.absPath, nestedRoots);
699
+ for (const branch of capabilityBranches(tree)) {
700
+ const sourceNodes = branch.node.type === "file" ? [branch.node] : flattenTreeFiles(branch.node.children);
701
+ const preferredPaths = new Set([
702
+ branch.entryPath,
703
+ branch.node.type === "dir" ? `${branch.branchPath}/llms.txt` : "",
704
+ branch.node.type === "dir" ? `${branch.branchPath}/SKILL.md` : "",
705
+ ]);
706
+ sourceNodes.sort((a, b) => Number(preferredPaths.has(b.path)) - Number(preferredPaths.has(a.path)) || a.path.localeCompare(b.path));
707
+ const files = [];
708
+ let bundleBytes = 0;
709
+ for (const node of sourceNodes) {
710
+ const extension = path.extname(node.name).toLowerCase();
711
+ const isText = SKILL_SUPPORT_EXTENSIONS.has(extension);
712
+ const contentType = isText
713
+ ? extension === ".md"
714
+ ? "text/markdown; charset=utf-8"
715
+ : extension === ".json"
716
+ ? "application/json; charset=utf-8"
717
+ : extension === ".js" || extension === ".mjs" || extension === ".cjs"
718
+ ? "text/javascript; charset=utf-8"
719
+ : extension === ".css"
720
+ ? "text/css; charset=utf-8"
721
+ : "text/plain; charset=utf-8"
722
+ : SKILL_ASSET_TYPES[extension];
723
+ if (!contentType) continue;
724
+ const absolutePath = await resolveSafeFilesystemPath(root.absPath, node.path);
725
+ if (!absolutePath) continue;
726
+ try {
727
+ const stat = await fs.lstat(absolutePath);
728
+ const fileLimit = isText ? 1024 * 1024 : 5 * 1024 * 1024;
729
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > fileLimit || bundleBytes + stat.size > SKILL_BUNDLE_MAX_BYTES) continue;
730
+ const bytes = await fs.readFile(absolutePath);
731
+ const content = isText ? bytes.toString("utf-8") : bytes;
732
+ bundleBytes += stat.size;
733
+ const isSingleFile = branch.node.type === "file";
734
+ let relativePath = isSingleFile
735
+ ? `references/${path.basename(node.path)}`
736
+ : node.path.slice(branch.branchPath.length + 1);
737
+ if (relativePath === "SKILL.md") relativePath = "SKILL.md";
738
+ files.push({
739
+ sourcePath: node.path,
740
+ relativePath,
741
+ catalogPath: relativePath === "SKILL.md" ? "references/source-SKILL.md" : relativePath,
742
+ content,
743
+ contentType,
744
+ size: stat.size,
745
+ isText,
746
+ });
747
+ if (files.length >= 200) break;
748
+ } catch {
749
+ /* Ignore unreadable, binary, or concurrently removed support files. */
750
+ }
751
+ }
752
+ const includedSourcePaths = new Set(files.map((file) => file.sourcePath));
753
+ const omittedFiles = sourceNodes.filter((node) => !includedSourcePaths.has(node.path)).map((node) => node.path);
754
+
755
+ const entryFile = files.find((file) => file.sourcePath === branch.entryPath);
756
+ const sourceSkill = files.find((file) => file.relativePath === "SKILL.md");
757
+ const machinePath = branch.node.type === "dir" ? `${branch.branchPath}/llms.txt` : null;
758
+ const machineFile = files.find((file) => file.sourcePath === machinePath);
759
+ const metadata = simpleMarkdownMetadata(entryFile?.content ?? "");
760
+ const authoredSkillMetadata = simpleMarkdownMetadata(sourceSkill?.content ?? "");
761
+ const title = metadata.title || firstCapabilityHeading(entryFile?.content) || prettifyCapabilityName(branch.entryPath);
762
+ const summary = metadata.summary || firstCapabilityParagraph(machineFile?.content) || firstCapabilityParagraph(entryFile?.content);
763
+ const description = (authoredSkillMetadata.description || summary || `Reusable instructions and resources for ${title}.`)
764
+ .replace(/\s+/g, " ")
765
+ .slice(0, 900);
766
+ const id = portableSkillId(project, root, branch.branchPath);
767
+ const routePath = branch.branchPath.replace(/^capabilities\//, "");
768
+ const record = {
769
+ id,
770
+ skillId: id,
771
+ title,
772
+ name: title,
773
+ description,
774
+ summary,
775
+ kind: metadata.kind || undefined,
776
+ tags: String(metadata.tags ?? "").replace(/^\[|\]$/g, "").split(",").map((tag) => tag.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean),
777
+ rootId: root.id,
778
+ projectId: project.id,
779
+ projectLabel: project.label,
780
+ docsRoot: root.displayPath,
781
+ branchPath: branch.branchPath,
782
+ entryPath: branch.entryPath,
783
+ routePath,
784
+ machinePath,
785
+ machineInstructions: machineFile?.content ?? "",
786
+ files,
787
+ omittedFiles,
788
+ };
789
+ record.skillMarkdown = renderAgentSkill(record);
790
+ const versionHash = createHash("sha256").update(record.skillMarkdown);
791
+ for (const file of files) versionHash.update(file.catalogPath).update("\0").update(file.content).update("\0");
792
+ record.skillVersion = versionHash.digest("hex");
793
+ record.files = files.map(({ content, ...file }) => file);
794
+ delete record.machineInstructions;
795
+ delete record.skillMarkdown;
796
+ capabilities.push(record);
797
+ }
798
+ }
799
+ }
800
+ return capabilities.sort((a, b) => `${a.projectLabel}/${a.docsRoot}/${a.branchPath}`.localeCompare(`${b.projectLabel}/${b.docsRoot}/${b.branchPath}`));
801
+ }
802
+
803
+ async function getCapabilityIndex(scope = {}) {
804
+ const projects = await getWorkspace();
805
+ if (scope.projectId && !projects.some((project) => project.id === scope.projectId)) return [];
806
+ if (scope.rootId && !projects.some((project) => project.docsRoots.some((root) => root.id === scope.rootId))) return [];
807
+ if (scope.rootId && scope.projectId && !projects.some((project) =>
808
+ project.id === scope.projectId && project.docsRoots.some((root) => root.id === scope.rootId)
809
+ )) return [];
810
+ const cacheKey = `${scope.rootId ?? ""}\0${scope.projectId ?? ""}`;
811
+ const cached = capabilityCaches.get(cacheKey);
812
+ if (cached && Date.now() < cached.expiresAt) return cached.records;
813
+ const existing = capabilityCachePromises.get(cacheKey);
814
+ if (existing) return existing;
815
+ const generation = capabilityGeneration;
816
+ const pending = buildCapabilityIndex(scope);
817
+ capabilityCachePromises.set(cacheKey, pending);
818
+ try {
819
+ const records = await pending;
820
+ if (generation !== capabilityGeneration) return getCapabilityIndex(scope);
821
+ const cacheEntry = { records, expiresAt: Date.now() + CAPABILITY_CACHE_MS };
822
+ capabilityCaches.set(cacheKey, cacheEntry);
823
+ setTimeout(() => {
824
+ if (capabilityCaches.get(cacheKey) === cacheEntry) capabilityCaches.delete(cacheKey);
825
+ }, CAPABILITY_CACHE_MS).unref?.();
826
+ return records;
827
+ } finally {
828
+ if (capabilityCachePromises.get(cacheKey) === pending) capabilityCachePromises.delete(cacheKey);
829
+ }
830
+ }
831
+
832
+ async function readCapabilityFile(record, file) {
833
+ const resolved = await resolveRoot(record.rootId);
834
+ if (!resolved) return null;
835
+ const absolutePath = await resolveSafeFilesystemPath(resolved.root.absPath, file.sourcePath);
836
+ if (!absolutePath) return null;
837
+ try {
838
+ const stat = await fs.lstat(absolutePath);
839
+ const fileLimit = file.isText ? 1024 * 1024 : 5 * 1024 * 1024;
840
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > fileLimit) return null;
841
+ const bytes = await fs.readFile(absolutePath);
842
+ return file.isText ? bytes.toString("utf-8") : bytes;
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
847
+
848
+ async function generateCapabilitySkill(record, machineInstructions) {
849
+ const sourceSkill = record.files.find((file) => file.relativePath === "SKILL.md");
850
+ const sourceSkillContent = sourceSkill ? await readCapabilityFile(record, sourceSkill) : "";
851
+ const files = sourceSkill
852
+ ? record.files.map((file) => file === sourceSkill ? { ...file, content: sourceSkillContent ?? "" } : file)
853
+ : record.files;
854
+ return renderAgentSkill({ ...record, files, machineInstructions });
855
+ }
856
+
857
+ function capabilitySummary(record) {
858
+ return {
859
+ id: record.id,
860
+ skillId: record.skillId,
861
+ name: record.name,
862
+ title: record.title,
863
+ description: record.description,
864
+ summary: record.summary,
865
+ kind: record.kind,
866
+ tags: record.tags,
867
+ rootId: record.rootId,
868
+ projectId: record.projectId,
869
+ projectLabel: record.projectLabel,
870
+ docsRoot: record.docsRoot,
871
+ branchPath: record.branchPath,
872
+ entryPath: record.entryPath,
873
+ routePath: record.routePath,
874
+ machinePath: record.machinePath,
875
+ fileCount: record.files.length,
876
+ omittedFileCount: record.omittedFiles.length,
877
+ version: record.skillVersion,
878
+ route: `#/root/${encodeURIComponent(record.rootId)}/capabilities/${encodeURIComponent(record.routePath)}`,
879
+ skillUrl: `/__api/skills/catalog/${record.id}/SKILL.md`,
880
+ };
881
+ }
882
+
883
+ async function capabilityDetail(record) {
884
+ const machineFile = record.machinePath ? record.files.find((file) => file.sourcePath === record.machinePath) : null;
885
+ const machineInstructions = machineFile ? await readCapabilityFile(record, machineFile) : "";
886
+ return {
887
+ ...capabilitySummary(record),
888
+ entrypoints: {
889
+ human: { path: record.entryPath, url: `/__api/file?rootId=${encodeURIComponent(record.rootId)}&path=${encodeURIComponent(record.entryPath)}` },
890
+ machine: record.machinePath
891
+ ? { path: record.machinePath, url: `/__api/file?rootId=${encodeURIComponent(record.rootId)}&path=${encodeURIComponent(record.machinePath)}` }
892
+ : null,
893
+ skill: { format: "Agent Skills", path: "SKILL.md", url: capabilitySummary(record).skillUrl },
894
+ },
895
+ rules: { path: record.machinePath, content: typeof machineInstructions === "string" ? machineInstructions : "" },
896
+ skillMarkdown: await generateCapabilitySkill(record, typeof machineInstructions === "string" ? machineInstructions : ""),
897
+ files: record.files.map((file) => ({
898
+ path: file.sourcePath,
899
+ relativePath: file.relativePath,
900
+ catalogPath: file.catalogPath,
901
+ url: `/__api/skills/catalog/${record.id}/${file.catalogPath.split("/").map(encodeURIComponent).join("/")}`,
902
+ sourceUrl: `${file.isText ? "/__api/file" : "/__api/asset"}?rootId=${encodeURIComponent(record.rootId)}&path=${encodeURIComponent(file.sourcePath)}`,
903
+ contentType: file.contentType,
904
+ size: file.size,
905
+ })),
906
+ omittedFiles: record.omittedFiles,
907
+ skillLocations: {
908
+ opencode: `.opencode/skills/${record.id}/SKILL.md or .agents/skills/${record.id}/SKILL.md`,
909
+ codex: `.agents/skills/${record.id}/SKILL.md`,
910
+ claudeCode: `.claude/skills/${record.id}/SKILL.md`,
911
+ },
912
+ openCodeCatalog: "/__api/skills/catalog/",
913
+ };
914
+ }
915
+
358
916
  function firstMarkdownPath(nodes) {
359
917
  for (const node of nodes) {
360
918
  if (node.type === "file" && node.name.endsWith(".md")) return node.path;
@@ -443,10 +1001,11 @@ export function createContentApiPlugin() {
443
1001
  if (!project) return null;
444
1002
  const existing = (await discoverDocsRoots(project))[0];
445
1003
  if (existing) return existing;
446
- const abs = path.join(project.rootPath, "docs");
1004
+ const abs = await resolveSafeFilesystemPath(project.rootPath, "docs");
1005
+ if (!abs) return null;
447
1006
  await fs.mkdir(abs, { recursive: true });
448
1007
  return {
449
- id: docsRootId(project.id, abs),
1008
+ id: docsRootId(abs),
450
1009
  projectId: project.id,
451
1010
  projectLabel: project.label,
452
1011
  absPath: abs,
@@ -458,12 +1017,13 @@ export function createContentApiPlugin() {
458
1017
  async function createDocsFolder(projectId, parentPath) {
459
1018
  const project = await resolveWorkspaceProject(projectId);
460
1019
  if (!project) return null;
461
- const parentAbs = safeResolve(project.rootPath, parentPath || ".");
1020
+ const parentAbs = await resolveSafeFilesystemPath(project.rootPath, parentPath || ".");
462
1021
  if (!parentAbs) return null;
463
- const abs = path.join(parentAbs, "docs");
1022
+ const abs = await resolveSafeFilesystemPath(project.rootPath, path.relative(project.rootPath, path.join(parentAbs, "docs")));
1023
+ if (!abs) return null;
464
1024
  await fs.mkdir(abs, { recursive: true });
465
1025
  return {
466
- id: docsRootId(project.id, abs),
1026
+ id: docsRootId(abs),
467
1027
  projectId: project.id,
468
1028
  projectLabel: project.label,
469
1029
  absPath: abs,
@@ -485,8 +1045,8 @@ export function createContentApiPlugin() {
485
1045
  }
486
1046
 
487
1047
  async function renamePath(rootAbsPath, oldPath, newPath, ignoreMissing = false) {
488
- const oldAbs = safeResolve(rootAbsPath, oldPath);
489
- const newAbs = safeResolve(rootAbsPath, newPath);
1048
+ const oldAbs = await resolveSafeFilesystemPath(rootAbsPath, oldPath);
1049
+ const newAbs = await resolveSafeFilesystemPath(rootAbsPath, newPath);
490
1050
  if (!oldAbs || !newAbs) throw new Error("Invalid path");
491
1051
  try { await fs.access(oldAbs); } catch (err) {
492
1052
  if (ignoreMissing && err.code === "ENOENT") return;
@@ -506,6 +1066,7 @@ export function createContentApiPlugin() {
506
1066
  const method = req.method || "GET";
507
1067
  if (route === "/llms.txt" && method === "GET") return void (await sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8"));
508
1068
  if (route === "/viteboard-guide.md" && method === "GET") return void (await sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8"));
1069
+ if (route === "/viteboard-skill.md" && method === "GET") return void (await sendMachineFile(res, "viteboard-skill.md", "text/markdown; charset=utf-8"));
509
1070
  if (route === "/board-v1.schema.json" && method === "GET") return void (await sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8"));
510
1071
  if (route === "/viteboard-example.board.json" && method === "GET") return void (await sendMachineFile(res, "example.board.json", "application/json; charset=utf-8"));
511
1072
  if (route === "/.well-known/viteboard.json" && method === "GET") return void (await sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8"));
@@ -518,6 +1079,7 @@ export function createContentApiPlugin() {
518
1079
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
519
1080
  res.setHeader("Cache-Control", "no-cache, no-transform");
520
1081
  res.setHeader("Connection", "keep-alive");
1082
+ res.setHeader("X-Accel-Buffering", "no");
521
1083
  res.flushHeaders?.();
522
1084
  sseClients.add(res);
523
1085
  sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
@@ -525,6 +1087,82 @@ export function createContentApiPlugin() {
525
1087
  return;
526
1088
  }
527
1089
 
1090
+ if (route === "/__api/capabilities" && method === "GET") {
1091
+ const rootId = parsed.searchParams.get("rootId");
1092
+ const projectId = parsed.searchParams.get("projectId");
1093
+ const capabilities = await getCapabilityIndex({ rootId, projectId });
1094
+ return send(res, 200, {
1095
+ version: 1,
1096
+ skillFormat: "Agent Skills",
1097
+ catalog: { openCode: "/__api/skills/catalog/", index: "/__api/skills/catalog/index.json" },
1098
+ locations: {
1099
+ openCode: ".opencode/skills/<skill-id>/SKILL.md or .agents/skills/<skill-id>/SKILL.md",
1100
+ codex: ".agents/skills/<skill-id>/SKILL.md",
1101
+ claudeCode: ".claude/skills/<skill-id>/SKILL.md",
1102
+ },
1103
+ total: capabilities.length,
1104
+ capabilities: capabilities.map(capabilitySummary),
1105
+ });
1106
+ }
1107
+
1108
+ if (route === "/__api/capability" && method === "GET") {
1109
+ const rootId = parsed.searchParams.get("rootId") ?? "";
1110
+ const requestedPath = parsed.searchParams.get("path") ?? "";
1111
+ if (!rootId || !requestedPath) return send(res, 400, { error: "Expected rootId and capability path" });
1112
+ const normalizedPath = normalizeRelativePath(requestedPath);
1113
+ const branchPath = normalizedPath.startsWith("capabilities/") ? normalizedPath : `capabilities/${normalizedPath}`;
1114
+ const capability = (await getCapabilityIndex({ rootId })).find((record) =>
1115
+ record.rootId === rootId && (record.branchPath === branchPath || record.routePath === normalizedPath)
1116
+ );
1117
+ if (!capability) return send(res, 404, { error: "Capability not found" });
1118
+ return send(res, 200, await capabilityDetail(capability));
1119
+ }
1120
+
1121
+ if (route === "/__api/skills/catalog/index.json" && method === "GET") {
1122
+ const records = await getCapabilityIndex();
1123
+ const catalog = {
1124
+ skills: records.map((record) => ({
1125
+ name: record.id,
1126
+ version: record.skillVersion,
1127
+ files: ["SKILL.md", ...new Set(record.files.map((file) => file.catalogPath))],
1128
+ })),
1129
+ };
1130
+ res.setHeader("Cache-Control", "no-cache");
1131
+ return sendText(res, 200, JSON.stringify(catalog, null, 2) + "\n", "application/json; charset=utf-8");
1132
+ }
1133
+
1134
+ const skillCatalogPrefix = "/__api/skills/catalog/";
1135
+ if (route.startsWith(skillCatalogPrefix) && method === "GET") {
1136
+ const segments = route.slice(skillCatalogPrefix.length).split("/");
1137
+ let decoded;
1138
+ try {
1139
+ decoded = segments.map((segment) => decodeURIComponent(segment));
1140
+ } catch {
1141
+ return send(res, 400, { error: "Invalid skill path" });
1142
+ }
1143
+ const [skillId, ...fileParts] = decoded;
1144
+ const filePath = fileParts.join("/");
1145
+ if (!skillId || !filePath || fileParts.some((part) => !part || part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.startsWith("."))) {
1146
+ return send(res, 400, { error: "Invalid skill path" });
1147
+ }
1148
+ const capability = (await getCapabilityIndex()).find((record) => record.id === skillId);
1149
+ if (!capability) return send(res, 404, { error: "Skill not found" });
1150
+ if (filePath === "SKILL.md") {
1151
+ res.setHeader("Cache-Control", "no-cache");
1152
+ const machineFile = capability.machinePath ? capability.files.find((file) => file.sourcePath === capability.machinePath) : null;
1153
+ const machineInstructions = machineFile ? await readCapabilityFile(capability, machineFile) : "";
1154
+ const skillMarkdown = await generateCapabilitySkill(capability, typeof machineInstructions === "string" ? machineInstructions : "");
1155
+ return sendText(res, 200, skillMarkdown, "text/markdown; charset=utf-8");
1156
+ }
1157
+ const file = capability.files.find((item) => item.catalogPath === filePath);
1158
+ if (!file) return send(res, 404, { error: "Skill file not found" });
1159
+ const content = await readCapabilityFile(capability, file);
1160
+ if (content === null) return send(res, 404, { error: "Skill file is unavailable" });
1161
+ res.setHeader("Cache-Control", "no-cache");
1162
+ if (Buffer.isBuffer(content)) return sendBinary(res, 200, content, file.contentType);
1163
+ return sendText(res, 200, content, file.contentType);
1164
+ }
1165
+
528
1166
  if (route === "/__api/workspace" && method === "GET") return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
529
1167
  if (route === "/__api/project-tree" && method === "GET") {
530
1168
  const projectId = parsed.searchParams.get("projectId") ?? "";
@@ -543,7 +1181,7 @@ export function createContentApiPlugin() {
543
1181
  const rel = parsed.searchParams.get("path") ?? "";
544
1182
  const resolved = await resolveRoot(rootId);
545
1183
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
546
- const abs = safeResolve(resolved.root.absPath, rel);
1184
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
547
1185
  if (!abs) return send(res, 400, { error: "Invalid path" });
548
1186
  try {
549
1187
  const content = await fs.readFile(abs, "utf-8");
@@ -554,44 +1192,61 @@ export function createContentApiPlugin() {
554
1192
  const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
555
1193
  const resolved = await resolveRoot(String(rootId ?? ""));
556
1194
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
557
- const abs = safeResolve(resolved.root.absPath, String(rel ?? ""));
1195
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
558
1196
  if (!abs) return send(res, 400, { error: "Invalid path" });
559
- try {
560
- const current = await fs.readFile(abs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
561
- if (expectedRevision && current !== null && revisionForText(current) !== expectedRevision) return send(res, 409, { error: "File changed on disk", currentRevision: revisionForText(current) });
562
- await fs.mkdir(path.dirname(abs), { recursive: true });
563
- const nextContent = String(content ?? "");
564
- await fs.writeFile(abs, nextContent, "utf-8");
565
- const revision = revisionForText(nextContent);
566
- broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", actorId: actor?.actorId ?? null });
567
- return send(res, 200, { ok: true, path: rel, revision });
568
- } catch (err) { return send(res, 500, { error: err.message }); }
1197
+ return withMutationLock(fileMutationKey(abs), async () => {
1198
+ try {
1199
+ const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
1200
+ if (!safeAbs) return send(res, 400, { error: "Invalid path" });
1201
+ const current = await fs.readFile(safeAbs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
1202
+ if (expectedRevision !== undefined) {
1203
+ if (expectedRevision !== null && typeof expectedRevision !== "string") return send(res, 400, { error: "expectedRevision must be a string or null" });
1204
+ const currentRevision = current === null ? null : revisionForText(current);
1205
+ if (expectedRevision !== currentRevision) return send(res, 409, { error: "File changed on disk", currentRevision });
1206
+ }
1207
+ await fs.mkdir(path.dirname(safeAbs), { recursive: true });
1208
+ const nextContent = String(content ?? "");
1209
+ await fs.writeFile(safeAbs, nextContent, "utf-8");
1210
+ const revision = revisionForText(nextContent);
1211
+ broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", created: current === null, actorId: actor?.actorId ?? null });
1212
+ return send(res, 200, { ok: true, path: rel, revision });
1213
+ } catch (err) { return send(res, 500, { error: err.message }); }
1214
+ });
569
1215
  }
570
1216
  if (route === "/__api/file" && method === "PATCH") {
571
1217
  const { rootId, oldPath, newPath, ignoreMissing } = JSON.parse(await readBody(req));
572
1218
  const resolved = await resolveRoot(String(rootId ?? ""));
573
1219
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
574
- await renamePath(resolved.root.absPath, String(oldPath ?? ""), String(newPath ?? ""), !!ignoreMissing);
575
- broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), action: "rename", actorId: actor?.actorId ?? null });
576
- return send(res, 200, { ok: true });
1220
+ const oldAbs = await resolveSafeFilesystemPath(resolved.root.absPath, String(oldPath ?? ""));
1221
+ const newAbs = await resolveSafeFilesystemPath(resolved.root.absPath, String(newPath ?? ""));
1222
+ if (!oldAbs || !newAbs) return send(res, 400, { error: "Invalid path" });
1223
+ return withMutationLocks([fileMutationKey(oldAbs), fileMutationKey(newAbs)], async () => {
1224
+ await renamePath(resolved.root.absPath, String(oldPath ?? ""), String(newPath ?? ""), !!ignoreMissing);
1225
+ broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), oldPath: String(oldPath), action: "rename", actorId: actor?.actorId ?? null });
1226
+ return send(res, 200, { ok: true });
1227
+ });
577
1228
  }
578
1229
  if (route === "/__api/file" && method === "DELETE") {
579
1230
  const rootId = parsed.searchParams.get("rootId") ?? "";
580
1231
  const rel = parsed.searchParams.get("path") ?? "";
581
1232
  const resolved = await resolveRoot(rootId);
582
1233
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
583
- const abs = safeResolve(resolved.root.absPath, rel);
1234
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
584
1235
  if (!abs) return send(res, 400, { error: "Invalid path" });
585
- try { await fs.unlink(abs); } catch (err) { if (err.code !== "ENOENT") throw err; }
586
- await deleteEmptyParentDirs(path.dirname(abs), resolved.root.absPath);
587
- broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
588
- return send(res, 200, { ok: true });
1236
+ return withMutationLock(fileMutationKey(abs), async () => {
1237
+ const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
1238
+ if (!safeAbs) return send(res, 400, { error: "Invalid path" });
1239
+ try { await fs.unlink(safeAbs); } catch (err) { if (err.code !== "ENOENT") throw err; }
1240
+ await deleteEmptyParentDirs(path.dirname(safeAbs), resolved.root.absPath);
1241
+ broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
1242
+ return send(res, 200, { ok: true });
1243
+ });
589
1244
  }
590
1245
  if (route === "/__api/directory" && method === "PUT") {
591
1246
  const { rootId, path: rel } = JSON.parse(await readBody(req));
592
1247
  const resolved = await resolveRoot(String(rootId ?? ""));
593
1248
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
594
- const abs = safeResolve(resolved.root.absPath, String(rel ?? ""));
1249
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
595
1250
  if (!abs) return send(res, 400, { error: "Invalid path" });
596
1251
  await fs.mkdir(abs, { recursive: true });
597
1252
  broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "mkdir", actorId: actor?.actorId ?? null });
@@ -602,7 +1257,7 @@ export function createContentApiPlugin() {
602
1257
  const id = parsed.searchParams.get("id") ?? "";
603
1258
  const resolved = await resolveRoot(rootId);
604
1259
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
605
- const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
1260
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
606
1261
  if (!abs) return send(res, 400, { error: "Invalid id" });
607
1262
  try {
608
1263
  const content = await fs.readFile(abs, "utf-8");
@@ -613,34 +1268,111 @@ export function createContentApiPlugin() {
613
1268
  const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
614
1269
  const resolved = await resolveRoot(String(rootId ?? ""));
615
1270
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
616
- const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
1271
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
617
1272
  if (!abs) return send(res, 400, { error: "Invalid id" });
618
- const current = await fs.readFile(abs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
619
- if (expectedRevision && current !== null && revisionForText(current) !== expectedRevision) return send(res, 409, { error: "Board changed on disk", currentRevision: revisionForText(current) });
620
- await fs.mkdir(path.dirname(abs), { recursive: true });
621
- const nextContent = JSON.stringify(doc, null, 2) + "\n";
622
- await fs.writeFile(abs, nextContent, "utf-8");
623
- const revision = revisionForText(nextContent);
624
- broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: "write", actorId: actor?.actorId ?? null });
625
- return send(res, 200, { ok: true, id, revision });
1273
+ return withMutationLock(fileMutationKey(abs), async () => {
1274
+ const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
1275
+ if (!safeAbs) return send(res, 400, { error: "Invalid id" });
1276
+ const current = await fs.readFile(safeAbs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
1277
+ if (expectedRevision !== undefined) {
1278
+ if (expectedRevision !== null && typeof expectedRevision !== "string") return send(res, 400, { error: "expectedRevision must be a string or null" });
1279
+ const currentRevision = current === null ? null : revisionForText(current);
1280
+ if (expectedRevision !== currentRevision) return send(res, 409, { error: "Board changed on disk", currentRevision });
1281
+ }
1282
+ await fs.mkdir(path.dirname(safeAbs), { recursive: true });
1283
+ const nextContent = JSON.stringify(doc, null, 2) + "\n";
1284
+ await fs.writeFile(safeAbs, nextContent, "utf-8");
1285
+ const revision = revisionForText(nextContent);
1286
+ broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: current === null ? "create" : "write", created: current === null, actorId: actor?.actorId ?? null });
1287
+ return send(res, 200, { ok: true, id, revision });
1288
+ });
626
1289
  }
627
1290
  if (route === "/__api/board" && method === "DELETE") {
628
1291
  const rootId = parsed.searchParams.get("rootId") ?? "";
629
1292
  const id = parsed.searchParams.get("id") ?? "";
630
1293
  const resolved = await resolveRoot(rootId);
631
1294
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
632
- const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
1295
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
633
1296
  if (!abs) return send(res, 400, { error: "Invalid id" });
634
- try { await fs.unlink(abs); } catch (err) { if (err.code !== "ENOENT") throw err; }
635
- broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
636
- return send(res, 200, { ok: true, id });
1297
+ return withMutationLock(fileMutationKey(abs), async () => {
1298
+ const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
1299
+ if (!safeAbs) return send(res, 400, { error: "Invalid id" });
1300
+ try { await fs.unlink(safeAbs); } catch (err) { if (err.code !== "ENOENT") throw err; }
1301
+ broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
1302
+ return send(res, 200, { ok: true, id });
1303
+ });
1304
+ }
1305
+ if (route === "/__api/export/board" && method === "POST") {
1306
+ const request = JSON.parse(await readBody(req));
1307
+ if (typeof request.rootId !== "string" || typeof request.boardId !== "string" || !request.boardId.trim()) {
1308
+ return send(res, 400, { error: "Expected rootId and boardId" });
1309
+ }
1310
+ if (!["pdf", "jpeg", "svg"].includes(request.format)) {
1311
+ return send(res, 400, { error: "format must be pdf, jpeg, or svg" });
1312
+ }
1313
+ if (request.region !== undefined && (!Array.isArray(request.region) || request.region.length !== 4 || request.region.some((value) => typeof value !== "number" || !Number.isFinite(value)) || request.region[2] <= 0 || request.region[3] <= 0 || request.region[2] > 10_000_000 || request.region[3] > 10_000_000)) {
1314
+ return send(res, 400, { error: "region must be [x, y, width, height] in board world units" });
1315
+ }
1316
+ if (request.padding !== undefined && (typeof request.padding !== "number" || !Number.isFinite(request.padding) || request.padding < 0 || request.padding > 100_000)) {
1317
+ return send(res, 400, { error: "padding must be a non-negative number of board world units" });
1318
+ }
1319
+ if (request.scale !== undefined && (typeof request.scale !== "number" || !Number.isFinite(request.scale) || request.scale < 0.1 || request.scale > 16)) {
1320
+ return send(res, 400, { error: "scale must be between 0.1 and 16 pixels per board unit" });
1321
+ }
1322
+ if (request.quality !== undefined && (typeof request.quality !== "number" || !Number.isFinite(request.quality) || request.quality < 0.1 || request.quality > 1)) {
1323
+ return send(res, 400, { error: "quality must be between 0.1 and 1" });
1324
+ }
1325
+ if (request.paper !== undefined && !["A4", "Letter"].includes(request.paper)) {
1326
+ return send(res, 400, { error: "paper must be A4 or Letter" });
1327
+ }
1328
+
1329
+ const resolved = await resolveRoot(request.rootId);
1330
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
1331
+ const boardId = request.boardId.trim().replace(/^\/+/, "").replace(/\.board\.json$/i, "");
1332
+ const boardAbs = await resolveSafeFilesystemPath(resolved.root.absPath, `${boardId}.board.json`);
1333
+ if (!boardAbs) return send(res, 400, { error: "Invalid boardId" });
1334
+ let boardDoc;
1335
+ try {
1336
+ boardDoc = JSON.parse(await fs.readFile(boardAbs, "utf-8"));
1337
+ } catch (err) {
1338
+ if (err.code === "ENOENT") return send(res, 404, { error: "Board not found" });
1339
+ if (err instanceof SyntaxError) return send(res, 400, { error: "Board JSON is invalid" });
1340
+ throw err;
1341
+ }
1342
+ if (boardDoc?.version !== 1 || !Array.isArray(boardDoc.elements)) return send(res, 400, { error: "Board document is invalid" });
1343
+ if (request.region === undefined && !boardDoc.elements.some((element) => element && element.hidden !== true && [element.x, element.y, element.width, element.height].every(Number.isFinite))) {
1344
+ return send(res, 400, { error: "Board has no visible content; provide an explicit region to export" });
1345
+ }
1346
+
1347
+ const port = req.socket?.localPort;
1348
+ let host = String(req.socket?.localAddress ?? "").replace(/^::ffff:/i, "");
1349
+ if (!port || !host) return send(res, 500, { error: "Could not determine the ViteBoard server address" });
1350
+ if (host.includes(":")) host = `[${host}]`;
1351
+ const apiRequest = { ...request, boardId };
1352
+ const expression = `window.__viteboardPrepareBoardExport ? window.__viteboardPrepareBoardExport(${JSON.stringify(apiRequest)}) : Promise.reject(new Error("ViteBoard board exporter did not load"))`;
1353
+ const prepared = await evaluatePageFromUrlPortable(`http://${host}:${port}/`, expression);
1354
+ if (prepared?.error) return send(res, 400, { error: prepared.error });
1355
+ const fileName = boardExportFileName(prepared?.fileName ?? boardDoc.title, request.format);
1356
+
1357
+ if (request.format === "pdf") {
1358
+ if (prepared?.format !== "pdf" || typeof prepared.html !== "string" || !prepared.html.trim()) {
1359
+ return send(res, 500, { error: "Board renderer returned no PDF document" });
1360
+ }
1361
+ const pdfBuffer = await renderPdfFromHtmlPortable(prepared.html, fileName, typeof prepared.pdf === "object" && prepared.pdf ? prepared.pdf : {});
1362
+ return sendBinary(res, 200, pdfBuffer, "application/pdf", fileName);
1363
+ }
1364
+
1365
+ const expectedMimeType = request.format === "jpeg" ? "image/jpeg" : "image/svg+xml";
1366
+ if (prepared?.format !== request.format) return send(res, 500, { error: "Board renderer returned an unexpected export format" });
1367
+ const imageBuffer = decodeExportDataUrl(prepared.dataUrl, expectedMimeType);
1368
+ return sendBinary(res, 200, imageBuffer, expectedMimeType, fileName);
637
1369
  }
638
1370
  if (route === "/__api/asset" && method === "GET") {
639
1371
  const rootId = parsed.searchParams.get("rootId") ?? "";
640
1372
  const rel = parsed.searchParams.get("path") ?? "";
641
1373
  const resolved = await resolveRoot(rootId);
642
1374
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
643
- const abs = safeResolve(resolved.root.absPath, rel);
1375
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
644
1376
  if (!abs) return send(res, 400, { error: "Invalid path" });
645
1377
  try {
646
1378
  const buf = await fs.readFile(abs);
@@ -658,7 +1390,7 @@ export function createContentApiPlugin() {
658
1390
  const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
659
1391
  const digest = createHash("sha256").update(bytes).digest("hex");
660
1392
  const rel = `assets/${digest}${ext}`;
661
- const abs = safeResolve(resolved.root.absPath, rel);
1393
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
662
1394
  if (!abs) return send(res, 400, { error: "Invalid path" });
663
1395
  await fs.mkdir(path.dirname(abs), { recursive: true });
664
1396
  try { await fs.access(abs); } catch { await fs.writeFile(abs, bytes); }
@@ -673,6 +1405,51 @@ export function createContentApiPlugin() {
673
1405
  if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
674
1406
  return send(res, 200, { ok: true, removed });
675
1407
  }
1408
+ if (route === "/__api/export/document/pdf" && method === "POST") {
1409
+ const request = JSON.parse(await readBody(req));
1410
+ if (typeof request.rootId !== "string" || !request.rootId || !["doc", "dir"].includes(request.kind) || typeof request.path !== "string") {
1411
+ return send(res, 400, { error: "Expected rootId, kind (doc or dir), and path" });
1412
+ }
1413
+ if (request.selectedPaths !== undefined && (!Array.isArray(request.selectedPaths) || request.selectedPaths.some((item) => typeof item !== "string"))) {
1414
+ return send(res, 400, { error: "selectedPaths must be an array of document paths" });
1415
+ }
1416
+ const resolved = await resolveRoot(request.rootId);
1417
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
1418
+ const targetAbs = await resolveSafeFilesystemPath(resolved.root.absPath, request.path);
1419
+ if (!targetAbs) return send(res, 400, { error: "Invalid export path" });
1420
+ if (request.kind === "doc" && !request.path.toLowerCase().endsWith(".md")) {
1421
+ return send(res, 400, { error: "Document PDF export requires a Markdown file" });
1422
+ }
1423
+ try {
1424
+ const targetStat = await fs.stat(targetAbs);
1425
+ if (request.kind === "doc" ? !targetStat.isFile() : !targetStat.isDirectory()) {
1426
+ return send(res, 404, { error: request.kind === "doc" ? "Markdown document not found" : "Directory not found" });
1427
+ }
1428
+ } catch (err) {
1429
+ if (err.code === "ENOENT") return send(res, 404, { error: request.kind === "doc" ? "Markdown document not found" : "Directory not found" });
1430
+ throw err;
1431
+ }
1432
+ const port = req.socket?.localPort;
1433
+ let host = String(req.socket?.localAddress ?? "").replace(/^::ffff:/i, "");
1434
+ if (!port || !host) return send(res, 500, { error: "Could not determine the ViteBoard server address" });
1435
+ if (host.includes(":")) host = `[${host}]`;
1436
+ const expression = `window.__viteboardPreparePdf ? window.__viteboardPreparePdf(${JSON.stringify(request)}) : Promise.reject(new Error("ViteBoard PDF renderer did not load"))`;
1437
+ const fallbackTitle = (request.path.split(/[\\/]/).pop() ?? "export").replace(/\.md$/i, "");
1438
+ const prepared = await evaluatePageFromUrlPortable(`http://${host}:${port}/`, expression);
1439
+ if (prepared?.error) return send(res, 400, { error: prepared.error });
1440
+ if (typeof prepared?.html !== "string" || !prepared.html.trim()) {
1441
+ return send(res, 500, { error: "The publication renderer returned no HTML" });
1442
+ }
1443
+ const fileName = sanitizePdfFileNamePortable(prepared.fileName ?? request.options?.title ?? fallbackTitle);
1444
+ const pdfBuffer = await renderPdfFromHtmlPortable(prepared.html, fileName, typeof prepared.pdf === "object" && prepared.pdf ? prepared.pdf : {});
1445
+ return sendBinary(res, 200, pdfBuffer, "application/pdf", fileName);
1446
+ }
1447
+ if (route === "/__api/export/pdf" && method === "POST") {
1448
+ const { html, fileName, pdf } = JSON.parse(await readBody(req));
1449
+ if (typeof html !== "string" || !html.trim()) return send(res, 400, { error: "Missing HTML payload" });
1450
+ const pdfBuffer = await renderPdfFromHtmlPortable(html, String(fileName ?? "export.pdf"), typeof pdf === "object" && pdf ? pdf : {});
1451
+ return sendBinary(res, 200, pdfBuffer, "application/pdf", sanitizePdfFileNamePortable(String(fileName ?? "export.pdf")));
1452
+ }
676
1453
  if (route === "/__api/workspace-file" && method === "GET") {
677
1454
  await ensureWorkspaceDir();
678
1455
  const manifest = await loadManifest();
@@ -680,28 +1457,41 @@ export function createContentApiPlugin() {
680
1457
  }
681
1458
  if (route === "/__api/workspace/project" && method === "POST") {
682
1459
  const { rootPath, label } = JSON.parse(await readBody(req));
683
- const absRoot = await canonicalizeProjectRoot(String(rootPath ?? ""));
1460
+ if (typeof rootPath !== "string" || !rootPath.trim()) return send(res, 400, { error: "Expected a project root path" });
1461
+ const absRoot = await canonicalizeProjectRoot(rootPath.trim());
1462
+ if (!(await isDirectory(absRoot))) return send(res, 400, { error: "Project root not found" });
684
1463
  const manifest = await loadManifest();
685
- const projectId = projectIdFor(absRoot);
1464
+ const existingProject = manifest.projects.find((project) => project.rootPath === absRoot);
1465
+ const projectId = existingProject?.id ?? projectIdFor(absRoot);
686
1466
  const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
687
1467
  if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
688
- if (!manifest.projects.some((project) => project.id === projectId)) {
689
- manifest.projects.push({ id: projectId, label: String(label ?? (path.basename(absRoot) || "Project")), rootPath: absRoot });
1468
+ const project = { id: projectId, label: String(label ?? "").trim() || path.basename(absRoot) || "Project", rootPath: absRoot };
1469
+ const projectIndex = manifest.projects.findIndex((item) => item.id === projectId);
1470
+ const changed = projectIndex < 0 || manifest.projects[projectIndex].label !== project.label || manifest.projects[projectIndex].rootPath !== project.rootPath;
1471
+ if (changed) {
1472
+ if (projectIndex < 0) manifest.projects.push(project);
1473
+ else manifest.projects.splice(projectIndex, 1, project);
690
1474
  await saveManifest(manifest);
1475
+ broadcast("workspace-changed", { action: existingProject ? "project-updated" : "project-added", projectId, actorId: actor?.actorId ?? null });
691
1476
  }
692
1477
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
693
1478
  }
694
1479
  if (route === "/__api/workspace/project" && method === "DELETE") {
695
1480
  const projectId = parsed.searchParams.get("id") ?? "";
696
1481
  const manifest = await loadManifest();
1482
+ const previousCount = manifest.projects.length;
697
1483
  manifest.projects = manifest.projects.filter((project) => project.id !== projectId);
698
- await saveManifest(manifest);
1484
+ if (manifest.projects.length !== previousCount) {
1485
+ await saveManifest(manifest);
1486
+ broadcast("workspace-changed", { action: "project-removed", projectId, actorId: actor?.actorId ?? null });
1487
+ }
699
1488
  return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
700
1489
  }
701
1490
  if (route === "/__api/workspace/pick-project" && method === "POST") {
702
1491
  const picked = await pickProjectFromSystemDialog();
703
1492
  if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
704
1493
  const absRoot = await canonicalizeProjectRoot(picked);
1494
+ if (!(await isDirectory(absRoot))) return send(res, 400, { error: "Project root not found" });
705
1495
  const manifest = await loadManifest();
706
1496
  const projectId = projectIdFor(absRoot);
707
1497
  const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
@@ -709,21 +1499,44 @@ export function createContentApiPlugin() {
709
1499
  if (!manifest.projects.some((project) => project.id === projectId)) {
710
1500
  manifest.projects.push({ id: projectId, label: path.basename(absRoot) || "Project", rootPath: absRoot });
711
1501
  await saveManifest(manifest);
1502
+ broadcast("workspace-changed", { action: "project-added", projectId, actorId: actor?.actorId ?? null });
712
1503
  }
713
1504
  const workspace = await getWorkspace();
714
1505
  return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(workspace), addedProjectId: projectId });
715
1506
  }
716
1507
  if (route === "/__api/workspace/project/docs-root" && method === "POST") {
717
1508
  const { projectId } = JSON.parse(await readBody(req));
718
- const root = await ensureProjectDocsRoot(String(projectId ?? ""));
719
- if (!root) return send(res, 404, { error: "Project not found" });
1509
+ const projectKey = String(projectId ?? "");
1510
+ if (!(await resolveWorkspaceProject(projectKey))) return send(res, 404, { error: "Project not found" });
1511
+ const root = await ensureProjectDocsRoot(projectKey);
1512
+ if (!root) return send(res, 400, { error: "Invalid docs root path" });
1513
+ broadcast("workspace-changed", { action: "docs-root-ready", projectId: String(projectId), actorId: actor?.actorId ?? null });
1514
+ const workspace = await getWorkspace();
1515
+ return send(res, 200, { root: { id: root.id, projectId: root.projectId, projectLabel: root.projectLabel, relativePath: root.relativePath, displayPath: root.displayPath }, workspace: await toWorkspaceResponse(workspace) });
1516
+ }
1517
+ if (route === "/__api/workspace/project/docs-area" && method === "POST") {
1518
+ const { projectId, folderName } = JSON.parse(await readBody(req));
1519
+ const project = await resolveWorkspaceProject(String(projectId ?? ""));
1520
+ if (!project) return send(res, 404, { error: "Project not found" });
1521
+ const folderAbs = await resolveSafeFilesystemPath(project.rootPath, String(folderName ?? ""));
1522
+ if (!folderAbs) return send(res, 400, { error: "Invalid docs area path" });
1523
+ const docsAbs = await resolveSafeFilesystemPath(project.rootPath, path.relative(project.rootPath, path.join(folderAbs, "docs")));
1524
+ if (!docsAbs) return send(res, 400, { error: "Invalid docs root path" });
1525
+ await fs.mkdir(docsAbs, { recursive: true });
1526
+ broadcast("workspace-changed", { action: "docs-area-ready", projectId: project.id, actorId: actor?.actorId ?? null });
720
1527
  const workspace = await getWorkspace();
1528
+ const refreshedProject = workspace.find((item) => item.id === project.id);
1529
+ const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsAbs);
1530
+ if (!root) return send(res, 500, { error: "Could not create docs area" });
721
1531
  return send(res, 200, { root: { id: root.id, projectId: root.projectId, projectLabel: root.projectLabel, relativePath: root.relativePath, displayPath: root.displayPath }, workspace: await toWorkspaceResponse(workspace) });
722
1532
  }
723
1533
  if (route === "/__api/workspace/project/create-docs-folder" && method === "POST") {
724
1534
  const { projectId, parentPath } = JSON.parse(await readBody(req));
725
- const root = await createDocsFolder(String(projectId ?? ""), String(parentPath ?? ""));
726
- if (!root) return send(res, 404, { error: "Project not found" });
1535
+ const projectKey = String(projectId ?? "");
1536
+ if (!(await resolveWorkspaceProject(projectKey))) return send(res, 404, { error: "Project not found" });
1537
+ const root = await createDocsFolder(projectKey, String(parentPath ?? ""));
1538
+ if (!root) return send(res, 400, { error: "Invalid docs folder path" });
1539
+ broadcast("workspace-changed", { action: "docs-folder-ready", projectId: String(projectId), actorId: actor?.actorId ?? null });
727
1540
  const workspace = await getWorkspace();
728
1541
  return send(res, 200, { root: { id: root.id, projectId: root.projectId, projectLabel: root.projectLabel, relativePath: root.relativePath, displayPath: root.displayPath }, workspace: await toWorkspaceResponse(workspace) });
729
1542
  }