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/README.md +96 -11
- package/cli.mjs +4 -2
- package/lib/capabilities.mjs +112 -0
- package/lib/claude.mjs +8 -3
- package/lib/deck-files.mjs +56 -0
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/package.json +6 -3
- package/public/editor.css +1 -0
- package/public/editor.js +65 -15
- package/public/index.html +1 -1
- package/server.mjs +445 -116
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
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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 =
|
|
91
|
+
const CLI_CMD = cliCommand(CLI_REL);
|
|
92
|
+
const CAPS = detectCapabilities();
|
|
64
93
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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",
|
|
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
|
-
|
|
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(() =>
|
|
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 (!
|
|
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
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
314
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
|
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 || [])
|
|
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
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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, "&").replace(/"/g, """);
|
|
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
|
|
750
|
-
res.writeHead(200, { "content-type":
|
|
751
|
-
res.end(
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
if (!
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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 !==
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1006
|
-
|
|
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)
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
fs.
|
|
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")
|
|
1149
|
-
|
|
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
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|