pindeck 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -12,16 +12,18 @@
12
12
  - stores figures and audio attached to chat messages in <deck>/figures/ for Claude to use, and attached
13
13
  PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
14
14
  - imports slides from PowerPoint files in the deck's own layouts
15
- Listens on 127.0.0.1 only; every API call needs the per-run token.
15
+ Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
16
+ cookie in the browser, a header for the CLI).
16
17
  Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
17
18
 
18
19
  import http from "node:http";
20
+ import { EventEmitter } from "node:events";
19
21
  import fs from "node:fs";
20
22
  import os from "node:os";
21
23
  import path from "node:path";
22
24
  import crypto from "node:crypto";
23
25
  import { spawn, execFile } from "node:child_process";
24
- import { fileURLToPath } from "node:url";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
27
  import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
26
28
  import { unifiedDiff } from "./lib/diff.mjs";
27
29
  import { ClaudeSession } from "./lib/claude.mjs";
@@ -30,22 +32,31 @@ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs
30
32
  import { readPptx } from "./lib/pptx.mjs";
31
33
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
32
34
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
35
+ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
36
+ import { writeAtomic, sameContent, revision } from "./lib/deck-files.mjs";
33
37
 
34
38
  /* ---- Config ------------------------------------------------------------------ */
35
39
  const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
40
+ pindeck login [deck-folder]
36
41
 
37
42
  Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
38
- The folder must contain index.html.`;
43
+ The folder must contain index.html.
44
+ login prints a new one-time login link for the PinDeck running for that deck
45
+ (without a folder: for the PinDeck started last).`;
39
46
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
40
47
  const argv = process.argv.slice(2);
41
48
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
42
49
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
43
- const PORT = +opt("--port", process.env.PORT || 5180);
44
- if (process.platform !== "darwin") {
50
+ const portArg = opt("--port", process.env.PORT || 5180);
51
+ let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
52
+ if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
53
+ if (!isSupportedPlatform(process.platform)) {
45
54
  const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
46
55
  await showPlatformNotice(PORT);
47
56
  process.exit(0);
48
57
  }
58
+ const LOGIN = argv[0] === "login";
59
+ if (LOGIN) argv.shift();
49
60
  const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
50
61
  const DECK_DIR = path.resolve(argv[0] || process.cwd());
51
62
  const DECK_FILE = "index.html";
@@ -57,35 +68,100 @@ const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
57
68
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
58
69
  const SHOT_DIR = path.join(STATE_DIR, "shots");
59
70
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
71
+ /* pindeck login: a new one-time login link from the running server, asked with the token in its server
72
+ file (readable only by this user) */
73
+ if (LOGIN) {
74
+ const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
75
+ let info = null;
76
+ for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
77
+ if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
78
+ try {
79
+ const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
80
+ if (!res.ok) throw new Error(res.status);
81
+ console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
82
+ process.exit(0);
83
+ } catch {
84
+ console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
85
+ process.exit(1);
86
+ }
87
+ }
60
88
  const TOKEN = crypto.randomBytes(16).toString("hex");
61
- const ORIGIN = `http://127.0.0.1:${PORT}`;
89
+ let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
62
90
  const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
63
- const CLI_CMD = /\s/.test(CLI_REL) ? `node "${CLI_REL}"` : `node ${CLI_REL}`;
91
+ const CLI_CMD = cliCommand(CLI_REL);
92
+ const CAPS = detectCapabilities();
64
93
 
65
- if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
66
- console.error(`No ${DECK_FILE} in ${DECK_DIR}\n\n${USAGE}`);
94
+ /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
95
+ function checkDeck() {
96
+ const file = path.join(DECK_DIR, DECK_FILE);
97
+ if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
98
+ try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
99
+ catch { return `${file} cannot be read and written (check its permissions).`; }
100
+ // saving replaces the file through a temporary file next to it, so the folder must be writable too
101
+ try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
102
+ catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
103
+ return null;
104
+ }
105
+ const deckProblem = checkDeck();
106
+ if (deckProblem) {
107
+ console.error(`${deckProblem}\n\n${USAGE}`);
67
108
  process.exit(1);
68
109
  }
69
- fs.mkdirSync(SHOT_DIR, { recursive: true });
70
- const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
71
- fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
72
- fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
110
+ /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
111
+ port is bound (see Start), so a failed start leaves no state behind. */
112
+ const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
113
+ function writeServerInfo() {
114
+ for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
115
+ const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
116
+ for (const f of SERVER_FILES) {
117
+ fs.writeFileSync(f, info, { mode: 0o600 });
118
+ fs.chmodSync(f, 0o600);
119
+ }
120
+ }
121
+ /* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
122
+ function removeServerInfo() {
123
+ for (const f of SERVER_FILES) {
124
+ try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
125
+ }
126
+ }
73
127
 
74
128
  /* ---- Small helpers --------------------------------------------------------------- */
75
129
  const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
76
130
  mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
77
131
  jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
78
132
  woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
79
- mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
133
+ ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
134
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
80
135
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
81
136
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
82
137
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
83
138
  function writeDeck(f, content, manual = false) {
84
139
  ourWrites.set(f, { hash: hash(content), manual });
85
- fs.writeFileSync(path.join(DECK_DIR, f), content);
140
+ writeAtomic(path.join(DECK_DIR, f), content);
141
+ }
142
+ /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
143
+ function writeDeckFile(rel, data, manual = true) {
144
+ const abs = path.join(DECK_DIR, rel);
145
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
146
+ ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
147
+ writeAtomic(abs, data);
86
148
  }
87
149
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
88
- const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
150
+ /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
151
+ const deckRev = () => revision(DECK_DIR, EDITABLE);
152
+ const json = (res, code, obj) => {
153
+ res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
154
+ res.end(JSON.stringify(obj));
155
+ };
156
+ /* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
157
+ written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
158
+ notes sent while the tab closes) are not checked. */
159
+ function staleEdit(res, b) {
160
+ if (b.rev == null || b.rev === deckRev()) return false;
161
+ json(res, 409, { code: "stale", rev: deckRev(),
162
+ error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
163
+ return true;
164
+ }
89
165
  const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
90
166
  async function body(req) {
91
167
  let s = "";
@@ -109,12 +185,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
109
185
  let backedUp = false;
110
186
  function runEidPass() {
111
187
  const html = readDeck(DECK_FILE);
188
+ if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
112
189
  const r = assignEids(html);
113
190
  if (r.changed) {
114
191
  if (!backedUp) {
115
192
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
116
- fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
117
- fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
193
+ fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
194
+ fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
118
195
  backedUp = true;
119
196
  }
120
197
  writeDeck(DECK_FILE, r.html);
@@ -131,11 +208,12 @@ function runEidPass() {
131
208
  let gitFilter = null; // null: not checked yet
132
209
  const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
133
210
  function refreshGit() {
134
- const run = () => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
211
+ // another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
212
+ const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
135
213
  if (e1 || !cleaned) return;
136
214
  git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
137
215
  const staged = entry.split(/\s+/)[1];
138
- if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
216
+ if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
139
217
  });
140
218
  });
141
219
  if (gitFilter === null) {
@@ -157,23 +235,19 @@ function resolveSlide(spec) {
157
235
  }
158
236
 
159
237
  /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
160
- const CHROME = [process.env.CHROME_BIN,
161
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
162
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
163
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
164
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
165
- "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
238
+ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
166
239
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
167
240
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
168
241
  let chromeSeq = 0;
169
242
  function chrome(args, done) {
170
- if (!CHROME) return Promise.reject(new Error("No Chrome/Chromium found (set CHROME_BIN)."));
243
+ if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
171
244
  return new Promise((resolve, reject) => {
172
245
  // a profile per run: concurrent runs must not share one
173
246
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
174
247
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
175
248
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
176
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000", ...args]);
249
+ "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
250
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
177
251
  let out = "", err = "", finished = false;
178
252
  const finish = (fn, v) => {
179
253
  if (finished) return;
@@ -187,7 +261,8 @@ function chrome(args, done) {
187
261
  proc.stdout.on("data", (d) => { out += d; check(); });
188
262
  proc.stderr.on("data", (d) => { err += d; check(); });
189
263
  proc.on("error", (e) => finish(reject, e));
190
- proc.on("exit", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
264
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
265
+ proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
191
266
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
192
267
  });
193
268
  }
@@ -226,7 +301,11 @@ function measureInEditor(slide) {
226
301
  pending.set(id, { resolve, reject, t });
227
302
  });
228
303
  }
229
- const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(slide));
304
+ const measure = (slide) => measureInEditor(slide).catch((e) => {
305
+ if (CAPS.headlessLayout.available) return measureHeadless(slide);
306
+ throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
307
+ `headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
308
+ });
230
309
 
231
310
  /* PDF export. handout: one page per slide, build steps in their final state.
232
311
  presentation: one page per build step (a slide with two clicks gives three pages).
@@ -234,18 +313,19 @@ const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(sl
234
313
  let exportQueue = Promise.resolve(), snapSeq = 0;
235
314
  const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
236
315
  function exportPdf(kind, includeHidden = false) {
237
- if (!CHROME) return Promise.reject(new Error("PDF export needs Chrome or Chromium (set CHROME_BIN)."));
316
+ if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
238
317
  const split = kind === "presentation";
239
318
  const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
240
319
  const out = path.join(DECK_DIR, "export", name);
241
320
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
242
321
  const run = async () => {
243
322
  fs.mkdirSync(path.dirname(out), { recursive: true });
244
- const html = await printLayout(CHROME,
245
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`, profile("layout"));
323
+ const layoutProfile = profile("layout"), printProfile = profile("print");
324
+ const html = await printLayout(CHROME, chromeStart(layoutProfile,
325
+ `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
246
326
  const id = `${process.pid}-${++snapSeq}`;
247
327
  snapshots.set(id, html);
248
- try { await printPdf(CHROME, `${ORIGIN}/deck/__pindeck-print-${id}.html`, out, profile("print")); }
328
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}/deck/__pindeck-print-${id}.html`), out, printProfile); }
249
329
  finally { snapshots.delete(id); }
250
330
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
251
331
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -267,8 +347,15 @@ try {
267
347
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
268
348
  Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
269
349
  } catch {}
270
- const saveChat = () => fs.writeFile(CHAT_FILE,
271
- JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId }), () => {});
350
+ /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
351
+ let chatTimer = null;
352
+ function writeChat() {
353
+ clearTimeout(chatTimer);
354
+ chatTimer = null;
355
+ try { writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId })); }
356
+ catch (e) { console.error(e.message); }
357
+ }
358
+ const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
272
359
  function addEntry(entry) {
273
360
  entry.id = ++state.seq;
274
361
  entry.time = Date.now();
@@ -295,28 +382,36 @@ else if (fileConfig.model !== undefined) console.warn(`config.json: unknown mode
295
382
  if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
296
383
  else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
297
384
  const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
298
- if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings(); // create or complete the file
385
+ const completeSettings = () => { // create or complete the file (at start, once the port is bound)
386
+ if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
387
+ };
299
388
  if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
300
389
 
301
390
  const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
302
- running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
391
+ running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
392
+ capabilities: publicCapabilities(CAPS), rev: deckRev(),
303
393
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
304
394
  const pushStatus = () => broadcast("status", status());
305
395
 
306
396
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
307
- .replaceAll("{{CLI}}", CLI_CMD);
397
+ .replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD);
308
398
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
309
399
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
310
400
  MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
311
- the system prompt instead, read afresh at each session start. */
401
+ the system prompt instead, read afresh at each session start, together with the deck's AGENTS.md (the
402
+ name other coding agents read). Decks copied from the example call the CLI as `node ../cli.mjs`, which
403
+ is right only next to PinDeck: that becomes this server's command. */
312
404
  function deckInstructions() {
313
- try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
314
- catch { return ""; }
405
+ return ["CLAUDE.md", "AGENTS.md"].map((name) => {
406
+ let s;
407
+ try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
408
+ return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
409
+ }).join("");
315
410
  }
316
411
  let stderrTail = "";
317
412
  const session = new ClaudeSession({
318
413
  cwd: DECK_DIR,
319
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
414
+ env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
320
415
  args: () => [
321
416
  "--append-system-prompt", systemPrompt + deckInstructions(),
322
417
  "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
@@ -338,7 +433,10 @@ const session = new ClaudeSession({
338
433
  },
339
434
  });
340
435
  function ensureSession() {
341
- if (!session.running) { stderrTail = ""; session.start(state.sessionId); pushStatus(); }
436
+ if (session.running || quitting || !detectCapabilities().claude.available) return;
437
+ stderrTail = "";
438
+ session.start(state.sessionId);
439
+ pushStatus();
342
440
  }
343
441
 
344
442
  function toolSummary(name, input = {}) {
@@ -398,6 +496,7 @@ function startTurn(promptText, userEntry) {
398
496
  state.busy = true;
399
497
  pushStatus();
400
498
  ensureSession();
499
+ if (!session.running) return finishTurn({ error: detectCapabilities().claude.reason || "Claude Code could not be started." });
401
500
  session.send([{ type: "text", text: promptText }]);
402
501
  }
403
502
 
@@ -503,7 +602,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
503
602
  screenshots folder, for Claude to look at before choosing; with one, into the deck. */
504
603
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
505
604
  const bad = (t, status = 400) => ({ status, text: t });
506
- if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
605
+ if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
507
606
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
508
607
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
509
608
  [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
@@ -526,18 +625,25 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
526
625
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
527
626
  const buf = fs.readFileSync(tmp);
528
627
  fs.rmSync(tmp, { force: true });
529
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
530
- ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
531
- fs.writeFileSync(path.join(DECK_DIR, out), buf);
628
+ writeDeckFile(out, buf);
532
629
  state.pdfFigures.add(out);
533
630
  return { text: `${out} (${size})` };
534
631
  }
535
632
 
536
- /* ---- File watching -------------------------------------------------------------------------- */
537
- let changed = new Set(), flush = null, eidTimer = null;
538
- fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
539
- if (!file || /(^|\/)\.|\.bak$|~$|^export[\/]/.test(file)) return;
540
- changed.add(file.split(path.sep).join("/"));
633
+ /* ---- File watching --------------------------------------------------------------------------
634
+ Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
635
+ events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
636
+ editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
637
+ files), it is started again and the editor told to reload everything. */
638
+ let changed = new Set(), flush = null, eidTimer = null, watcher = null;
639
+ function onFileEvent(_ev, file) {
640
+ if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
641
+ file = String(file).split(path.sep).join("/");
642
+ if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
643
+ changed.add(file);
644
+ scheduleFlush();
645
+ }
646
+ function scheduleFlush() {
541
647
  clearTimeout(flush);
542
648
  flush = setTimeout(() => {
543
649
  const files = [...changed];
@@ -553,14 +659,71 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
553
659
  if (external) {
554
660
  state.notes = [...new Set([...state.notes,
555
661
  "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
556
- // new elements from a hand edit get ids once the file is quiet
662
+ // new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
557
663
  clearTimeout(eidTimer);
558
- eidTimer = setTimeout(() => { if (!state.busy) runEidPass(); }, 800);
664
+ const seen = hash(readDeck(DECK_FILE) ?? "");
665
+ const pass = () => {
666
+ if (state.busy) return;
667
+ const now = readDeck(DECK_FILE);
668
+ if (now == null || hash(now) !== seen) return; // still being written; its own event comes
669
+ runEidPass();
670
+ };
671
+ eidTimer = setTimeout(pass, 800);
559
672
  }
560
673
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
561
- broadcast("files", { files, manual });
674
+ broadcast("files", { files, manual, rev: deckRev() });
562
675
  }, 120);
563
- });
676
+ }
677
+ /* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
678
+ rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
679
+ every folder (inotify on directories) keeps working; folders created later are added as they appear.
680
+ Hidden folders (.git) and node_modules are skipped. */
681
+ function watchFolders(root, onEvent) {
682
+ const watchers = new Map(), tree = new EventEmitter();
683
+ const add = (dir) => {
684
+ if (watchers.has(dir)) return;
685
+ let w;
686
+ try {
687
+ w = fs.watch(dir, (ev, name) => {
688
+ if (name == null) return onEvent(ev, null);
689
+ const abs = path.join(dir, String(name));
690
+ if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
691
+ onEvent(ev, path.relative(root, abs));
692
+ });
693
+ } catch (e) {
694
+ if (dir === root) throw e;
695
+ return;
696
+ }
697
+ w.on("error", (e) => {
698
+ try { w.close(); } catch {}
699
+ watchers.delete(dir);
700
+ if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
701
+ });
702
+ watchers.set(dir, w);
703
+ };
704
+ const scan = (dir) => {
705
+ add(dir);
706
+ let entries = [];
707
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
708
+ for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
709
+ };
710
+ scan(root);
711
+ tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
712
+ return tree;
713
+ }
714
+ function startWatcher() {
715
+ watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
716
+ : fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
717
+ watcher.on("error", (e) => {
718
+ console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
719
+ try { watcher.close(); } catch {}
720
+ setTimeout(() => {
721
+ if (quitting) return;
722
+ try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
723
+ broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
724
+ }, 1000);
725
+ });
726
+ }
564
727
 
565
728
  /* ---- Hand edits ----------------------------------------------------------------------------------------- */
566
729
  function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
@@ -597,21 +760,46 @@ function deletePlan(eid) {
597
760
  return { ...target, html: rest, files };
598
761
  }
599
762
 
600
- /* ---- Undo (Claude's turns and hand edits share one history) ---------------------------------------- */
763
+ /* ---- Undo (Claude's turns and hand edits share one history) ----------------------------------------
764
+ Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
765
+ every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
766
+ files, and the pictures or audio files they would delete or bring back. Anything else means the files
767
+ were changed since, by hand or by another program, and going back would silently throw that work away.
768
+ Then nothing is written and the answer is 409 with the files that differ. */
769
+ function undoConflicts(t, direction) {
770
+ const expect = direction === "undo" ? t.after : t.before;
771
+ const files = EDITABLE.filter((f) => (readDeck(f) ?? null) !== (expect[f] ?? null));
772
+ // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
773
+ // ones (which must be unchanged); redo the other way round
774
+ const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
775
+ const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
776
+ for (const { rel, data } of comeBack) {
777
+ const abs = path.join(DECK_DIR, rel);
778
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
779
+ }
780
+ for (const { rel, data } of go) {
781
+ const abs = path.join(DECK_DIR, rel);
782
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
783
+ }
784
+ return files;
785
+ }
786
+ function conflict(res, direction, files) {
787
+ json(res, 409, { code: "diverged", files,
788
+ error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
789
+ `it would overwrite those changes. Reload to see the files as they are.` });
790
+ }
601
791
  function undo(res) {
602
792
  if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
603
793
  const t = liveTurns().at(-1);
604
794
  if (!t) return json(res, 409, { error: "Nothing to undo." });
605
- const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
795
+ const diverged = undoConflicts(t, "undo");
796
+ if (diverged.length) return conflict(res, "undo", diverged);
606
797
  for (const f of EDITABLE) {
607
798
  if (readDeck(f) === t.before[f]) continue;
608
799
  if (t.before[f] != null) writeDeck(f, t.before[f]);
609
800
  else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
610
801
  }
611
- for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
612
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
613
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
614
- }
802
+ for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
615
803
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
616
804
  t.undone = true;
617
805
  state.redo.push(t);
@@ -619,7 +807,6 @@ function undo(res) {
619
807
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
620
808
  : `The user undid your change from turn ${t.id}; ${t.files.map((f) => f.file).join(" and ")} are back to the state before it. Re-read before editing.`);
621
809
  broadcast("undone", { turnId: t.id, undone: true });
622
- if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
623
810
  pushStatus();
624
811
  return json(res, 200, {});
625
812
  }
@@ -631,7 +818,6 @@ function undo(res) {
631
818
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
632
819
  const IMPORT_MAX = 500 * 1024 * 1024;
633
820
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
634
- fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
635
821
 
636
822
  function startImport(name, buf) {
637
823
  const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
@@ -678,12 +864,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
678
864
  const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
679
865
  const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
680
866
  const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
681
- for (const f of r.files) {
682
- const abs = path.join(DECK_DIR, f.rel);
683
- fs.mkdirSync(path.dirname(abs), { recursive: true });
684
- ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
685
- fs.writeFileSync(abs, f.data);
686
- }
867
+ for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
687
868
  const all = findSlides(parse(html));
688
869
  const first = afterIdx + 1, last = afterIdx + r.fragments.length;
689
870
  const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
@@ -741,31 +922,122 @@ async function refineImport(imp, pdf, plans, eids, first) {
741
922
 
742
923
  /* ---- HTTP ------------------------------------------------------------------------------------- */
743
924
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
744
- const HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
925
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
926
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
927
+ const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
745
928
 
746
- function serveFile(res, file, transform) {
747
- fs.readFile(file, (err, data) => {
929
+ /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
930
+ where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
931
+ code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
932
+ and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
933
+ carries this server's port. */
934
+ const loginCodes = new Map(); // code -> expiry (ms)
935
+ function loginCode(ttl) {
936
+ const now = Date.now();
937
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
938
+ const code = crypto.randomBytes(16).toString("hex");
939
+ loginCodes.set(code, now + ttl);
940
+ return code;
941
+ }
942
+ const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
943
+ const cookieName = () => `pindeck-${PORT}`;
944
+ const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
945
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
946
+ function cookie(req, name) {
947
+ for (const part of String(req.headers.cookie || "").split(";")) {
948
+ const i = part.indexOf("=");
949
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
950
+ }
951
+ return null;
952
+ }
953
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
954
+ const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
955
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
956
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
957
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
958
+ PinDeck checkout).</p>`;
959
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
960
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
961
+ function login(res, url) {
962
+ const code = url.searchParams.get("code") || "";
963
+ const exp = loginCodes.get(code);
964
+ loginCodes.delete(code);
965
+ if (!exp || exp < Date.now()) {
966
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
967
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
968
+ }
969
+ let next = url.searchParams.get("next") || "/";
970
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
971
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
972
+ "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
973
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
974
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
975
+ }
976
+ /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
977
+ its command line: the page sends it to a one-time login code, which leads on to the target. */
978
+ function chromeStart(profile, target) {
979
+ fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
980
+ const start = path.join(profile, "pindeck-start.html");
981
+ const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
982
+ fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
983
+ return pathToFileURL(start).href;
984
+ }
985
+
986
+ /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
987
+ Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
988
+ function serveFile(res, file, transform, req) {
989
+ const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
990
+ if (transform) return fs.readFile(file, (err, data) => {
748
991
  if (err) return text(res, 404, "Not found");
749
- const ext = path.extname(file).slice(1).toLowerCase();
750
- res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream", "cache-control": "no-store" });
751
- res.end(transform ? transform(String(data)) : data);
992
+ const out = Buffer.from(transform(String(data)));
993
+ res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
994
+ res.end(out);
995
+ });
996
+ fs.stat(file, (err, st) => {
997
+ if (err || !st.isFile()) return text(res, 404, "Not found");
998
+ const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
999
+ let start = 0, end = st.size - 1, code = 200;
1000
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1001
+ if (range && (range[1] || range[2])) {
1002
+ if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
1003
+ else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
1004
+ if (start > end || start >= st.size) {
1005
+ res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
1006
+ return res.end();
1007
+ }
1008
+ code = 206;
1009
+ headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
1010
+ }
1011
+ headers["content-length"] = st.size ? end - start + 1 : 0;
1012
+ res.writeHead(code, headers);
1013
+ if (!st.size) return res.end();
1014
+ const stream = fs.createReadStream(file, { start, end });
1015
+ stream.on("error", () => res.destroy());
1016
+ res.on("close", () => stream.destroy());
1017
+ stream.pipe(res);
752
1018
  });
753
1019
  }
754
1020
 
1021
+ let ready = false;
755
1022
  const server = http.createServer(async (req, res) => {
756
- // only pages opened as 127.0.0.1 or localhost: another site's name pointed at 127.0.0.1 (DNS rebinding)
757
- // must not read the editor page, its token or the deck
758
- if (!HOSTS.has(String(req.headers.host || "").toLowerCase())) return text(res, 403, "Bad host");
1023
+ if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1024
+ const host = String(req.headers.host || "").toLowerCase();
1025
+ if (!goodHost(host)) return text(res, 403, "Bad host");
759
1026
  const url = new URL(req.url, ORIGIN);
760
1027
  const p = url.pathname;
761
1028
  try {
762
- // UI and deck files (read-only)
763
- if (req.method === "GET" && (p === "/" || p === "/index.html"))
764
- return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("%%TOKEN%%", TOKEN));
1029
+ if (req.method === "GET" && p === "/login") return login(res, url);
1030
+ // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1031
+ const ok = authed(req);
1032
+ if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1033
+ if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1034
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1035
+ }
765
1036
  if (req.method === "GET" && p.startsWith("/editor/")) {
766
1037
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
767
- return f ? serveFile(res, f) : text(res, 403, "Forbidden");
1038
+ return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
768
1039
  }
1040
+ if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
769
1041
  let snap;
770
1042
  if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
771
1043
  const html = snapshots.get(snap[1]);
@@ -777,7 +1049,7 @@ const server = http.createServer(async (req, res) => {
777
1049
  const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
778
1050
  if (!f) return text(res, 403, "Forbidden");
779
1051
  const mode = url.searchParams.get("editor");
780
- if (!mode || !f.endsWith(".html")) return serveFile(res, f);
1052
+ if (!mode || !f.endsWith(".html")) return serveFile(res, f, null, req);
781
1053
  return serveFile(res, f, (s) => {
782
1054
  // headless runs must not stream media, or Chrome never finishes the page
783
1055
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -786,23 +1058,24 @@ const server = http.createServer(async (req, res) => {
786
1058
  });
787
1059
  }
788
1060
 
789
- // API: token required; POSTs must come from the editor page itself
1061
+ // API: token required (header or login cookie); POSTs must come from the editor page itself
790
1062
  if (!p.startsWith("/api/")) return text(res, 404, "Not found");
791
- const tok = req.headers["x-editor-token"] || url.searchParams.get("t");
792
- if (tok !== TOKEN) return text(res, 403, "Bad token");
1063
+ if (!ok) return text(res, 403, "Bad token");
793
1064
  const origin = req.headers.origin;
794
- if (req.method === "POST" && origin && origin !== ORIGIN && origin !== `http://localhost:${PORT}`)
1065
+ if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
795
1066
  return text(res, 403, "Bad origin");
796
1067
 
797
1068
  if (p === "/api/events") {
798
1069
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
799
- res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
1070
+ res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
800
1071
  turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
801
1072
  clients.add(res);
802
1073
  req.on("close", () => clients.delete(res));
803
1074
  return;
804
1075
  }
805
1076
  if (p === "/api/ping") return json(res, 200, { ok: true });
1077
+ if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1078
+ if (p === "/api/status") return json(res, 200, status());
806
1079
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
807
1080
  if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
808
1081
  if (p === "/api/measure-result" && req.method === "POST") {
@@ -814,6 +1087,9 @@ const server = http.createServer(async (req, res) => {
814
1087
 
815
1088
  if (p === "/api/message" && req.method === "POST") {
816
1089
  if (state.busy) return json(res, 409, { error: "Claude is still working." });
1090
+ // checked again here: the claude command may have been installed (or removed) since the start
1091
+ const claude = detectCapabilities().claude;
1092
+ if (!claude.available) return json(res, 503, { code: "claude-unavailable", error: claude.reason });
817
1093
  const b = await body(req);
818
1094
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
819
1095
  // what the prompt reads from pins and the selection must be there
@@ -853,18 +1129,16 @@ const server = http.createServer(async (req, res) => {
853
1129
  const buf = Buffer.concat(chunks);
854
1130
  if (kind === "pdf") {
855
1131
  if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
856
- fs.mkdirSync(PDF_DIR, { recursive: true });
1132
+ fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
857
1133
  const abs = attachPath(original, PDF_DIR);
858
- fs.writeFileSync(abs, buf);
1134
+ fs.writeFileSync(abs, buf, { mode: 0o600 });
859
1135
  state.uploads.add(abs);
860
1136
  let info = null;
861
- if (process.platform === "darwin") { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
1137
+ if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
862
1138
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
863
1139
  }
864
1140
  const rel = attachPath(original);
865
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
866
- ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
867
- fs.writeFileSync(path.join(DECK_DIR, rel), buf);
1141
+ writeDeckFile(rel, buf);
868
1142
  state.uploads.add(rel);
869
1143
  return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
870
1144
  }
@@ -886,7 +1160,7 @@ const server = http.createServer(async (req, res) => {
886
1160
  Object.assign(settings, { model, effort });
887
1161
  saveSettings();
888
1162
  // restart the session with the new flags; --resume keeps the conversation
889
- if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
1163
+ if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
890
1164
  addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
891
1165
  pushStatus();
892
1166
  return json(res, 200, { settings });
@@ -894,6 +1168,7 @@ const server = http.createServer(async (req, res) => {
894
1168
  if (p === "/api/slide-hidden" && req.method === "POST") {
895
1169
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
896
1170
  const b = await body(req);
1171
+ if (staleEdit(res, b)) return;
897
1172
  const before = snapshot(EDITABLE);
898
1173
  const slides = findSlides(parse(before[DECK_FILE]));
899
1174
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
@@ -909,6 +1184,7 @@ const server = http.createServer(async (req, res) => {
909
1184
  if (p === "/api/move-slide" && req.method === "POST") {
910
1185
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
911
1186
  const b = await body(req);
1187
+ if (staleEdit(res, b)) return;
912
1188
  const before = snapshot(EDITABLE);
913
1189
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
914
1190
  if (r.error) return json(res, 409, { error: r.error });
@@ -921,6 +1197,7 @@ const server = http.createServer(async (req, res) => {
921
1197
  if (p === "/api/add-slide" && req.method === "POST") {
922
1198
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
923
1199
  const b = await body(req);
1200
+ if (staleEdit(res, b)) return;
924
1201
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
925
1202
  const before = snapshot(EDITABLE);
926
1203
  const r = insertSlide(before[DECK_FILE], b.kind, b.after);
@@ -935,6 +1212,7 @@ const server = http.createServer(async (req, res) => {
935
1212
  return json(res, 200, { turnId, index: r.index, eid });
936
1213
  }
937
1214
  if (p === "/api/import" && req.method === "POST") {
1215
+ if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
938
1216
  const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
939
1217
  if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
940
1218
  const chunks = [];
@@ -960,6 +1238,7 @@ const server = http.createServer(async (req, res) => {
960
1238
  if (p === "/api/import-apply" && req.method === "POST") {
961
1239
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
962
1240
  const b = await body(req);
1241
+ if (staleEdit(res, b)) return;
963
1242
  const imp = lastImport;
964
1243
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
965
1244
  const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
@@ -1002,18 +1281,16 @@ const server = http.createServer(async (req, res) => {
1002
1281
  if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
1003
1282
  const t = state.redo.at(-1);
1004
1283
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1005
- if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
1006
- state.redo = [];
1007
- pushStatus();
1008
- return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
1009
- }
1284
+ const diverged = undoConflicts(t, "redo");
1285
+ if (diverged.length) return conflict(res, "redo", diverged);
1010
1286
  state.redo.pop();
1011
- for (const f of EDITABLE) if (t.after[f] != null) writeDeck(f, t.after[f]);
1012
- for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1013
- for (const { rel, data } of t.addedFiles || []) {
1014
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
1015
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
1287
+ for (const f of EDITABLE) {
1288
+ if (readDeck(f) === t.after[f]) continue;
1289
+ if (t.after[f] != null) writeDeck(f, t.after[f]);
1290
+ else fs.rmSync(path.join(DECK_DIR, f), { force: true });
1016
1291
  }
1292
+ for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1293
+ for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1017
1294
  t.undone = false;
1018
1295
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1019
1296
  : `The user redid your change from turn ${t.id} after undoing it.`);
@@ -1031,6 +1308,7 @@ const server = http.createServer(async (req, res) => {
1031
1308
  if (p === "/api/delete" && req.method === "POST") {
1032
1309
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1033
1310
  const b = await body(req);
1311
+ if (staleEdit(res, b)) return;
1034
1312
  const plan = deletePlan(b.eid);
1035
1313
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1036
1314
  const before = snapshot(EDITABLE);
@@ -1050,6 +1328,7 @@ const server = http.createServer(async (req, res) => {
1050
1328
  if (p === "/api/edit" && req.method === "POST") {
1051
1329
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1052
1330
  const b = await body(req);
1331
+ if (staleEdit(res, b)) return;
1053
1332
  const before = snapshot(EDITABLE);
1054
1333
  const r = setStyles(before[DECK_FILE], b.ops || []);
1055
1334
  if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
@@ -1071,6 +1350,7 @@ const server = http.createServer(async (req, res) => {
1071
1350
  if (p === "/api/notes" && req.method === "POST") {
1072
1351
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1073
1352
  const b = await body(req);
1353
+ if (staleEdit(res, b)) return;
1074
1354
  const before = snapshot(EDITABLE);
1075
1355
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
1076
1356
  if (!r) return json(res, 404, { error: "Slide not found in the file." });
@@ -1085,6 +1365,7 @@ const server = http.createServer(async (req, res) => {
1085
1365
  if (p === "/api/text" && req.method === "POST") {
1086
1366
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1087
1367
  const b = await body(req);
1368
+ if (staleEdit(res, b)) return;
1088
1369
  const before = snapshot(EDITABLE);
1089
1370
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
1090
1371
  if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1100,6 +1381,7 @@ const server = http.createServer(async (req, res) => {
1100
1381
  if (p === "/api/split" && req.method === "POST") {
1101
1382
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1102
1383
  const b = await body(req);
1384
+ if (staleEdit(res, b)) return;
1103
1385
  const before = snapshot(EDITABLE);
1104
1386
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1105
1387
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1112,6 +1394,7 @@ const server = http.createServer(async (req, res) => {
1112
1394
  if (p === "/api/remove" && req.method === "POST") {
1113
1395
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1114
1396
  const b = await body(req);
1397
+ if (staleEdit(res, b)) return;
1115
1398
  const before = snapshot(EDITABLE);
1116
1399
  const html = removeElement(before[DECK_FILE], b.eid);
1117
1400
  if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1123,13 +1406,14 @@ const server = http.createServer(async (req, res) => {
1123
1406
  if (p === "/api/export" && req.method === "POST") {
1124
1407
  const b = await body(req);
1125
1408
  try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
1126
- catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1409
+ catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
1127
1410
  }
1128
1411
 
1129
1412
  // CLI endpoints (used by Claude Code via cli.mjs)
1130
1413
  if (p === "/api/pdf") {
1131
1414
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1132
1415
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
1416
+ if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
1133
1417
  const r = await exportPdf(kind, url.searchParams.get("all") === "1");
1134
1418
  return text(res, 200, r.path);
1135
1419
  }
@@ -1145,8 +1429,12 @@ const server = http.createServer(async (req, res) => {
1145
1429
  if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1146
1430
  if (!slide.eid) runEidPass();
1147
1431
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1148
- if (p === "/api/shot") return text(res, 200, await takeShot(s2));
1149
- return text(res, 200, formatLayout(await measure(s2)));
1432
+ if (p === "/api/shot") {
1433
+ if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
1434
+ return text(res, 200, await takeShot(s2));
1435
+ }
1436
+ try { return text(res, 200, formatLayout(await measure(s2))); }
1437
+ catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
1150
1438
  }
1151
1439
  return text(res, 404, "Not found");
1152
1440
  } catch (e) {
@@ -1157,15 +1445,56 @@ const server = http.createServer(async (req, res) => {
1157
1445
  }
1158
1446
  });
1159
1447
 
1160
- /* ---- Start ------------------------------------------------------------------------------------- */
1161
- const first = runEidPass();
1162
- if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1448
+ /* ---- Start ----------------------------------------------------------------------------------------
1449
+ The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
1450
+ the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
1451
+ let quitting = false;
1452
+ server.on("error", (e) => {
1453
+ if (ready) return console.error(e);
1454
+ console.error(e.code === "EADDRINUSE"
1455
+ ? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
1456
+ : `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
1457
+ process.exit(1);
1458
+ });
1163
1459
  server.listen(PORT, "127.0.0.1", () => {
1164
- console.log(`PinDeck: ${ORIGIN}/`);
1460
+ PORT = server.address().port;
1461
+ ORIGIN = `http://127.0.0.1:${PORT}`;
1462
+ try {
1463
+ writeServerInfo();
1464
+ completeSettings();
1465
+ fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
1466
+ const first = runEidPass();
1467
+ if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1468
+ startWatcher();
1469
+ } catch (e) {
1470
+ console.error(`PinDeck could not open the deck: ${e.message}`);
1471
+ removeServerInfo();
1472
+ process.exit(1);
1473
+ }
1474
+ ready = true;
1475
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1476
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1165
1477
  console.log(`Deck: ${DECK_DIR}`);
1166
- if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1478
+ console.log(`Server: ${CAPS.label}`);
1479
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1480
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1167
1481
  ensureSession();
1168
1482
  });
1169
- const quit = () => { session.stop(); setTimeout(() => process.exit(0), 300); };
1483
+ /* Ctrl+C or SIGTERM: stop taking requests, let Claude Code end (it gets end of input, then SIGTERM, then
1484
+ SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
1485
+ async function quit() {
1486
+ if (quitting) process.exit(1);
1487
+ quitting = true;
1488
+ try { watcher?.close(); } catch {}
1489
+ clearTimeout(flush); clearTimeout(eidTimer);
1490
+ for (const res of clients) res.end();
1491
+ for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
1492
+ server.close();
1493
+ server.closeAllConnections?.();
1494
+ removeServerInfo();
1495
+ await Promise.race([session.stop(), new Promise((r) => setTimeout(r, 6000))]);
1496
+ if (chatTimer) writeChat();
1497
+ process.exit(0);
1498
+ }
1170
1499
  process.on("SIGINT", quit);
1171
1500
  process.on("SIGTERM", quit);