viteboard 0.2.0 → 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.
@@ -1,11 +1,10 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
- import os from "node:os";
4
3
  import { createHash } from "node:crypto";
5
4
  import { execFile } from "node:child_process";
6
5
  import { promisify } from "node:util";
7
- import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { evaluatePageFromUrl as evaluatePageFromUrlPortable, renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
9
8
 
10
9
  const WORKSPACE_DIR = ".workspace";
11
10
  const WORKSPACE_FILE = "workspace.json";
@@ -30,18 +29,80 @@ const MIME_EXT = {
30
29
  const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
31
30
  const ACTOR_TTL_MS = 30000;
32
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
+ };
33
49
  const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
34
50
  const execFileAsync = promisify(execFile);
35
51
 
36
- export function createContentApiPlugin() {
37
- 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());
38
78
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
39
79
  const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
40
80
  const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
41
81
  const machineDir = path.join(packageRoot, "content", "machine");
42
82
  const actorSessions = new Map();
43
83
  const locks = new Map();
84
+ const mutationQueues = new Map();
44
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
+ }
45
106
 
46
107
  function send(res, status, body) {
47
108
  res.statusCode = status;
@@ -58,7 +119,12 @@ export function createContentApiPlugin() {
58
119
  function sendBinary(res, status, body, type, fileName) {
59
120
  res.statusCode = status;
60
121
  res.setHeader("Content-Type", type);
61
- if (fileName) res.setHeader("Content-Disposition", `attachment; filename="${String(fileName).replace(/[\r\n\"]/g, "-")}"`);
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
+ }
62
128
  res.end(body);
63
129
  }
64
130
 
@@ -68,6 +134,12 @@ export function createContentApiPlugin() {
68
134
  }
69
135
 
70
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
+ }
71
143
  for (const client of sseClients) {
72
144
  try { sendSse(client, event, body); } catch { sseClients.delete(client); }
73
145
  }
@@ -86,6 +158,34 @@ export function createContentApiPlugin() {
86
158
  return `${target.targetType}:${target.rootId}:${suffix}`;
87
159
  }
88
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
+
89
189
  function activeActors() { return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label)); }
90
190
  function activeLocks() { return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt); }
91
191
  function broadcastPresence() { broadcast("presence", { actors: activeActors(), locks: activeLocks() }); }
@@ -112,135 +212,17 @@ export function createContentApiPlugin() {
112
212
  }
113
213
 
114
214
  setInterval(pruneEphemeral, 10000).unref?.();
115
-
116
- function revisionForText(content) { return createHash("sha1").update(content).digest("hex"); }
117
- let chromiumBinaryPromise = null;
118
- async function resolveChromiumBinary() {
119
- if (chromiumBinaryPromise) return chromiumBinaryPromise;
120
- chromiumBinaryPromise = (async () => {
121
- for (const candidate of ["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]) {
122
- try {
123
- await execFileAsync("which", [candidate]);
124
- return candidate;
125
- } catch {
126
- /* continue */
127
- }
128
- }
129
- throw new Error("No Chromium-compatible browser is available for PDF export.");
130
- })();
131
- return chromiumBinaryPromise;
132
- }
133
- function sanitizePdfFileName(input) {
134
- const cleaned = String(input ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120) || "export";
135
- return cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
136
- }
137
- function mmToInches(mm) {
138
- return Number(mm ?? 0) / 25.4;
139
- }
140
- async function openCdpPage(port) {
141
- const res = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
142
- const target = await res.json();
143
- if (!target.webSocketDebuggerUrl) throw new Error("Could not open Chromium page target for PDF export.");
144
- return target.webSocketDebuggerUrl;
145
- }
146
- async function printPdfViaCdp(wsUrl, url, pdf) {
147
- const ws = new WebSocket(wsUrl);
148
- let nextId = 0;
149
- const pending = new Map();
150
- let loadResolve = null;
151
- const loaded = new Promise((resolve) => { loadResolve = resolve; });
152
- ws.onmessage = (event) => {
153
- const message = JSON.parse(String(event.data));
154
- if (message.method === "Page.loadEventFired") {
155
- if (loadResolve) loadResolve();
156
- loadResolve = null;
157
- return;
158
- }
159
- if (!message.id) return;
160
- const entry = pending.get(message.id);
161
- if (!entry) return;
162
- pending.delete(message.id);
163
- if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
164
- else entry.resolve(message.result);
165
- };
166
- const opened = new Promise((resolve, reject) => {
167
- ws.onopen = resolve;
168
- ws.onerror = () => reject(new Error("Could not connect to Chromium PDF export session."));
169
- });
170
- const send = (method, params = {}) => new Promise((resolve, reject) => {
171
- const id = ++nextId;
172
- pending.set(id, { resolve, reject });
173
- ws.send(JSON.stringify({ id, method, params }));
174
- });
175
- await opened;
176
- try {
177
- await send("Page.enable");
178
- await send("Runtime.enable");
179
- await send("Page.navigate", { url });
180
- await loaded;
181
- await send("Runtime.evaluate", {
182
- expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
183
- awaitPromise: true,
184
- returnByValue: true,
185
- });
186
- const result = await send("Page.printToPDF", {
187
- printBackground: true,
188
- preferCSSPageSize: true,
189
- displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
190
- headerTemplate: pdf.headerTemplate || "<div></div>",
191
- footerTemplate: pdf.footerTemplate || "<div></div>",
192
- marginTop: mmToInches(pdf.marginTopMm ?? 12),
193
- marginBottom: mmToInches(pdf.marginBottomMm ?? 12),
194
- marginLeft: mmToInches(pdf.marginLeftMm ?? 12),
195
- marginRight: mmToInches(pdf.marginRightMm ?? 12),
196
- });
197
- return Buffer.from(result.data, "base64");
198
- } finally {
199
- try { ws.close(); } catch { /* ignore */ }
200
- }
201
- }
202
- async function renderPdfFromHtml(html, fileName, pdf = {}) {
203
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
204
- const htmlPath = path.join(tmpDir, "publication.html");
205
- const profileDir = path.join(tmpDir, "profile");
206
- const browser = await resolveChromiumBinary();
207
- let browserProcess = null;
208
- try {
209
- await fs.writeFile(htmlPath, html, "utf-8");
210
- await fs.mkdir(profileDir, { recursive: true });
211
- browserProcess = execFile(browser, [
212
- "--headless",
213
- "--disable-gpu",
214
- "--no-sandbox",
215
- `--user-data-dir=${profileDir}`,
216
- "--remote-debugging-port=0",
217
- "about:blank",
218
- ], { windowsHide: true });
219
- const activePortPath = path.join(profileDir, "DevToolsActivePort");
220
- for (let attempt = 0; attempt < 100; attempt += 1) {
221
- try {
222
- const raw = await fs.readFile(activePortPath, "utf-8");
223
- const port = raw.split(/\r?\n/)[0]?.trim();
224
- if (!port) throw new Error("Chromium debugging port missing.");
225
- const wsUrl = await openCdpPage(port);
226
- return await printPdfViaCdp(wsUrl, pathToFileURL(htmlPath).href, pdf);
227
- } catch {
228
- await new Promise((resolve) => setTimeout(resolve, 100));
229
- }
230
- }
231
- throw new Error("Timed out starting Chromium PDF export session.");
232
- } finally {
233
- if (browserProcess) {
234
- const exited = new Promise((resolve) => {
235
- browserProcess?.once("exit", resolve);
236
- browserProcess?.once("close", resolve);
237
- });
238
- try { browserProcess.kill(); } catch { /* ignore */ }
239
- await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 500))]);
215
+ setInterval(() => {
216
+ for (const client of sseClients) {
217
+ if (client.destroyed || client.writableEnded) {
218
+ sseClients.delete(client);
219
+ continue;
240
220
  }
241
- await fs.rm(tmpDir, { recursive: true, force: true });
221
+ try { client.write(": keep-alive\n\n"); } catch { sseClients.delete(client); }
242
222
  }
243
- }
223
+ }, 20000).unref?.();
224
+
225
+ function revisionForText(content) { return createHash("sha1").update(content).digest("hex"); }
244
226
  function hashPath(value) {
245
227
  let hash = 2166136261;
246
228
  for (let i = 0; i < value.length; i++) {
@@ -272,6 +254,10 @@ export function createContentApiPlugin() {
272
254
  try { return path.resolve(await fs.realpath(resolved)); } catch { return resolved; }
273
255
  }
274
256
 
257
+ async function isDirectory(absPath) {
258
+ try { return (await fs.stat(absPath)).isDirectory(); } catch { return false; }
259
+ }
260
+
275
261
  function normalizeRelativePath(relPath) {
276
262
  return relPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
277
263
  }
@@ -382,9 +368,25 @@ export function createContentApiPlugin() {
382
368
  const raw = await fs.readFile(workspaceFile, "utf-8");
383
369
  const parsed = JSON.parse(raw);
384
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
+ }
385
387
  return {
386
388
  version: 1,
387
- projects: rawProjects.filter((project) => typeof project?.id === "string" && typeof project?.label === "string" && typeof project?.rootPath === "string"),
389
+ projects,
388
390
  };
389
391
  } catch { return { version: 1, projects: [] }; }
390
392
  }
@@ -399,11 +401,22 @@ export function createContentApiPlugin() {
399
401
  return rel === "" ? "docs" : rel;
400
402
  }
401
403
 
402
- 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)}`; }
403
406
  function projectIdFor(absRoot) { return `project-${hashPath(absRoot)}`; }
404
407
 
405
408
  async function discoverDocsRoots(project) {
406
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
+ }
407
420
  const walk = async (dir) => {
408
421
  let entries;
409
422
  try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
@@ -414,7 +427,7 @@ export function createContentApiPlugin() {
414
427
  if (entry.name === "docs") {
415
428
  const relativePath = normalizeRelativePath(path.relative(project.rootPath, abs));
416
429
  roots.push({
417
- id: docsRootId(project.id, abs),
430
+ id: docsRootId(abs),
418
431
  projectId: project.id,
419
432
  projectLabel: project.label,
420
433
  absPath: abs,
@@ -429,13 +442,37 @@ export function createContentApiPlugin() {
429
442
  return roots.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
430
443
  }
431
444
 
432
- async function getWorkspace() {
445
+ async function loadWorkspace() {
433
446
  const manifest = await loadManifest();
434
447
  const projects = [];
435
448
  for (const project of manifest.projects) projects.push({ ...project, docsRoots: await discoverDocsRoots(project) });
436
449
  return projects;
437
450
  }
438
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
+
439
476
  function toWorkspaceResponse(projects) {
440
477
  return {
441
478
  projects: projects.map((project) => ({
@@ -459,6 +496,34 @@ export function createContentApiPlugin() {
459
496
  return abs;
460
497
  }
461
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
+
462
527
  async function resolveRoot(rootId) {
463
528
  const projects = await getWorkspace();
464
529
  for (const project of projects) {
@@ -491,6 +556,363 @@ export function createContentApiPlugin() {
491
556
  return nodes;
492
557
  }
493
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
+
494
916
  function firstMarkdownPath(nodes) {
495
917
  for (const node of nodes) {
496
918
  if (node.type === "file" && node.name.endsWith(".md")) return node.path;
@@ -579,10 +1001,11 @@ export function createContentApiPlugin() {
579
1001
  if (!project) return null;
580
1002
  const existing = (await discoverDocsRoots(project))[0];
581
1003
  if (existing) return existing;
582
- const abs = path.join(project.rootPath, "docs");
1004
+ const abs = await resolveSafeFilesystemPath(project.rootPath, "docs");
1005
+ if (!abs) return null;
583
1006
  await fs.mkdir(abs, { recursive: true });
584
1007
  return {
585
- id: docsRootId(project.id, abs),
1008
+ id: docsRootId(abs),
586
1009
  projectId: project.id,
587
1010
  projectLabel: project.label,
588
1011
  absPath: abs,
@@ -594,12 +1017,13 @@ export function createContentApiPlugin() {
594
1017
  async function createDocsFolder(projectId, parentPath) {
595
1018
  const project = await resolveWorkspaceProject(projectId);
596
1019
  if (!project) return null;
597
- const parentAbs = safeResolve(project.rootPath, parentPath || ".");
1020
+ const parentAbs = await resolveSafeFilesystemPath(project.rootPath, parentPath || ".");
598
1021
  if (!parentAbs) return null;
599
- 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;
600
1024
  await fs.mkdir(abs, { recursive: true });
601
1025
  return {
602
- id: docsRootId(project.id, abs),
1026
+ id: docsRootId(abs),
603
1027
  projectId: project.id,
604
1028
  projectLabel: project.label,
605
1029
  absPath: abs,
@@ -621,8 +1045,8 @@ export function createContentApiPlugin() {
621
1045
  }
622
1046
 
623
1047
  async function renamePath(rootAbsPath, oldPath, newPath, ignoreMissing = false) {
624
- const oldAbs = safeResolve(rootAbsPath, oldPath);
625
- const newAbs = safeResolve(rootAbsPath, newPath);
1048
+ const oldAbs = await resolveSafeFilesystemPath(rootAbsPath, oldPath);
1049
+ const newAbs = await resolveSafeFilesystemPath(rootAbsPath, newPath);
626
1050
  if (!oldAbs || !newAbs) throw new Error("Invalid path");
627
1051
  try { await fs.access(oldAbs); } catch (err) {
628
1052
  if (ignoreMissing && err.code === "ENOENT") return;
@@ -642,6 +1066,7 @@ export function createContentApiPlugin() {
642
1066
  const method = req.method || "GET";
643
1067
  if (route === "/llms.txt" && method === "GET") return void (await sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8"));
644
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"));
645
1070
  if (route === "/board-v1.schema.json" && method === "GET") return void (await sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8"));
646
1071
  if (route === "/viteboard-example.board.json" && method === "GET") return void (await sendMachineFile(res, "example.board.json", "application/json; charset=utf-8"));
647
1072
  if (route === "/.well-known/viteboard.json" && method === "GET") return void (await sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8"));
@@ -654,6 +1079,7 @@ export function createContentApiPlugin() {
654
1079
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
655
1080
  res.setHeader("Cache-Control", "no-cache, no-transform");
656
1081
  res.setHeader("Connection", "keep-alive");
1082
+ res.setHeader("X-Accel-Buffering", "no");
657
1083
  res.flushHeaders?.();
658
1084
  sseClients.add(res);
659
1085
  sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
@@ -661,6 +1087,82 @@ export function createContentApiPlugin() {
661
1087
  return;
662
1088
  }
663
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
+
664
1166
  if (route === "/__api/workspace" && method === "GET") return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
665
1167
  if (route === "/__api/project-tree" && method === "GET") {
666
1168
  const projectId = parsed.searchParams.get("projectId") ?? "";
@@ -679,7 +1181,7 @@ export function createContentApiPlugin() {
679
1181
  const rel = parsed.searchParams.get("path") ?? "";
680
1182
  const resolved = await resolveRoot(rootId);
681
1183
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
682
- const abs = safeResolve(resolved.root.absPath, rel);
1184
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
683
1185
  if (!abs) return send(res, 400, { error: "Invalid path" });
684
1186
  try {
685
1187
  const content = await fs.readFile(abs, "utf-8");
@@ -690,44 +1192,61 @@ export function createContentApiPlugin() {
690
1192
  const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
691
1193
  const resolved = await resolveRoot(String(rootId ?? ""));
692
1194
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
693
- const abs = safeResolve(resolved.root.absPath, String(rel ?? ""));
1195
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
694
1196
  if (!abs) return send(res, 400, { error: "Invalid path" });
695
- try {
696
- const current = await fs.readFile(abs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
697
- if (expectedRevision && current !== null && revisionForText(current) !== expectedRevision) return send(res, 409, { error: "File changed on disk", currentRevision: revisionForText(current) });
698
- await fs.mkdir(path.dirname(abs), { recursive: true });
699
- const nextContent = String(content ?? "");
700
- await fs.writeFile(abs, nextContent, "utf-8");
701
- const revision = revisionForText(nextContent);
702
- broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", actorId: actor?.actorId ?? null });
703
- return send(res, 200, { ok: true, path: rel, revision });
704
- } 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
+ });
705
1215
  }
706
1216
  if (route === "/__api/file" && method === "PATCH") {
707
1217
  const { rootId, oldPath, newPath, ignoreMissing } = JSON.parse(await readBody(req));
708
1218
  const resolved = await resolveRoot(String(rootId ?? ""));
709
1219
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
710
- await renamePath(resolved.root.absPath, String(oldPath ?? ""), String(newPath ?? ""), !!ignoreMissing);
711
- broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), action: "rename", actorId: actor?.actorId ?? null });
712
- 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
+ });
713
1228
  }
714
1229
  if (route === "/__api/file" && method === "DELETE") {
715
1230
  const rootId = parsed.searchParams.get("rootId") ?? "";
716
1231
  const rel = parsed.searchParams.get("path") ?? "";
717
1232
  const resolved = await resolveRoot(rootId);
718
1233
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
719
- const abs = safeResolve(resolved.root.absPath, rel);
1234
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
720
1235
  if (!abs) return send(res, 400, { error: "Invalid path" });
721
- try { await fs.unlink(abs); } catch (err) { if (err.code !== "ENOENT") throw err; }
722
- await deleteEmptyParentDirs(path.dirname(abs), resolved.root.absPath);
723
- broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
724
- 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
+ });
725
1244
  }
726
1245
  if (route === "/__api/directory" && method === "PUT") {
727
1246
  const { rootId, path: rel } = JSON.parse(await readBody(req));
728
1247
  const resolved = await resolveRoot(String(rootId ?? ""));
729
1248
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
730
- const abs = safeResolve(resolved.root.absPath, String(rel ?? ""));
1249
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
731
1250
  if (!abs) return send(res, 400, { error: "Invalid path" });
732
1251
  await fs.mkdir(abs, { recursive: true });
733
1252
  broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "mkdir", actorId: actor?.actorId ?? null });
@@ -738,7 +1257,7 @@ export function createContentApiPlugin() {
738
1257
  const id = parsed.searchParams.get("id") ?? "";
739
1258
  const resolved = await resolveRoot(rootId);
740
1259
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
741
- const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
1260
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
742
1261
  if (!abs) return send(res, 400, { error: "Invalid id" });
743
1262
  try {
744
1263
  const content = await fs.readFile(abs, "utf-8");
@@ -749,34 +1268,111 @@ export function createContentApiPlugin() {
749
1268
  const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
750
1269
  const resolved = await resolveRoot(String(rootId ?? ""));
751
1270
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
752
- const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
1271
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
753
1272
  if (!abs) return send(res, 400, { error: "Invalid id" });
754
- const current = await fs.readFile(abs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
755
- if (expectedRevision && current !== null && revisionForText(current) !== expectedRevision) return send(res, 409, { error: "Board changed on disk", currentRevision: revisionForText(current) });
756
- await fs.mkdir(path.dirname(abs), { recursive: true });
757
- const nextContent = JSON.stringify(doc, null, 2) + "\n";
758
- await fs.writeFile(abs, nextContent, "utf-8");
759
- const revision = revisionForText(nextContent);
760
- broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: "write", actorId: actor?.actorId ?? null });
761
- 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
+ });
762
1289
  }
763
1290
  if (route === "/__api/board" && method === "DELETE") {
764
1291
  const rootId = parsed.searchParams.get("rootId") ?? "";
765
1292
  const id = parsed.searchParams.get("id") ?? "";
766
1293
  const resolved = await resolveRoot(rootId);
767
1294
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
768
- const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
1295
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
769
1296
  if (!abs) return send(res, 400, { error: "Invalid id" });
770
- try { await fs.unlink(abs); } catch (err) { if (err.code !== "ENOENT") throw err; }
771
- broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
772
- 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);
773
1369
  }
774
1370
  if (route === "/__api/asset" && method === "GET") {
775
1371
  const rootId = parsed.searchParams.get("rootId") ?? "";
776
1372
  const rel = parsed.searchParams.get("path") ?? "";
777
1373
  const resolved = await resolveRoot(rootId);
778
1374
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
779
- const abs = safeResolve(resolved.root.absPath, rel);
1375
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
780
1376
  if (!abs) return send(res, 400, { error: "Invalid path" });
781
1377
  try {
782
1378
  const buf = await fs.readFile(abs);
@@ -794,7 +1390,7 @@ export function createContentApiPlugin() {
794
1390
  const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
795
1391
  const digest = createHash("sha256").update(bytes).digest("hex");
796
1392
  const rel = `assets/${digest}${ext}`;
797
- const abs = safeResolve(resolved.root.absPath, rel);
1393
+ const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
798
1394
  if (!abs) return send(res, 400, { error: "Invalid path" });
799
1395
  await fs.mkdir(path.dirname(abs), { recursive: true });
800
1396
  try { await fs.access(abs); } catch { await fs.writeFile(abs, bytes); }
@@ -809,6 +1405,45 @@ export function createContentApiPlugin() {
809
1405
  if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
810
1406
  return send(res, 200, { ok: true, removed });
811
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
+ }
812
1447
  if (route === "/__api/export/pdf" && method === "POST") {
813
1448
  const { html, fileName, pdf } = JSON.parse(await readBody(req));
814
1449
  if (typeof html !== "string" || !html.trim()) return send(res, 400, { error: "Missing HTML payload" });
@@ -822,28 +1457,41 @@ export function createContentApiPlugin() {
822
1457
  }
823
1458
  if (route === "/__api/workspace/project" && method === "POST") {
824
1459
  const { rootPath, label } = JSON.parse(await readBody(req));
825
- 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" });
826
1463
  const manifest = await loadManifest();
827
- const projectId = projectIdFor(absRoot);
1464
+ const existingProject = manifest.projects.find((project) => project.rootPath === absRoot);
1465
+ const projectId = existingProject?.id ?? projectIdFor(absRoot);
828
1466
  const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
829
1467
  if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
830
- if (!manifest.projects.some((project) => project.id === projectId)) {
831
- 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);
832
1474
  await saveManifest(manifest);
1475
+ broadcast("workspace-changed", { action: existingProject ? "project-updated" : "project-added", projectId, actorId: actor?.actorId ?? null });
833
1476
  }
834
1477
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
835
1478
  }
836
1479
  if (route === "/__api/workspace/project" && method === "DELETE") {
837
1480
  const projectId = parsed.searchParams.get("id") ?? "";
838
1481
  const manifest = await loadManifest();
1482
+ const previousCount = manifest.projects.length;
839
1483
  manifest.projects = manifest.projects.filter((project) => project.id !== projectId);
840
- 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
+ }
841
1488
  return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
842
1489
  }
843
1490
  if (route === "/__api/workspace/pick-project" && method === "POST") {
844
1491
  const picked = await pickProjectFromSystemDialog();
845
1492
  if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
846
1493
  const absRoot = await canonicalizeProjectRoot(picked);
1494
+ if (!(await isDirectory(absRoot))) return send(res, 400, { error: "Project root not found" });
847
1495
  const manifest = await loadManifest();
848
1496
  const projectId = projectIdFor(absRoot);
849
1497
  const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
@@ -851,21 +1499,44 @@ export function createContentApiPlugin() {
851
1499
  if (!manifest.projects.some((project) => project.id === projectId)) {
852
1500
  manifest.projects.push({ id: projectId, label: path.basename(absRoot) || "Project", rootPath: absRoot });
853
1501
  await saveManifest(manifest);
1502
+ broadcast("workspace-changed", { action: "project-added", projectId, actorId: actor?.actorId ?? null });
854
1503
  }
855
1504
  const workspace = await getWorkspace();
856
1505
  return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(workspace), addedProjectId: projectId });
857
1506
  }
858
1507
  if (route === "/__api/workspace/project/docs-root" && method === "POST") {
859
1508
  const { projectId } = JSON.parse(await readBody(req));
860
- const root = await ensureProjectDocsRoot(String(projectId ?? ""));
861
- 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 });
862
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" });
863
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) });
864
1532
  }
865
1533
  if (route === "/__api/workspace/project/create-docs-folder" && method === "POST") {
866
1534
  const { projectId, parentPath } = JSON.parse(await readBody(req));
867
- const root = await createDocsFolder(String(projectId ?? ""), String(parentPath ?? ""));
868
- 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 });
869
1540
  const workspace = await getWorkspace();
870
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) });
871
1542
  }