pindeck 2.8.1 → 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 +102 -7
- 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 +34 -0
- package/package.json +6 -2
- package/public/editor.css +1 -0
- package/public/editor.js +65 -15
- package/public/index.html +1 -1
- package/server.mjs +449 -115
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,17 +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
|
|
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)) {
|
|
54
|
+
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
55
|
+
await showPlatformNotice(PORT);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
const LOGIN = argv[0] === "login";
|
|
59
|
+
if (LOGIN) argv.shift();
|
|
44
60
|
const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
|
|
45
61
|
const DECK_DIR = path.resolve(argv[0] || process.cwd());
|
|
46
62
|
const DECK_FILE = "index.html";
|
|
@@ -52,35 +68,100 @@ const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
|
|
|
52
68
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
53
69
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
54
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
|
+
}
|
|
55
88
|
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
56
|
-
|
|
89
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
57
90
|
const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
|
|
58
|
-
const CLI_CMD =
|
|
91
|
+
const CLI_CMD = cliCommand(CLI_REL);
|
|
92
|
+
const CAPS = detectCapabilities();
|
|
59
93
|
|
|
60
|
-
|
|
61
|
-
|
|
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}`);
|
|
62
108
|
process.exit(1);
|
|
63
109
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
127
|
|
|
69
128
|
/* ---- Small helpers --------------------------------------------------------------- */
|
|
70
129
|
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
71
130
|
mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
|
|
72
131
|
jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
|
|
73
132
|
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
|
|
74
|
-
|
|
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" };
|
|
75
135
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
76
136
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
77
137
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
78
138
|
function writeDeck(f, content, manual = false) {
|
|
79
139
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
80
|
-
|
|
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);
|
|
81
148
|
}
|
|
82
149
|
const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
|
|
83
|
-
|
|
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
|
+
}
|
|
84
165
|
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
|
|
85
166
|
async function body(req) {
|
|
86
167
|
let s = "";
|
|
@@ -104,12 +185,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
|
|
|
104
185
|
let backedUp = false;
|
|
105
186
|
function runEidPass() {
|
|
106
187
|
const html = readDeck(DECK_FILE);
|
|
188
|
+
if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
|
|
107
189
|
const r = assignEids(html);
|
|
108
190
|
if (r.changed) {
|
|
109
191
|
if (!backedUp) {
|
|
110
192
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
111
|
-
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
|
|
112
|
-
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 });
|
|
113
195
|
backedUp = true;
|
|
114
196
|
}
|
|
115
197
|
writeDeck(DECK_FILE, r.html);
|
|
@@ -126,11 +208,12 @@ function runEidPass() {
|
|
|
126
208
|
let gitFilter = null; // null: not checked yet
|
|
127
209
|
const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
|
|
128
210
|
function refreshGit() {
|
|
129
|
-
|
|
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) => {
|
|
130
213
|
if (e1 || !cleaned) return;
|
|
131
214
|
git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
|
|
132
215
|
const staged = entry.split(/\s+/)[1];
|
|
133
|
-
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); });
|
|
134
217
|
});
|
|
135
218
|
});
|
|
136
219
|
if (gitFilter === null) {
|
|
@@ -152,23 +235,19 @@ function resolveSlide(spec) {
|
|
|
152
235
|
}
|
|
153
236
|
|
|
154
237
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
155
|
-
const CHROME =
|
|
156
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
157
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
158
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
159
|
-
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
160
|
-
"/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)
|
|
161
239
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
162
240
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
163
241
|
let chromeSeq = 0;
|
|
164
242
|
function chrome(args, done) {
|
|
165
|
-
if (!CHROME) return Promise.reject(new Error(
|
|
243
|
+
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
166
244
|
return new Promise((resolve, reject) => {
|
|
167
245
|
// a profile per run: concurrent runs must not share one
|
|
168
246
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
169
247
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
170
248
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
171
|
-
"--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)]);
|
|
172
251
|
let out = "", err = "", finished = false;
|
|
173
252
|
const finish = (fn, v) => {
|
|
174
253
|
if (finished) return;
|
|
@@ -182,7 +261,8 @@ function chrome(args, done) {
|
|
|
182
261
|
proc.stdout.on("data", (d) => { out += d; check(); });
|
|
183
262
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
184
263
|
proc.on("error", (e) => finish(reject, e));
|
|
185
|
-
|
|
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."))));
|
|
186
266
|
const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
|
|
187
267
|
});
|
|
188
268
|
}
|
|
@@ -221,7 +301,11 @@ function measureInEditor(slide) {
|
|
|
221
301
|
pending.set(id, { resolve, reject, t });
|
|
222
302
|
});
|
|
223
303
|
}
|
|
224
|
-
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
|
+
});
|
|
225
309
|
|
|
226
310
|
/* PDF export. handout: one page per slide, build steps in their final state.
|
|
227
311
|
presentation: one page per build step (a slide with two clicks gives three pages).
|
|
@@ -229,18 +313,19 @@ const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(sl
|
|
|
229
313
|
let exportQueue = Promise.resolve(), snapSeq = 0;
|
|
230
314
|
const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
|
|
231
315
|
function exportPdf(kind, includeHidden = false) {
|
|
232
|
-
if (!
|
|
316
|
+
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
233
317
|
const split = kind === "presentation";
|
|
234
318
|
const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
|
|
235
319
|
const out = path.join(DECK_DIR, "export", name);
|
|
236
320
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
237
321
|
const run = async () => {
|
|
238
322
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
239
|
-
const
|
|
240
|
-
|
|
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);
|
|
241
326
|
const id = `${process.pid}-${++snapSeq}`;
|
|
242
327
|
snapshots.set(id, html);
|
|
243
|
-
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); }
|
|
244
329
|
finally { snapshots.delete(id); }
|
|
245
330
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
246
331
|
url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -262,8 +347,15 @@ try {
|
|
|
262
347
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
263
348
|
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
|
|
264
349
|
} catch {}
|
|
265
|
-
|
|
266
|
-
|
|
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); };
|
|
267
359
|
function addEntry(entry) {
|
|
268
360
|
entry.id = ++state.seq;
|
|
269
361
|
entry.time = Date.now();
|
|
@@ -290,28 +382,36 @@ else if (fileConfig.model !== undefined) console.warn(`config.json: unknown mode
|
|
|
290
382
|
if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
|
|
291
383
|
else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
|
|
292
384
|
const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
|
|
293
|
-
|
|
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
|
+
};
|
|
294
388
|
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
295
389
|
|
|
296
390
|
const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
297
|
-
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(),
|
|
298
393
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
299
394
|
const pushStatus = () => broadcast("status", status());
|
|
300
395
|
|
|
301
396
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
302
|
-
.replaceAll("{{CLI}}", CLI_CMD);
|
|
397
|
+
.replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD);
|
|
303
398
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
304
399
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
305
400
|
MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
|
|
306
|
-
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. */
|
|
307
404
|
function deckInstructions() {
|
|
308
|
-
|
|
309
|
-
|
|
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("");
|
|
310
410
|
}
|
|
311
411
|
let stderrTail = "";
|
|
312
412
|
const session = new ClaudeSession({
|
|
313
413
|
cwd: DECK_DIR,
|
|
314
|
-
env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
|
|
414
|
+
env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
|
|
315
415
|
args: () => [
|
|
316
416
|
"--append-system-prompt", systemPrompt + deckInstructions(),
|
|
317
417
|
"--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
|
|
@@ -333,7 +433,10 @@ const session = new ClaudeSession({
|
|
|
333
433
|
},
|
|
334
434
|
});
|
|
335
435
|
function ensureSession() {
|
|
336
|
-
if (
|
|
436
|
+
if (session.running || quitting || !detectCapabilities().claude.available) return;
|
|
437
|
+
stderrTail = "";
|
|
438
|
+
session.start(state.sessionId);
|
|
439
|
+
pushStatus();
|
|
337
440
|
}
|
|
338
441
|
|
|
339
442
|
function toolSummary(name, input = {}) {
|
|
@@ -393,6 +496,7 @@ function startTurn(promptText, userEntry) {
|
|
|
393
496
|
state.busy = true;
|
|
394
497
|
pushStatus();
|
|
395
498
|
ensureSession();
|
|
499
|
+
if (!session.running) return finishTurn({ error: detectCapabilities().claude.reason || "Claude Code could not be started." });
|
|
396
500
|
session.send([{ type: "text", text: promptText }]);
|
|
397
501
|
}
|
|
398
502
|
|
|
@@ -498,7 +602,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
498
602
|
screenshots folder, for Claude to look at before choosing; with one, into the deck. */
|
|
499
603
|
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
|
|
500
604
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
501
|
-
if (
|
|
605
|
+
if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
|
|
502
606
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
503
607
|
.map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
|
|
504
608
|
[DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
|
|
@@ -521,18 +625,25 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
521
625
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
522
626
|
const buf = fs.readFileSync(tmp);
|
|
523
627
|
fs.rmSync(tmp, { force: true });
|
|
524
|
-
|
|
525
|
-
ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
526
|
-
fs.writeFileSync(path.join(DECK_DIR, out), buf);
|
|
628
|
+
writeDeckFile(out, buf);
|
|
527
629
|
state.pdfFigures.add(out);
|
|
528
630
|
return { text: `${out} (${size})` };
|
|
529
631
|
}
|
|
530
632
|
|
|
531
|
-
/* ---- File watching --------------------------------------------------------------------------
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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() {
|
|
536
647
|
clearTimeout(flush);
|
|
537
648
|
flush = setTimeout(() => {
|
|
538
649
|
const files = [...changed];
|
|
@@ -548,14 +659,71 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
|
|
|
548
659
|
if (external) {
|
|
549
660
|
state.notes = [...new Set([...state.notes,
|
|
550
661
|
"The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
|
|
551
|
-
// 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
|
|
552
663
|
clearTimeout(eidTimer);
|
|
553
|
-
|
|
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);
|
|
554
672
|
}
|
|
555
673
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
556
|
-
broadcast("files", { files, manual });
|
|
674
|
+
broadcast("files", { files, manual, rev: deckRev() });
|
|
557
675
|
}, 120);
|
|
558
|
-
}
|
|
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
|
+
}
|
|
559
727
|
|
|
560
728
|
/* ---- Hand edits ----------------------------------------------------------------------------------------- */
|
|
561
729
|
function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
|
|
@@ -592,21 +760,46 @@ function deletePlan(eid) {
|
|
|
592
760
|
return { ...target, html: rest, files };
|
|
593
761
|
}
|
|
594
762
|
|
|
595
|
-
/* ---- 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
|
+
}
|
|
596
791
|
function undo(res) {
|
|
597
792
|
if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
|
|
598
793
|
const t = liveTurns().at(-1);
|
|
599
794
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
600
|
-
const
|
|
795
|
+
const diverged = undoConflicts(t, "undo");
|
|
796
|
+
if (diverged.length) return conflict(res, "undo", diverged);
|
|
601
797
|
for (const f of EDITABLE) {
|
|
602
798
|
if (readDeck(f) === t.before[f]) continue;
|
|
603
799
|
if (t.before[f] != null) writeDeck(f, t.before[f]);
|
|
604
800
|
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
|
|
605
801
|
}
|
|
606
|
-
for (const { rel, data } of t.deletedFiles || [])
|
|
607
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
608
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
609
|
-
}
|
|
802
|
+
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
610
803
|
for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
|
|
611
804
|
t.undone = true;
|
|
612
805
|
state.redo.push(t);
|
|
@@ -614,7 +807,6 @@ function undo(res) {
|
|
|
614
807
|
? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
|
|
615
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.`);
|
|
616
809
|
broadcast("undone", { turnId: t.id, undone: true });
|
|
617
|
-
if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
|
|
618
810
|
pushStatus();
|
|
619
811
|
return json(res, 200, {});
|
|
620
812
|
}
|
|
@@ -626,7 +818,6 @@ function undo(res) {
|
|
|
626
818
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
627
819
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
628
820
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
629
|
-
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
630
821
|
|
|
631
822
|
function startImport(name, buf) {
|
|
632
823
|
const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
|
|
@@ -673,12 +864,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
673
864
|
const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
|
|
674
865
|
const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
|
|
675
866
|
const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
|
|
676
|
-
for (const f of r.files)
|
|
677
|
-
const abs = path.join(DECK_DIR, f.rel);
|
|
678
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
679
|
-
ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
|
|
680
|
-
fs.writeFileSync(abs, f.data);
|
|
681
|
-
}
|
|
867
|
+
for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
|
|
682
868
|
const all = findSlides(parse(html));
|
|
683
869
|
const first = afterIdx + 1, last = afterIdx + r.fragments.length;
|
|
684
870
|
const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
|
|
@@ -736,31 +922,122 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
736
922
|
|
|
737
923
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
738
924
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
739
|
-
|
|
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);
|
|
928
|
+
|
|
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
|
+
}
|
|
740
985
|
|
|
741
|
-
|
|
742
|
-
|
|
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) => {
|
|
743
991
|
if (err) return text(res, 404, "Not found");
|
|
744
|
-
const
|
|
745
|
-
res.writeHead(200, { "content-type":
|
|
746
|
-
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);
|
|
747
1018
|
});
|
|
748
1019
|
}
|
|
749
1020
|
|
|
1021
|
+
let ready = false;
|
|
750
1022
|
const server = http.createServer(async (req, res) => {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
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");
|
|
754
1026
|
const url = new URL(req.url, ORIGIN);
|
|
755
1027
|
const p = url.pathname;
|
|
756
1028
|
try {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
+
}
|
|
760
1036
|
if (req.method === "GET" && p.startsWith("/editor/")) {
|
|
761
1037
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
762
|
-
return f ? serveFile(res, f) : text(res, 403, "Forbidden");
|
|
1038
|
+
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
763
1039
|
}
|
|
1040
|
+
if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
|
|
764
1041
|
let snap;
|
|
765
1042
|
if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
|
|
766
1043
|
const html = snapshots.get(snap[1]);
|
|
@@ -772,7 +1049,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
772
1049
|
const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
|
|
773
1050
|
if (!f) return text(res, 403, "Forbidden");
|
|
774
1051
|
const mode = url.searchParams.get("editor");
|
|
775
|
-
if (!mode || !f.endsWith(".html")) return serveFile(res, f);
|
|
1052
|
+
if (!mode || !f.endsWith(".html")) return serveFile(res, f, null, req);
|
|
776
1053
|
return serveFile(res, f, (s) => {
|
|
777
1054
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
778
1055
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -781,23 +1058,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
781
1058
|
});
|
|
782
1059
|
}
|
|
783
1060
|
|
|
784
|
-
// 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
|
|
785
1062
|
if (!p.startsWith("/api/")) return text(res, 404, "Not found");
|
|
786
|
-
|
|
787
|
-
if (tok !== TOKEN) return text(res, 403, "Bad token");
|
|
1063
|
+
if (!ok) return text(res, 403, "Bad token");
|
|
788
1064
|
const origin = req.headers.origin;
|
|
789
|
-
if (req.method === "POST" && origin && origin !==
|
|
1065
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
|
|
790
1066
|
return text(res, 403, "Bad origin");
|
|
791
1067
|
|
|
792
1068
|
if (p === "/api/events") {
|
|
793
1069
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
794
|
-
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(),
|
|
795
1071
|
turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
|
|
796
1072
|
clients.add(res);
|
|
797
1073
|
req.on("close", () => clients.delete(res));
|
|
798
1074
|
return;
|
|
799
1075
|
}
|
|
800
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());
|
|
801
1079
|
if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
|
|
802
1080
|
if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
|
|
803
1081
|
if (p === "/api/measure-result" && req.method === "POST") {
|
|
@@ -809,6 +1087,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
809
1087
|
|
|
810
1088
|
if (p === "/api/message" && req.method === "POST") {
|
|
811
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 });
|
|
812
1093
|
const b = await body(req);
|
|
813
1094
|
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
|
|
814
1095
|
// what the prompt reads from pins and the selection must be there
|
|
@@ -848,18 +1129,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
848
1129
|
const buf = Buffer.concat(chunks);
|
|
849
1130
|
if (kind === "pdf") {
|
|
850
1131
|
if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
|
|
851
|
-
fs.mkdirSync(PDF_DIR, { recursive: true });
|
|
1132
|
+
fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
|
|
852
1133
|
const abs = attachPath(original, PDF_DIR);
|
|
853
|
-
fs.writeFileSync(abs, buf);
|
|
1134
|
+
fs.writeFileSync(abs, buf, { mode: 0o600 });
|
|
854
1135
|
state.uploads.add(abs);
|
|
855
1136
|
let info = null;
|
|
856
|
-
if (
|
|
1137
|
+
if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
|
|
857
1138
|
return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
|
|
858
1139
|
}
|
|
859
1140
|
const rel = attachPath(original);
|
|
860
|
-
|
|
861
|
-
ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
862
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), buf);
|
|
1141
|
+
writeDeckFile(rel, buf);
|
|
863
1142
|
state.uploads.add(rel);
|
|
864
1143
|
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
|
|
865
1144
|
}
|
|
@@ -881,7 +1160,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
881
1160
|
Object.assign(settings, { model, effort });
|
|
882
1161
|
saveSettings();
|
|
883
1162
|
// restart the session with the new flags; --resume keeps the conversation
|
|
884
|
-
if (session.running)
|
|
1163
|
+
if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
|
|
885
1164
|
addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
|
|
886
1165
|
pushStatus();
|
|
887
1166
|
return json(res, 200, { settings });
|
|
@@ -889,6 +1168,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
889
1168
|
if (p === "/api/slide-hidden" && req.method === "POST") {
|
|
890
1169
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
891
1170
|
const b = await body(req);
|
|
1171
|
+
if (staleEdit(res, b)) return;
|
|
892
1172
|
const before = snapshot(EDITABLE);
|
|
893
1173
|
const slides = findSlides(parse(before[DECK_FILE]));
|
|
894
1174
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
@@ -904,6 +1184,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
904
1184
|
if (p === "/api/move-slide" && req.method === "POST") {
|
|
905
1185
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
906
1186
|
const b = await body(req);
|
|
1187
|
+
if (staleEdit(res, b)) return;
|
|
907
1188
|
const before = snapshot(EDITABLE);
|
|
908
1189
|
const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
|
|
909
1190
|
if (r.error) return json(res, 409, { error: r.error });
|
|
@@ -916,6 +1197,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
916
1197
|
if (p === "/api/add-slide" && req.method === "POST") {
|
|
917
1198
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
918
1199
|
const b = await body(req);
|
|
1200
|
+
if (staleEdit(res, b)) return;
|
|
919
1201
|
if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
|
|
920
1202
|
const before = snapshot(EDITABLE);
|
|
921
1203
|
const r = insertSlide(before[DECK_FILE], b.kind, b.after);
|
|
@@ -930,6 +1212,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
930
1212
|
return json(res, 200, { turnId, index: r.index, eid });
|
|
931
1213
|
}
|
|
932
1214
|
if (p === "/api/import" && req.method === "POST") {
|
|
1215
|
+
if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
|
|
933
1216
|
const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
|
|
934
1217
|
if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
|
|
935
1218
|
const chunks = [];
|
|
@@ -955,6 +1238,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
955
1238
|
if (p === "/api/import-apply" && req.method === "POST") {
|
|
956
1239
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
957
1240
|
const b = await body(req);
|
|
1241
|
+
if (staleEdit(res, b)) return;
|
|
958
1242
|
const imp = lastImport;
|
|
959
1243
|
if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
|
|
960
1244
|
const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
|
|
@@ -997,18 +1281,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
997
1281
|
if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
|
|
998
1282
|
const t = state.redo.at(-1);
|
|
999
1283
|
if (!t) return json(res, 409, { error: "Nothing to redo." });
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
pushStatus();
|
|
1003
|
-
return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
|
|
1004
|
-
}
|
|
1284
|
+
const diverged = undoConflicts(t, "redo");
|
|
1285
|
+
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1005
1286
|
state.redo.pop();
|
|
1006
|
-
for (const f of EDITABLE)
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
fs.
|
|
1010
|
-
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 });
|
|
1011
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);
|
|
1012
1294
|
t.undone = false;
|
|
1013
1295
|
state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
|
|
1014
1296
|
: `The user redid your change from turn ${t.id} after undoing it.`);
|
|
@@ -1026,6 +1308,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1026
1308
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1027
1309
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1028
1310
|
const b = await body(req);
|
|
1311
|
+
if (staleEdit(res, b)) return;
|
|
1029
1312
|
const plan = deletePlan(b.eid);
|
|
1030
1313
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
1031
1314
|
const before = snapshot(EDITABLE);
|
|
@@ -1045,6 +1328,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1045
1328
|
if (p === "/api/edit" && req.method === "POST") {
|
|
1046
1329
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1047
1330
|
const b = await body(req);
|
|
1331
|
+
if (staleEdit(res, b)) return;
|
|
1048
1332
|
const before = snapshot(EDITABLE);
|
|
1049
1333
|
const r = setStyles(before[DECK_FILE], b.ops || []);
|
|
1050
1334
|
if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
|
|
@@ -1066,6 +1350,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1066
1350
|
if (p === "/api/notes" && req.method === "POST") {
|
|
1067
1351
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1068
1352
|
const b = await body(req);
|
|
1353
|
+
if (staleEdit(res, b)) return;
|
|
1069
1354
|
const before = snapshot(EDITABLE);
|
|
1070
1355
|
const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
|
|
1071
1356
|
if (!r) return json(res, 404, { error: "Slide not found in the file." });
|
|
@@ -1080,6 +1365,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1080
1365
|
if (p === "/api/text" && req.method === "POST") {
|
|
1081
1366
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1082
1367
|
const b = await body(req);
|
|
1368
|
+
if (staleEdit(res, b)) return;
|
|
1083
1369
|
const before = snapshot(EDITABLE);
|
|
1084
1370
|
const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
|
|
1085
1371
|
if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1095,6 +1381,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1095
1381
|
if (p === "/api/split" && req.method === "POST") {
|
|
1096
1382
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1097
1383
|
const b = await body(req);
|
|
1384
|
+
if (staleEdit(res, b)) return;
|
|
1098
1385
|
const before = snapshot(EDITABLE);
|
|
1099
1386
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
1100
1387
|
if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1107,6 +1394,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1107
1394
|
if (p === "/api/remove" && req.method === "POST") {
|
|
1108
1395
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1109
1396
|
const b = await body(req);
|
|
1397
|
+
if (staleEdit(res, b)) return;
|
|
1110
1398
|
const before = snapshot(EDITABLE);
|
|
1111
1399
|
const html = removeElement(before[DECK_FILE], b.eid);
|
|
1112
1400
|
if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1118,13 +1406,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1118
1406
|
if (p === "/api/export" && req.method === "POST") {
|
|
1119
1407
|
const b = await body(req);
|
|
1120
1408
|
try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
|
|
1121
|
-
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) }); }
|
|
1122
1410
|
}
|
|
1123
1411
|
|
|
1124
1412
|
// CLI endpoints (used by Claude Code via cli.mjs)
|
|
1125
1413
|
if (p === "/api/pdf") {
|
|
1126
1414
|
const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
|
|
1127
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}.`);
|
|
1128
1417
|
const r = await exportPdf(kind, url.searchParams.get("all") === "1");
|
|
1129
1418
|
return text(res, 200, r.path);
|
|
1130
1419
|
}
|
|
@@ -1140,8 +1429,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1140
1429
|
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1141
1430
|
if (!slide.eid) runEidPass();
|
|
1142
1431
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1143
|
-
if (p === "/api/shot")
|
|
1144
|
-
|
|
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; }
|
|
1145
1438
|
}
|
|
1146
1439
|
return text(res, 404, "Not found");
|
|
1147
1440
|
} catch (e) {
|
|
@@ -1152,15 +1445,56 @@ const server = http.createServer(async (req, res) => {
|
|
|
1152
1445
|
}
|
|
1153
1446
|
});
|
|
1154
1447
|
|
|
1155
|
-
/* ---- Start
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
+
});
|
|
1158
1459
|
server.listen(PORT, "127.0.0.1", () => {
|
|
1159
|
-
|
|
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)");
|
|
1160
1477
|
console.log(`Deck: ${DECK_DIR}`);
|
|
1161
|
-
|
|
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})`);
|
|
1162
1481
|
ensureSession();
|
|
1163
1482
|
});
|
|
1164
|
-
|
|
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
|
+
}
|
|
1165
1499
|
process.on("SIGINT", quit);
|
|
1166
1500
|
process.on("SIGTERM", quit);
|