pindeck 3.0.0-beta.1 → 3.0.0-beta.2
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 +217 -44
- package/cli.mjs +6 -3
- package/lib/agent-process.mjs +7 -3
- package/lib/agent-tools.mjs +32 -9
- package/lib/capabilities.mjs +121 -0
- package/lib/claude.mjs +7 -3
- package/lib/codex.mjs +4 -2
- package/lib/deck-files.mjs +94 -0
- package/lib/frameworks.mjs +41 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +2 -1
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/editor.css +1 -0
- package/public/editor.js +79 -22
- package/public/index.html +2 -2
- package/server.mjs +599 -166
package/server.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* PinDeck: local server.
|
|
3
3
|
|
|
4
|
-
pindeck [deck-dir] [--port 5180] [--provider claude|codex] [--model <name>]
|
|
4
|
+
pindeck [deck-dir | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>] (or: node server.mjs ...)
|
|
5
5
|
|
|
6
6
|
- serves the editor UI and the deck (with a bridge script injected in edit mode)
|
|
7
7
|
- pushes file changes to the browser (server-sent events) for live reload
|
|
@@ -12,16 +12,18 @@
|
|
|
12
12
|
- stores figures and audio attached to chat messages in <deck>/figures/ for the assistant 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";
|
|
@@ -33,65 +35,177 @@ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs
|
|
|
33
35
|
import { readPptx } from "./lib/pptx.mjs";
|
|
34
36
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
35
37
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
38
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
39
|
+
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
40
|
+
import { readInstructions } from "./lib/instructions.mjs";
|
|
41
|
+
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
36
42
|
|
|
37
43
|
/* ---- Config ------------------------------------------------------------------ */
|
|
38
|
-
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--provider claude|codex] [--model <name>]
|
|
44
|
+
const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
|
|
45
|
+
pindeck login [deck-folder | deck-file.html]
|
|
39
46
|
|
|
40
|
-
Opens
|
|
41
|
-
|
|
47
|
+
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
48
|
+
folder), which then opens its index.html.
|
|
49
|
+
login prints a new one-time login link for the PinDeck running for that deck
|
|
50
|
+
(without a deck: for the PinDeck started last).
|
|
51
|
+
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
52
|
+
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
53
|
+
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
|
42
54
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
43
55
|
const argv = process.argv.slice(2);
|
|
44
56
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
45
57
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
46
|
-
const
|
|
47
|
-
|
|
58
|
+
const portArg = opt("--port", process.env.PORT || 5180);
|
|
59
|
+
const assetArgs = [];
|
|
60
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
|
|
61
|
+
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
62
|
+
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
63
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
48
64
|
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
49
65
|
await showPlatformNotice(PORT);
|
|
50
66
|
process.exit(0);
|
|
51
67
|
}
|
|
68
|
+
const LOGIN = argv[0] === "login";
|
|
69
|
+
if (LOGIN) argv.shift();
|
|
52
70
|
const PROVIDER = opt("--provider", "");
|
|
53
71
|
const MODEL = opt("--model", "");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
72
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
|
|
73
|
+
that is not a folder is a file, so a missing file gets a clear message. */
|
|
74
|
+
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
75
|
+
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
76
|
+
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
77
|
+
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
78
|
+
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
79
|
+
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
80
|
+
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
58
81
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
59
82
|
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
84
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
85
|
+
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
86
|
+
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
62
87
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
63
88
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
64
89
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
90
|
+
/* pindeck login: a new one-time login link from the running server, asked with the token in its server
|
|
91
|
+
file (readable only by this user) */
|
|
92
|
+
if (LOGIN) {
|
|
93
|
+
const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
|
|
94
|
+
let info = null;
|
|
95
|
+
for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
|
|
96
|
+
if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
|
|
99
|
+
if (!res.ok) throw new Error(res.status);
|
|
100
|
+
console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
|
|
101
|
+
process.exit(0);
|
|
102
|
+
} catch {
|
|
103
|
+
console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
65
107
|
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
66
|
-
|
|
108
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
67
109
|
const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
|
|
68
|
-
const CLI_CMD =
|
|
110
|
+
const CLI_CMD = cliCommand(CLI_REL);
|
|
111
|
+
const CAPS = detectCapabilities();
|
|
69
112
|
|
|
70
|
-
|
|
71
|
-
|
|
113
|
+
/* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
|
|
114
|
+
function checkDeck() {
|
|
115
|
+
const file = path.join(DECK_DIR, DECK_FILE);
|
|
116
|
+
if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
|
|
117
|
+
try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
|
|
118
|
+
catch { return `${file} cannot be read and written (check its permissions).`; }
|
|
119
|
+
// saving replaces the file through a temporary file next to it, so the folder must be writable too
|
|
120
|
+
try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
|
|
121
|
+
catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const deckProblem = checkDeck();
|
|
125
|
+
if (deckProblem) {
|
|
126
|
+
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
72
127
|
process.exit(1);
|
|
73
128
|
}
|
|
74
|
-
|
|
75
|
-
fs.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
129
|
+
// only reveal.js for now: another framework's deck is left exactly as it is (no ids, no state)
|
|
130
|
+
const frameworkProblem = unsupportedMessage(fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8"), DECK_FILE);
|
|
131
|
+
if (frameworkProblem) {
|
|
132
|
+
console.error(frameworkProblem);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
136
|
+
port is bound (see Start), so a failed start leaves no state behind. */
|
|
137
|
+
const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
|
|
138
|
+
function writeServerInfo() {
|
|
139
|
+
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
140
|
+
const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
|
|
141
|
+
for (const f of SERVER_FILES) {
|
|
142
|
+
fs.writeFileSync(f, info, { mode: 0o600 });
|
|
143
|
+
fs.chmodSync(f, 0o600);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
|
|
147
|
+
function removeServerInfo() {
|
|
148
|
+
for (const f of SERVER_FILES) {
|
|
149
|
+
try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
79
152
|
|
|
80
153
|
/* ---- Small helpers --------------------------------------------------------------- */
|
|
81
154
|
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
82
155
|
mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
|
|
83
156
|
jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
|
|
84
157
|
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
|
|
85
|
-
|
|
158
|
+
ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
|
|
159
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
|
|
86
160
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
87
161
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
88
162
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
89
163
|
function writeDeck(f, content, manual = false) {
|
|
90
164
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
91
|
-
|
|
165
|
+
writeAtomic(path.join(DECK_DIR, f), content);
|
|
166
|
+
}
|
|
167
|
+
/* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
|
|
168
|
+
function writeDeckFile(rel, data, manual = true) {
|
|
169
|
+
const abs = path.join(DECK_DIR, rel);
|
|
170
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
171
|
+
ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
|
|
172
|
+
writeAtomic(abs, data);
|
|
92
173
|
}
|
|
93
174
|
const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
|
|
94
|
-
|
|
175
|
+
/* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
|
|
176
|
+
a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
|
|
177
|
+
const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
|
|
178
|
+
const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
179
|
+
const asText = (v) => (v == null ? "" : String(v));
|
|
180
|
+
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
181
|
+
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
182
|
+
if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
183
|
+
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
184
|
+
writeDeck(rel, v);
|
|
185
|
+
}
|
|
186
|
+
function fileChange(rel, before, after) {
|
|
187
|
+
if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
|
|
188
|
+
const d = unifiedDiff(asText(before), asText(after), rel);
|
|
189
|
+
return { file: rel, added: d.added, removed: d.removed };
|
|
190
|
+
}
|
|
191
|
+
const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
|
|
192
|
+
? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
|
|
193
|
+
: unifiedDiff(asText(before), asText(after), rel).text);
|
|
194
|
+
/* The revision of the editable files: sent with every API answer, checked on the editor's changes */
|
|
195
|
+
const deckRev = () => revision(DECK_DIR, EDITABLE);
|
|
196
|
+
const json = (res, code, obj) => {
|
|
197
|
+
res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
|
|
198
|
+
res.end(JSON.stringify(obj));
|
|
199
|
+
};
|
|
200
|
+
/* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
|
|
201
|
+
written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
|
|
202
|
+
notes sent while the tab closes) are not checked. */
|
|
203
|
+
function staleEdit(res, b) {
|
|
204
|
+
if (b.rev == null || b.rev === deckRev()) return false;
|
|
205
|
+
json(res, 409, { code: "stale", rev: deckRev(),
|
|
206
|
+
error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
95
209
|
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
|
|
96
210
|
async function body(req) {
|
|
97
211
|
let s = "";
|
|
@@ -115,12 +229,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
|
|
|
115
229
|
let backedUp = false;
|
|
116
230
|
function runEidPass() {
|
|
117
231
|
const html = readDeck(DECK_FILE);
|
|
232
|
+
if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
|
|
118
233
|
const r = assignEids(html);
|
|
119
234
|
if (r.changed) {
|
|
120
235
|
if (!backedUp) {
|
|
121
236
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
122
|
-
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
|
|
123
|
-
fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
|
|
237
|
+
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
|
|
238
|
+
fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
|
|
124
239
|
backedUp = true;
|
|
125
240
|
}
|
|
126
241
|
writeDeck(DECK_FILE, r.html);
|
|
@@ -137,11 +252,12 @@ function runEidPass() {
|
|
|
137
252
|
let gitFilter = null; // null: not checked yet
|
|
138
253
|
const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
|
|
139
254
|
function refreshGit() {
|
|
140
|
-
|
|
255
|
+
// another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
|
|
256
|
+
const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
|
|
141
257
|
if (e1 || !cleaned) return;
|
|
142
258
|
git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
|
|
143
259
|
const staged = entry.split(/\s+/)[1];
|
|
144
|
-
if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
|
|
260
|
+
if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
|
|
145
261
|
});
|
|
146
262
|
});
|
|
147
263
|
if (gitFilter === null) {
|
|
@@ -163,23 +279,19 @@ function resolveSlide(spec) {
|
|
|
163
279
|
}
|
|
164
280
|
|
|
165
281
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
166
|
-
const CHROME =
|
|
167
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
168
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
169
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
170
|
-
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
171
|
-
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
|
|
282
|
+
const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
|
|
172
283
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
173
284
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
174
285
|
let chromeSeq = 0;
|
|
175
286
|
function chrome(args, done) {
|
|
176
|
-
if (!CHROME) return Promise.reject(new Error(
|
|
287
|
+
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
177
288
|
return new Promise((resolve, reject) => {
|
|
178
289
|
// a profile per run: concurrent runs must not share one
|
|
179
290
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
180
291
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
181
292
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
182
|
-
"--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
293
|
+
"--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
294
|
+
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
|
|
183
295
|
let out = "", err = "", finished = false;
|
|
184
296
|
const finish = (fn, v) => {
|
|
185
297
|
if (finished) return;
|
|
@@ -193,12 +305,13 @@ function chrome(args, done) {
|
|
|
193
305
|
proc.stdout.on("data", (d) => { out += d; check(); });
|
|
194
306
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
195
307
|
proc.on("error", (e) => finish(reject, e));
|
|
196
|
-
|
|
308
|
+
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
309
|
+
proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
|
|
197
310
|
const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
|
|
198
311
|
});
|
|
199
312
|
}
|
|
200
313
|
const deckUrl = (mode, params = {}) =>
|
|
201
|
-
`${ORIGIN}
|
|
314
|
+
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
202
315
|
async function takeShot(slide) {
|
|
203
316
|
const pins = state.pins.filter((p) => p.slide.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
|
|
204
317
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
@@ -232,26 +345,31 @@ function measureInEditor(slide) {
|
|
|
232
345
|
pending.set(id, { resolve, reject, t });
|
|
233
346
|
});
|
|
234
347
|
}
|
|
235
|
-
const measure = (slide) => measureInEditor(slide).catch(() =>
|
|
348
|
+
const measure = (slide) => measureInEditor(slide).catch((e) => {
|
|
349
|
+
if (CAPS.headlessLayout.available) return measureHeadless(slide);
|
|
350
|
+
throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
|
|
351
|
+
`headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
|
|
352
|
+
});
|
|
236
353
|
|
|
237
354
|
/* PDF export. handout: one page per slide, build steps in their final state.
|
|
238
355
|
presentation: one page per build step (a slide with two clicks gives three pages).
|
|
239
356
|
Files go to <deck>/export/. One export at a time. */
|
|
240
357
|
let exportQueue = Promise.resolve(), snapSeq = 0;
|
|
241
|
-
const snapshots = new Map(); // id -> static print layout, served
|
|
358
|
+
const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
|
|
242
359
|
function exportPdf(kind, includeHidden = false) {
|
|
243
|
-
if (!
|
|
360
|
+
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
244
361
|
const split = kind === "presentation";
|
|
245
|
-
const name = `${
|
|
362
|
+
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
246
363
|
const out = path.join(DECK_DIR, "export", name);
|
|
247
364
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
248
365
|
const run = async () => {
|
|
249
366
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
250
|
-
const
|
|
251
|
-
|
|
367
|
+
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
368
|
+
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
369
|
+
`${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
252
370
|
const id = `${process.pid}-${++snapSeq}`;
|
|
253
371
|
snapshots.set(id, html);
|
|
254
|
-
try { await printPdf(CHROME, `${ORIGIN}
|
|
372
|
+
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
255
373
|
finally { snapshots.delete(id); }
|
|
256
374
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
257
375
|
url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -273,9 +391,17 @@ try {
|
|
|
273
391
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
274
392
|
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
|
|
275
393
|
} catch {}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
394
|
+
/* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
|
|
395
|
+
let chatTimer = null;
|
|
396
|
+
function writeChat() {
|
|
397
|
+
clearTimeout(chatTimer);
|
|
398
|
+
chatTimer = null;
|
|
399
|
+
try {
|
|
400
|
+
writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
|
|
401
|
+
provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
|
|
402
|
+
} catch (e) { console.error(e.message); }
|
|
403
|
+
}
|
|
404
|
+
const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
|
|
279
405
|
function addEntry(entry) {
|
|
280
406
|
entry.id = ++state.seq;
|
|
281
407
|
entry.time = Date.now();
|
|
@@ -291,44 +417,98 @@ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") :
|
|
|
291
417
|
const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
|
292
418
|
let fileConfig = {};
|
|
293
419
|
try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
|
|
294
|
-
catch (e) { if (e.code !== "ENOENT") console.warn(
|
|
420
|
+
catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
|
|
295
421
|
const settings = migrateSettings(fileConfig);
|
|
296
422
|
let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
|
|
297
423
|
// Preserve the legacy selection as the first model-specific preference.
|
|
298
424
|
if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
299
425
|
if (PROVIDER) {
|
|
300
|
-
if (!["claude", "codex"].includes(PROVIDER))
|
|
426
|
+
if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
|
|
301
427
|
settings.provider = PROVIDER;
|
|
302
428
|
if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
|
|
303
429
|
}
|
|
304
|
-
if (MODEL) settings.model = MODEL;
|
|
430
|
+
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
305
431
|
// A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
|
|
306
432
|
if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
|
|
307
433
|
state.provider = settings.provider;
|
|
434
|
+
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
435
|
+
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
436
|
+
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
437
|
+
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
438
|
+
a symlink that leads anywhere else is refused. */
|
|
439
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
440
|
+
const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
|
|
441
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
442
|
+
let st = null;
|
|
443
|
+
try { st = fs.statSync(abs); } catch {}
|
|
444
|
+
if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
445
|
+
return abs;
|
|
446
|
+
});
|
|
447
|
+
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
448
|
+
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
449
|
+
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
450
|
+
const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
|
|
451
|
+
const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
|
452
|
+
/* The deck page's URL, and the folder URL its relative paths resolve against */
|
|
453
|
+
const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
|
|
454
|
+
: `/deck/${encodeURIComponent(DECK_FILE)}`;
|
|
455
|
+
const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
|
|
456
|
+
/* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
|
|
457
|
+
followed. null: refuse. A missing file passes, and is answered 404. */
|
|
458
|
+
function servable(base, rel) {
|
|
459
|
+
const f = safeJoin(base, rel);
|
|
460
|
+
if (!f) return null;
|
|
461
|
+
let real;
|
|
462
|
+
try { real = fs.realpathSync(f); } catch { return f; }
|
|
463
|
+
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
464
|
+
}
|
|
465
|
+
|
|
308
466
|
let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
|
|
309
467
|
let session = null, starting = null;
|
|
310
468
|
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
311
|
-
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
469
|
+
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
470
|
+
chrome: CAPS.screenshot.available, capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
312
471
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
313
472
|
const pushStatus = () => broadcast("status", status());
|
|
314
473
|
|
|
315
474
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
316
|
-
.replaceAll("{{CLI}}", CLI_CMD)
|
|
475
|
+
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
476
|
+
// a template's own notes usually say index.html; other decks may share this folder
|
|
477
|
+
(NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
|
|
478
|
+
`${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
|
|
479
|
+
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
480
|
+
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
481
|
+
`other decks may use them too.\n` : "");
|
|
482
|
+
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
483
|
+
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
484
|
+
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
485
|
+
function lockedPaths() {
|
|
486
|
+
let others = [];
|
|
487
|
+
try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
|
|
488
|
+
return [...LOCKED, "vendor/**", ".git/**", ...others];
|
|
489
|
+
}
|
|
317
490
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
318
491
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
319
|
-
MCP servers have no place in a slide editor). That also skips
|
|
320
|
-
the system prompt instead, read afresh at each session start.
|
|
492
|
+
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
493
|
+
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
494
|
+
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
495
|
+
not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
|
|
496
|
+
`node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
|
|
321
497
|
function deckInstructions() {
|
|
322
|
-
|
|
323
|
-
|
|
498
|
+
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
499
|
+
const s = readInstructions(path.join(DECK_DIR, name));
|
|
500
|
+
return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
|
|
501
|
+
}).join("");
|
|
324
502
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
503
|
+
// a function: ORIGIN is known only once the port is bound
|
|
504
|
+
const agentEnv = () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN });
|
|
505
|
+
const agentTools = createDeckTools({ deckDir: DECK_DIR, deckFile: DECK_FILE, readDirs: [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS],
|
|
506
|
+
locked: lockedPaths, cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
|
|
507
|
+
env: agentEnv });
|
|
328
508
|
const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
|
|
329
509
|
Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
|
|
330
510
|
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
331
|
-
Use pindeck_edit for exact replacements in
|
|
511
|
+
Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
|
|
332
512
|
Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
|
|
333
513
|
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
334
514
|
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
@@ -340,7 +520,7 @@ function makeSession(choice) {
|
|
|
340
520
|
let initial = null;
|
|
341
521
|
const options = {
|
|
342
522
|
cwd: DECK_DIR,
|
|
343
|
-
env:
|
|
523
|
+
env: agentEnv,
|
|
344
524
|
onEvent: ev => {
|
|
345
525
|
if (ev.type === "system" && ev.subtype === "init") initial = ev;
|
|
346
526
|
if (session === agent) handleEvent(ev);
|
|
@@ -351,40 +531,57 @@ function makeSession(choice) {
|
|
|
351
531
|
pushStatus();
|
|
352
532
|
},
|
|
353
533
|
};
|
|
534
|
+
/* Claude Code: any file in the deck folder (Edit rules cover Write too; Write is named as well, to be
|
|
535
|
+
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
354
536
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
355
537
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
356
|
-
args: ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
357
|
-
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands",
|
|
358
|
-
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Bash",
|
|
359
|
-
"--allowedTools", "Read", "Glob", "Grep",
|
|
360
|
-
"
|
|
538
|
+
args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
539
|
+
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
540
|
+
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
541
|
+
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
|
|
542
|
+
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
543
|
+
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
361
544
|
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
362
545
|
});
|
|
363
546
|
agent.initialEvent = () => initial;
|
|
364
547
|
return agent;
|
|
365
548
|
}
|
|
549
|
+
const providerAvailable = (provider) => detectCapabilities()[provider];
|
|
550
|
+
/* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
|
|
551
|
+
replaced, the current one. */
|
|
552
|
+
const retiring = new Set(), discoveryAgents = new Set();
|
|
553
|
+
function retire(agent) {
|
|
554
|
+
if (!agent) return;
|
|
555
|
+
const stopped = agent.stop();
|
|
556
|
+
retiring.add(stopped);
|
|
557
|
+
stopped.finally(() => retiring.delete(stopped));
|
|
558
|
+
}
|
|
366
559
|
async function discoverModels() {
|
|
367
560
|
if (discovering) return discovering;
|
|
368
561
|
discovering = Promise.all(["claude", "codex"].map(async provider => {
|
|
562
|
+
if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
|
|
369
563
|
const agent = makeSession({ provider, model: "", effort: null });
|
|
564
|
+
discoveryAgents.add(agent);
|
|
370
565
|
try {
|
|
371
566
|
if (provider === "codex") await agent.connect(); else await agent.start();
|
|
372
567
|
catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
|
|
373
568
|
} catch (e) { catalog[provider] = { models: [], error: e.message }; }
|
|
374
|
-
finally {
|
|
569
|
+
finally { discoveryAgents.delete(agent); retire(agent); }
|
|
375
570
|
})).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
|
|
376
571
|
return discovering;
|
|
377
572
|
}
|
|
378
573
|
async function ensureSession() {
|
|
379
574
|
if (starting) return starting;
|
|
380
|
-
if (session?.running) return;
|
|
575
|
+
if (session?.running || quitting) return;
|
|
576
|
+
const available = providerAvailable(settings.provider);
|
|
577
|
+
if (!available.available) throw new Error(available.reason);
|
|
381
578
|
starting = (async () => {
|
|
382
579
|
if (!catalog[settings.provider].models.length) await discoverModels();
|
|
383
580
|
Object.assign(settings, selectModel(catalog, settings, {}));
|
|
384
581
|
const agent = makeSession({ ...settings });
|
|
385
582
|
session = agent;
|
|
386
583
|
try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
|
|
387
|
-
catch (e) { agent
|
|
584
|
+
catch (e) { retire(agent); throw e; }
|
|
388
585
|
})().finally(() => { starting = null; });
|
|
389
586
|
return starting;
|
|
390
587
|
}
|
|
@@ -415,12 +612,12 @@ async function changeSettings(requested) {
|
|
|
415
612
|
if (reuse) candidate.settings = choice;
|
|
416
613
|
session = candidate;
|
|
417
614
|
if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
|
|
418
|
-
if (!reuse) old
|
|
615
|
+
if (!reuse) retire(old);
|
|
419
616
|
saveChat();
|
|
420
617
|
addEntry({ kind: "notice", text: changingProvider
|
|
421
618
|
? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
|
|
422
619
|
: `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
|
|
423
|
-
} catch (e) { if (session !== candidate) candidate
|
|
620
|
+
} catch (e) { if (session !== candidate) retire(candidate); throw e; }
|
|
424
621
|
finally { state.busy = false; state.switching = false; pushStatus(); }
|
|
425
622
|
}
|
|
426
623
|
|
|
@@ -433,7 +630,7 @@ function toolSummary(name, input = {}) {
|
|
|
433
630
|
};
|
|
434
631
|
switch (name) {
|
|
435
632
|
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
436
|
-
case "pindeck_edit": return { name: "Edit", detail: base(input.path) };
|
|
633
|
+
case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
|
|
437
634
|
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
438
635
|
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
439
636
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
@@ -480,7 +677,9 @@ function handleEvent(ev) {
|
|
|
480
677
|
}
|
|
481
678
|
|
|
482
679
|
function startTurn(promptText, userEntry) {
|
|
483
|
-
|
|
680
|
+
// every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
|
|
681
|
+
// takes back all it changed
|
|
682
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
|
|
484
683
|
state.busy = true;
|
|
485
684
|
pushStatus();
|
|
486
685
|
const turn = state.turn;
|
|
@@ -509,11 +708,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
509
708
|
}
|
|
510
709
|
toolEntries.clear();
|
|
511
710
|
const eid = runEidPass();
|
|
512
|
-
const
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
711
|
+
const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
|
|
712
|
+
const before = {}, after = {};
|
|
713
|
+
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
714
|
+
// the deck file and theme.css first, then the rest by name
|
|
715
|
+
const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
|
|
716
|
+
const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
|
|
717
|
+
.map((rel) => fileChange(rel, before[rel], after[rel]));
|
|
517
718
|
// after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
|
|
518
719
|
// deleted; undo brings them back with the turn
|
|
519
720
|
const deletedFiles = [];
|
|
@@ -523,11 +724,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
523
724
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
524
725
|
fs.rmSync(abs, { force: true });
|
|
525
726
|
}
|
|
526
|
-
const t = { id: turn.id, kind: "agent", provider: settings.provider, before
|
|
727
|
+
const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
|
|
527
728
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
528
729
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
529
730
|
denials, restored, newIds: eid.assigned.length });
|
|
530
731
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
732
|
+
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
733
|
+
`too large to keep a copy of (over 2 MB): Undo leaves ${tooLarge.length > 1 ? "them" : "it"} as ${tooLarge.length > 1 ? "they are" : "it is"}.` });
|
|
531
734
|
pushStatus();
|
|
532
735
|
}
|
|
533
736
|
|
|
@@ -586,7 +789,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
586
789
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
587
790
|
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
|
|
588
791
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
589
|
-
|
|
792
|
+
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
590
793
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
591
794
|
}
|
|
592
795
|
|
|
@@ -595,7 +798,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
595
798
|
screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
|
|
596
799
|
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
|
|
597
800
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
598
|
-
if (
|
|
801
|
+
if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
|
|
599
802
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
600
803
|
.map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
|
|
601
804
|
[DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
|
|
@@ -613,24 +816,31 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
613
816
|
if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
|
|
614
817
|
const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
|
|
615
818
|
await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
|
|
616
|
-
if (!fs.existsSync(tmp)) return bad("
|
|
819
|
+
if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
|
|
617
820
|
const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
|
|
618
821
|
if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
|
|
619
822
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
620
823
|
const buf = fs.readFileSync(tmp);
|
|
621
824
|
fs.rmSync(tmp, { force: true });
|
|
622
|
-
|
|
623
|
-
ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
624
|
-
fs.writeFileSync(path.join(DECK_DIR, out), buf);
|
|
825
|
+
writeDeckFile(out, buf);
|
|
625
826
|
state.pdfFigures.add(out);
|
|
626
827
|
return { text: `${out} (${size})` };
|
|
627
828
|
}
|
|
628
829
|
|
|
629
|
-
/* ---- File watching --------------------------------------------------------------------------
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
830
|
+
/* ---- File watching --------------------------------------------------------------------------
|
|
831
|
+
Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
|
|
832
|
+
events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
|
|
833
|
+
editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
|
|
834
|
+
files), it is started again and the editor told to reload everything. */
|
|
835
|
+
let changed = new Set(), flush = null, eidTimer = null, watcher = null;
|
|
836
|
+
function onFileEvent(_ev, file) {
|
|
837
|
+
if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
|
|
838
|
+
file = String(file).split(path.sep).join("/");
|
|
839
|
+
if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
|
|
840
|
+
changed.add(file);
|
|
841
|
+
scheduleFlush();
|
|
842
|
+
}
|
|
843
|
+
function scheduleFlush() {
|
|
634
844
|
clearTimeout(flush);
|
|
635
845
|
flush = setTimeout(() => {
|
|
636
846
|
const files = [...changed];
|
|
@@ -646,14 +856,71 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
|
|
|
646
856
|
if (external) {
|
|
647
857
|
state.notes = [...new Set([...state.notes,
|
|
648
858
|
"The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
|
|
649
|
-
// new elements from a hand edit get ids once the file is quiet
|
|
859
|
+
// new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
|
|
650
860
|
clearTimeout(eidTimer);
|
|
651
|
-
|
|
861
|
+
const seen = hash(readDeck(DECK_FILE) ?? "");
|
|
862
|
+
const pass = () => {
|
|
863
|
+
if (state.busy) return;
|
|
864
|
+
const now = readDeck(DECK_FILE);
|
|
865
|
+
if (now == null || hash(now) !== seen) return; // still being written; its own event comes
|
|
866
|
+
runEidPass();
|
|
867
|
+
};
|
|
868
|
+
eidTimer = setTimeout(pass, 800);
|
|
652
869
|
}
|
|
653
870
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
654
|
-
broadcast("files", { files, manual });
|
|
871
|
+
broadcast("files", { files, manual, rev: deckRev() });
|
|
655
872
|
}, 120);
|
|
656
|
-
}
|
|
873
|
+
}
|
|
874
|
+
/* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
|
|
875
|
+
rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
|
|
876
|
+
every folder (inotify on directories) keeps working; folders created later are added as they appear.
|
|
877
|
+
Hidden folders (.git) and node_modules are skipped. */
|
|
878
|
+
function watchFolders(root, onEvent) {
|
|
879
|
+
const watchers = new Map(), tree = new EventEmitter();
|
|
880
|
+
const add = (dir) => {
|
|
881
|
+
if (watchers.has(dir)) return;
|
|
882
|
+
let w;
|
|
883
|
+
try {
|
|
884
|
+
w = fs.watch(dir, (ev, name) => {
|
|
885
|
+
if (name == null) return onEvent(ev, null);
|
|
886
|
+
const abs = path.join(dir, String(name));
|
|
887
|
+
if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
|
|
888
|
+
onEvent(ev, path.relative(root, abs));
|
|
889
|
+
});
|
|
890
|
+
} catch (e) {
|
|
891
|
+
if (dir === root) throw e;
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
w.on("error", (e) => {
|
|
895
|
+
try { w.close(); } catch {}
|
|
896
|
+
watchers.delete(dir);
|
|
897
|
+
if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
|
|
898
|
+
});
|
|
899
|
+
watchers.set(dir, w);
|
|
900
|
+
};
|
|
901
|
+
const scan = (dir) => {
|
|
902
|
+
add(dir);
|
|
903
|
+
let entries = [];
|
|
904
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
|
|
905
|
+
for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
|
|
906
|
+
};
|
|
907
|
+
scan(root);
|
|
908
|
+
tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
|
|
909
|
+
return tree;
|
|
910
|
+
}
|
|
911
|
+
function startWatcher() {
|
|
912
|
+
watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
|
|
913
|
+
: fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
|
|
914
|
+
watcher.on("error", (e) => {
|
|
915
|
+
console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
|
|
916
|
+
try { watcher.close(); } catch {}
|
|
917
|
+
setTimeout(() => {
|
|
918
|
+
if (quitting) return;
|
|
919
|
+
try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
|
|
920
|
+
broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
|
|
921
|
+
}, 1000);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
657
924
|
|
|
658
925
|
/* ---- Hand edits ----------------------------------------------------------------------------------------- */
|
|
659
926
|
function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
|
|
@@ -690,21 +957,42 @@ function deletePlan(eid) {
|
|
|
690
957
|
return { ...target, html: rest, files };
|
|
691
958
|
}
|
|
692
959
|
|
|
693
|
-
/* ---- Undo (assistant turns and hand edits share one history)
|
|
960
|
+
/* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
|
|
961
|
+
Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
|
|
962
|
+
every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
|
|
963
|
+
files, and the pictures or audio files they would delete or bring back. Anything else means the files
|
|
964
|
+
were changed since, by hand or by another program, and going back would silently throw that work away.
|
|
965
|
+
Then nothing is written and the answer is 409 with the files that differ. */
|
|
966
|
+
function undoConflicts(t, direction) {
|
|
967
|
+
const expect = direction === "undo" ? t.after : t.before;
|
|
968
|
+
const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
|
|
969
|
+
// undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
|
|
970
|
+
// ones (which must be unchanged); redo the other way round
|
|
971
|
+
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
972
|
+
const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
|
|
973
|
+
for (const { rel, data } of comeBack) {
|
|
974
|
+
const abs = path.join(DECK_DIR, rel);
|
|
975
|
+
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
976
|
+
}
|
|
977
|
+
for (const { rel, data } of go) {
|
|
978
|
+
const abs = path.join(DECK_DIR, rel);
|
|
979
|
+
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
980
|
+
}
|
|
981
|
+
return files;
|
|
982
|
+
}
|
|
983
|
+
function conflict(res, direction, files) {
|
|
984
|
+
json(res, 409, { code: "diverged", files,
|
|
985
|
+
error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
|
|
986
|
+
`it would overwrite those changes. Reload to see the files as they are.` });
|
|
987
|
+
}
|
|
694
988
|
function undo(res) {
|
|
695
989
|
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
696
990
|
const t = liveTurns().at(-1);
|
|
697
991
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
|
|
703
|
-
}
|
|
704
|
-
for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
|
|
705
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
706
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
707
|
-
}
|
|
992
|
+
const diverged = undoConflicts(t, "undo");
|
|
993
|
+
if (diverged.length) return conflict(res, "undo", diverged);
|
|
994
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
995
|
+
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
708
996
|
for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
|
|
709
997
|
t.undone = true;
|
|
710
998
|
state.redo.push(t);
|
|
@@ -712,7 +1000,6 @@ function undo(res) {
|
|
|
712
1000
|
? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
|
|
713
1001
|
: `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.`);
|
|
714
1002
|
broadcast("undone", { turnId: t.id, undone: true });
|
|
715
|
-
if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
|
|
716
1003
|
pushStatus();
|
|
717
1004
|
return json(res, 200, {});
|
|
718
1005
|
}
|
|
@@ -724,7 +1011,6 @@ function undo(res) {
|
|
|
724
1011
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
725
1012
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
726
1013
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
727
|
-
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
728
1014
|
|
|
729
1015
|
function startImport(name, buf) {
|
|
730
1016
|
const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
|
|
@@ -771,12 +1057,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
771
1057
|
const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
|
|
772
1058
|
const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
|
|
773
1059
|
const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
|
|
774
|
-
for (const f of r.files)
|
|
775
|
-
const abs = path.join(DECK_DIR, f.rel);
|
|
776
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
777
|
-
ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
|
|
778
|
-
fs.writeFileSync(abs, f.data);
|
|
779
|
-
}
|
|
1060
|
+
for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
|
|
780
1061
|
const all = findSlides(parse(html));
|
|
781
1062
|
const first = afterIdx + 1, last = afterIdx + r.fragments.length;
|
|
782
1063
|
const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
|
|
@@ -834,43 +1115,142 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
834
1115
|
|
|
835
1116
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
836
1117
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
837
|
-
|
|
1118
|
+
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
1119
|
+
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
1120
|
+
const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
|
|
1121
|
+
|
|
1122
|
+
/* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
|
|
1123
|
+
where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
|
|
1124
|
+
code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
|
|
1125
|
+
and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
|
|
1126
|
+
carries this server's port. */
|
|
1127
|
+
const loginCodes = new Map(); // code -> expiry (ms)
|
|
1128
|
+
function loginCode(ttl) {
|
|
1129
|
+
const now = Date.now();
|
|
1130
|
+
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
1131
|
+
const code = crypto.randomBytes(16).toString("hex");
|
|
1132
|
+
loginCodes.set(code, now + ttl);
|
|
1133
|
+
return code;
|
|
1134
|
+
}
|
|
1135
|
+
const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
1136
|
+
const cookieName = () => `pindeck-${PORT}`;
|
|
1137
|
+
const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
|
|
1138
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
|
|
1139
|
+
function cookie(req, name) {
|
|
1140
|
+
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
1141
|
+
const i = part.indexOf("=");
|
|
1142
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
1147
|
+
const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
1148
|
+
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
1149
|
+
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
1150
|
+
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
|
|
1151
|
+
PinDeck checkout).</p>`;
|
|
1152
|
+
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
1153
|
+
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
1154
|
+
function login(res, url) {
|
|
1155
|
+
const code = url.searchParams.get("code") || "";
|
|
1156
|
+
const exp = loginCodes.get(code);
|
|
1157
|
+
loginCodes.delete(code);
|
|
1158
|
+
if (!exp || exp < Date.now()) {
|
|
1159
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1160
|
+
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
1161
|
+
}
|
|
1162
|
+
let next = url.searchParams.get("next") || "/";
|
|
1163
|
+
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
1164
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1165
|
+
"set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
|
|
1166
|
+
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
1167
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
1168
|
+
}
|
|
1169
|
+
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1170
|
+
its command line: the page sends it to a one-time login code, which leads on to the target. */
|
|
1171
|
+
function chromeStart(profile, target) {
|
|
1172
|
+
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
1173
|
+
const start = path.join(profile, "pindeck-start.html");
|
|
1174
|
+
const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&").replace(/"/g, """);
|
|
1175
|
+
fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
|
|
1176
|
+
return pathToFileURL(start).href;
|
|
1177
|
+
}
|
|
838
1178
|
|
|
839
|
-
|
|
840
|
-
|
|
1179
|
+
/* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
|
|
1180
|
+
Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
|
|
1181
|
+
function serveFile(res, file, transform, req) {
|
|
1182
|
+
const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
|
|
1183
|
+
if (transform) return fs.readFile(file, (err, data) => {
|
|
841
1184
|
if (err) return text(res, 404, "Not found");
|
|
842
|
-
const
|
|
843
|
-
res.writeHead(200, { "content-type":
|
|
844
|
-
res.end(
|
|
1185
|
+
const out = Buffer.from(transform(String(data)));
|
|
1186
|
+
res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
|
|
1187
|
+
res.end(out);
|
|
1188
|
+
});
|
|
1189
|
+
fs.stat(file, (err, st) => {
|
|
1190
|
+
if (err || !st.isFile()) return text(res, 404, "Not found");
|
|
1191
|
+
const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
|
|
1192
|
+
let start = 0, end = st.size - 1, code = 200;
|
|
1193
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
|
|
1194
|
+
if (range && (range[1] || range[2])) {
|
|
1195
|
+
if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
|
|
1196
|
+
else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
|
|
1197
|
+
if (start > end || start >= st.size) {
|
|
1198
|
+
res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
|
|
1199
|
+
return res.end();
|
|
1200
|
+
}
|
|
1201
|
+
code = 206;
|
|
1202
|
+
headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
|
|
1203
|
+
}
|
|
1204
|
+
headers["content-length"] = st.size ? end - start + 1 : 0;
|
|
1205
|
+
res.writeHead(code, headers);
|
|
1206
|
+
if (!st.size) return res.end();
|
|
1207
|
+
const stream = fs.createReadStream(file, { start, end });
|
|
1208
|
+
stream.on("error", () => res.destroy());
|
|
1209
|
+
res.on("close", () => stream.destroy());
|
|
1210
|
+
stream.pipe(res);
|
|
845
1211
|
});
|
|
846
1212
|
}
|
|
847
1213
|
|
|
1214
|
+
let ready = false;
|
|
848
1215
|
const server = http.createServer(async (req, res) => {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (!
|
|
1216
|
+
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
1217
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
1218
|
+
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
852
1219
|
const url = new URL(req.url, ORIGIN);
|
|
853
1220
|
const p = url.pathname;
|
|
854
1221
|
try {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1222
|
+
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1223
|
+
// UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
|
|
1224
|
+
const ok = authed(req);
|
|
1225
|
+
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
1226
|
+
if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
|
|
1227
|
+
return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
|
|
1228
|
+
}
|
|
858
1229
|
if (req.method === "GET" && p.startsWith("/editor/")) {
|
|
859
1230
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
860
|
-
return f ? serveFile(res, f) : text(res, 403, "Forbidden");
|
|
1231
|
+
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
861
1232
|
}
|
|
1233
|
+
if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
|
|
862
1234
|
let snap;
|
|
863
|
-
if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
|
|
1235
|
+
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
864
1236
|
const html = snapshots.get(snap[1]);
|
|
865
1237
|
if (!html) return text(res, 404, "Not found");
|
|
866
1238
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
867
1239
|
return res.end(html);
|
|
868
1240
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1241
|
+
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1242
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there.
|
|
1243
|
+
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1244
|
+
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1245
|
+
return res.end();
|
|
1246
|
+
}
|
|
1247
|
+
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
1248
|
+
: p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
|
|
1249
|
+
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1250
|
+
if (deckPath) {
|
|
1251
|
+
const f = deckPath;
|
|
872
1252
|
const mode = url.searchParams.get("editor");
|
|
873
|
-
if (!mode ||
|
|
1253
|
+
if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
|
|
874
1254
|
return serveFile(res, f, (s) => {
|
|
875
1255
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
876
1256
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -879,23 +1259,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
879
1259
|
});
|
|
880
1260
|
}
|
|
881
1261
|
|
|
882
|
-
// API: token required; POSTs must come from the editor page itself
|
|
1262
|
+
// API: token required (header or login cookie); POSTs must come from the editor page itself
|
|
883
1263
|
if (!p.startsWith("/api/")) return text(res, 404, "Not found");
|
|
884
|
-
|
|
885
|
-
if (tok !== TOKEN) return text(res, 403, "Bad token");
|
|
1264
|
+
if (!ok) return text(res, 403, "Bad token");
|
|
886
1265
|
const origin = req.headers.origin;
|
|
887
|
-
if (req.method === "POST" && origin && origin !==
|
|
1266
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
|
|
888
1267
|
return text(res, 403, "Bad origin");
|
|
889
1268
|
|
|
890
1269
|
if (p === "/api/events") {
|
|
891
1270
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
892
|
-
res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
|
|
1271
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
|
|
893
1272
|
turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
|
|
894
1273
|
clients.add(res);
|
|
895
1274
|
req.on("close", () => clients.delete(res));
|
|
896
1275
|
return;
|
|
897
1276
|
}
|
|
898
1277
|
if (p === "/api/ping") return json(res, 200, { ok: true });
|
|
1278
|
+
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
|
|
1279
|
+
if (p === "/api/status") return json(res, 200, status());
|
|
899
1280
|
if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
|
|
900
1281
|
if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
|
|
901
1282
|
if (p === "/api/measure-result" && req.method === "POST") {
|
|
@@ -907,6 +1288,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
907
1288
|
|
|
908
1289
|
if (p === "/api/message" && req.method === "POST") {
|
|
909
1290
|
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
1291
|
+
// checked again here: the claude or codex command may have been installed (or removed) since the start
|
|
1292
|
+
const backend = providerAvailable(settings.provider);
|
|
1293
|
+
if (!backend.available) return json(res, 503, { code: `${settings.provider}-unavailable`, error: backend.reason });
|
|
910
1294
|
const b = await body(req);
|
|
911
1295
|
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
912
1296
|
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
|
|
@@ -953,18 +1337,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
953
1337
|
const buf = Buffer.concat(chunks);
|
|
954
1338
|
if (kind === "pdf") {
|
|
955
1339
|
if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
|
|
956
|
-
fs.mkdirSync(PDF_DIR, { recursive: true });
|
|
1340
|
+
fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
|
|
957
1341
|
const abs = attachPath(original, PDF_DIR);
|
|
958
|
-
fs.writeFileSync(abs, buf);
|
|
1342
|
+
fs.writeFileSync(abs, buf, { mode: 0o600 });
|
|
959
1343
|
state.uploads.add(abs);
|
|
960
1344
|
let info = null;
|
|
961
|
-
if (
|
|
1345
|
+
if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
|
|
962
1346
|
return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
|
|
963
1347
|
}
|
|
964
1348
|
const rel = attachPath(original);
|
|
965
|
-
|
|
966
|
-
ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
967
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), buf);
|
|
1349
|
+
writeDeckFile(rel, buf);
|
|
968
1350
|
state.uploads.add(rel);
|
|
969
1351
|
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
|
|
970
1352
|
}
|
|
@@ -987,6 +1369,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
987
1369
|
if (p === "/api/slide-hidden" && req.method === "POST") {
|
|
988
1370
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
989
1371
|
const b = await body(req);
|
|
1372
|
+
if (staleEdit(res, b)) return;
|
|
990
1373
|
const before = snapshot(EDITABLE);
|
|
991
1374
|
const slides = findSlides(parse(before[DECK_FILE]));
|
|
992
1375
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
@@ -1002,6 +1385,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1002
1385
|
if (p === "/api/move-slide" && req.method === "POST") {
|
|
1003
1386
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1004
1387
|
const b = await body(req);
|
|
1388
|
+
if (staleEdit(res, b)) return;
|
|
1005
1389
|
const before = snapshot(EDITABLE);
|
|
1006
1390
|
const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
|
|
1007
1391
|
if (r.error) return json(res, 409, { error: r.error });
|
|
@@ -1014,6 +1398,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1014
1398
|
if (p === "/api/add-slide" && req.method === "POST") {
|
|
1015
1399
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1016
1400
|
const b = await body(req);
|
|
1401
|
+
if (staleEdit(res, b)) return;
|
|
1017
1402
|
if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
|
|
1018
1403
|
const before = snapshot(EDITABLE);
|
|
1019
1404
|
const r = insertSlide(before[DECK_FILE], b.kind, b.after);
|
|
@@ -1028,6 +1413,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1028
1413
|
return json(res, 200, { turnId, index: r.index, eid });
|
|
1029
1414
|
}
|
|
1030
1415
|
if (p === "/api/import" && req.method === "POST") {
|
|
1416
|
+
if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
|
|
1031
1417
|
const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
|
|
1032
1418
|
if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
|
|
1033
1419
|
const chunks = [];
|
|
@@ -1053,6 +1439,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1053
1439
|
if (p === "/api/import-apply" && req.method === "POST") {
|
|
1054
1440
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1055
1441
|
const b = await body(req);
|
|
1442
|
+
if (staleEdit(res, b)) return;
|
|
1056
1443
|
const imp = lastImport;
|
|
1057
1444
|
if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
|
|
1058
1445
|
const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
|
|
@@ -1068,7 +1455,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1068
1455
|
if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
|
|
1069
1456
|
Promise.resolve(session.interrupt()).catch(() => {});
|
|
1070
1457
|
setTimeout(() => {
|
|
1071
|
-
if (state.busy && state.turn === interruptedTurn) { session
|
|
1458
|
+
if (state.busy && state.turn === interruptedTurn) { session?.stop(); finishTurn({ error: "Stopped." }); }
|
|
1072
1459
|
}, 5000); // force only if the interrupted turn does not answer
|
|
1073
1460
|
}
|
|
1074
1461
|
return json(res, 200, {});
|
|
@@ -1087,7 +1474,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1087
1474
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
|
|
1088
1475
|
const t = state.turns.find((t) => t.id === +m[1]);
|
|
1089
1476
|
if (!t) return text(res, 404, "No such change");
|
|
1090
|
-
return text(res, 200,
|
|
1477
|
+
return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
|
|
1091
1478
|
}
|
|
1092
1479
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
|
|
1093
1480
|
const t = liveTurns().at(-1);
|
|
@@ -1099,18 +1486,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1099
1486
|
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
1100
1487
|
const t = state.redo.at(-1);
|
|
1101
1488
|
if (!t) return json(res, 409, { error: "Nothing to redo." });
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
pushStatus();
|
|
1105
|
-
return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
|
|
1106
|
-
}
|
|
1489
|
+
const diverged = undoConflicts(t, "redo");
|
|
1490
|
+
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1107
1491
|
state.redo.pop();
|
|
1108
|
-
for (const
|
|
1492
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1109
1493
|
for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
1110
|
-
for (const { rel, data } of t.addedFiles || [])
|
|
1111
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
1112
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
1113
|
-
}
|
|
1494
|
+
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1114
1495
|
t.undone = false;
|
|
1115
1496
|
state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
|
|
1116
1497
|
: `The user redid your change from turn ${t.id} after undoing it.`);
|
|
@@ -1128,6 +1509,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1128
1509
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1129
1510
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1130
1511
|
const b = await body(req);
|
|
1512
|
+
if (staleEdit(res, b)) return;
|
|
1131
1513
|
const plan = deletePlan(b.eid);
|
|
1132
1514
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
1133
1515
|
const before = snapshot(EDITABLE);
|
|
@@ -1147,6 +1529,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1147
1529
|
if (p === "/api/edit" && req.method === "POST") {
|
|
1148
1530
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1149
1531
|
const b = await body(req);
|
|
1532
|
+
if (staleEdit(res, b)) return;
|
|
1150
1533
|
const before = snapshot(EDITABLE);
|
|
1151
1534
|
const r = setStyles(before[DECK_FILE], b.ops || []);
|
|
1152
1535
|
if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
|
|
@@ -1168,6 +1551,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1168
1551
|
if (p === "/api/notes" && req.method === "POST") {
|
|
1169
1552
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1170
1553
|
const b = await body(req);
|
|
1554
|
+
if (staleEdit(res, b)) return;
|
|
1171
1555
|
const before = snapshot(EDITABLE);
|
|
1172
1556
|
const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
|
|
1173
1557
|
if (!r) return json(res, 404, { error: "Slide not found in the file." });
|
|
@@ -1182,6 +1566,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1182
1566
|
if (p === "/api/text" && req.method === "POST") {
|
|
1183
1567
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1184
1568
|
const b = await body(req);
|
|
1569
|
+
if (staleEdit(res, b)) return;
|
|
1185
1570
|
const before = snapshot(EDITABLE);
|
|
1186
1571
|
const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
|
|
1187
1572
|
if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1197,6 +1582,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1197
1582
|
if (p === "/api/split" && req.method === "POST") {
|
|
1198
1583
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1199
1584
|
const b = await body(req);
|
|
1585
|
+
if (staleEdit(res, b)) return;
|
|
1200
1586
|
const before = snapshot(EDITABLE);
|
|
1201
1587
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
1202
1588
|
if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1209,6 +1595,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1209
1595
|
if (p === "/api/remove" && req.method === "POST") {
|
|
1210
1596
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1211
1597
|
const b = await body(req);
|
|
1598
|
+
if (staleEdit(res, b)) return;
|
|
1212
1599
|
const before = snapshot(EDITABLE);
|
|
1213
1600
|
const html = removeElement(before[DECK_FILE], b.eid);
|
|
1214
1601
|
if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1220,13 +1607,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1220
1607
|
if (p === "/api/export" && req.method === "POST") {
|
|
1221
1608
|
const b = await body(req);
|
|
1222
1609
|
try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
|
|
1223
|
-
catch (e) { return json(res, 500, { error: String(e.message || e) }); }
|
|
1610
|
+
catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
|
|
1224
1611
|
}
|
|
1225
1612
|
|
|
1226
1613
|
// CLI endpoints (used by both backends via cli.mjs)
|
|
1227
1614
|
if (p === "/api/pdf") {
|
|
1228
1615
|
const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
|
|
1229
1616
|
if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
|
|
1617
|
+
if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
|
|
1230
1618
|
const r = await exportPdf(kind, url.searchParams.get("all") === "1");
|
|
1231
1619
|
return text(res, 200, r.path);
|
|
1232
1620
|
}
|
|
@@ -1243,8 +1631,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1243
1631
|
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1244
1632
|
if (!slide.eid) runEidPass();
|
|
1245
1633
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1246
|
-
if (p === "/api/shot")
|
|
1247
|
-
|
|
1634
|
+
if (p === "/api/shot") {
|
|
1635
|
+
if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
|
|
1636
|
+
return text(res, 200, await takeShot(s2));
|
|
1637
|
+
}
|
|
1638
|
+
try { return text(res, 200, formatLayout(await measure(s2))); }
|
|
1639
|
+
catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
|
|
1248
1640
|
}
|
|
1249
1641
|
return text(res, 404, "Not found");
|
|
1250
1642
|
} catch (e) {
|
|
@@ -1255,13 +1647,38 @@ const server = http.createServer(async (req, res) => {
|
|
|
1255
1647
|
}
|
|
1256
1648
|
});
|
|
1257
1649
|
|
|
1258
|
-
/* ---- Start
|
|
1259
|
-
|
|
1260
|
-
|
|
1650
|
+
/* ---- Start ----------------------------------------------------------------------------------------
|
|
1651
|
+
The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
|
|
1652
|
+
the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
|
|
1653
|
+
let quitting = false;
|
|
1654
|
+
server.on("error", (e) => {
|
|
1655
|
+
if (ready) return console.error(e);
|
|
1656
|
+
console.error(e.code === "EADDRINUSE"
|
|
1657
|
+
? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
|
|
1658
|
+
: `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
1659
|
+
process.exit(1);
|
|
1660
|
+
});
|
|
1261
1661
|
server.listen(PORT, "127.0.0.1", () => {
|
|
1262
|
-
|
|
1662
|
+
PORT = server.address().port;
|
|
1663
|
+
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
1664
|
+
try {
|
|
1665
|
+
writeServerInfo();
|
|
1666
|
+
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
1667
|
+
const first = runEidPass();
|
|
1668
|
+
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1669
|
+
startWatcher();
|
|
1670
|
+
} catch (e) {
|
|
1671
|
+
console.error(`PinDeck could not open the deck: ${e.message}`);
|
|
1672
|
+
removeServerInfo();
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
ready = true;
|
|
1676
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1677
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1263
1678
|
console.log(`Deck: ${DECK_DIR}`);
|
|
1264
|
-
|
|
1679
|
+
console.log(`Server: ${CAPS.label}`);
|
|
1680
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1681
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1265
1682
|
discoverModels().then(() => {
|
|
1266
1683
|
if (!settings.model) {
|
|
1267
1684
|
const models = catalog[settings.provider].models;
|
|
@@ -1271,6 +1688,22 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1271
1688
|
pushStatus();
|
|
1272
1689
|
});
|
|
1273
1690
|
});
|
|
1274
|
-
|
|
1691
|
+
/* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
|
|
1692
|
+
SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
|
|
1693
|
+
async function quit() {
|
|
1694
|
+
if (quitting) process.exit(1);
|
|
1695
|
+
quitting = true;
|
|
1696
|
+
try { watcher?.close(); } catch {}
|
|
1697
|
+
clearTimeout(flush); clearTimeout(eidTimer);
|
|
1698
|
+
for (const res of clients) res.end();
|
|
1699
|
+
for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
|
|
1700
|
+
server.close();
|
|
1701
|
+
server.closeAllConnections?.();
|
|
1702
|
+
removeServerInfo();
|
|
1703
|
+
for (const agent of [...discoveryAgents, session]) retire(agent);
|
|
1704
|
+
await Promise.race([Promise.all(retiring), new Promise((r) => setTimeout(r, 6000))]);
|
|
1705
|
+
if (chatTimer) writeChat();
|
|
1706
|
+
process.exit(0);
|
|
1707
|
+
}
|
|
1275
1708
|
process.on("SIGINT", quit);
|
|
1276
1709
|
process.on("SIGTERM", quit);
|