pindeck 3.1.0-beta.1 → 3.2.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 +142 -58
- package/cli.mjs +19 -9
- package/front.mjs +138 -43
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +28 -5
- package/lib/deck-target.mjs +21 -3
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +15 -1
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/platform-notice.mjs +2 -2
- package/lib/servers.mjs +91 -0
- package/package.json +2 -2
- package/public/bridge.js +56 -7
- package/public/editor.css +8 -2
- package/public/editor.js +191 -83
- package/public/events-worker.js +47 -0
- package/public/index.html +1 -1
- package/server.mjs +452 -155
package/front.mjs
CHANGED
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
import http from "node:http";
|
|
18
18
|
import fs from "node:fs";
|
|
19
|
-
import os from "node:os";
|
|
20
19
|
import path from "node:path";
|
|
21
20
|
import crypto from "node:crypto";
|
|
22
21
|
import { spawn } from "node:child_process";
|
|
23
22
|
import { fileURLToPath } from "node:url";
|
|
24
23
|
import { isSupportedPlatform, detectCapabilities, publicCapabilities, parsePort } from "./lib/capabilities.mjs";
|
|
25
|
-
import { deckTarget, homeDir } from "./lib/deck-target.mjs";
|
|
26
|
-
import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
|
|
24
|
+
import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
|
|
25
|
+
import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
27
26
|
import { listDecks } from "./lib/deck-list.mjs";
|
|
27
|
+
import { assetRoots } from "./lib/asset-roots.mjs";
|
|
28
|
+
import { registerServer, printLogin } from "./lib/servers.mjs";
|
|
28
29
|
import { USAGE } from "./lib/usage.mjs";
|
|
29
30
|
|
|
30
31
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -48,38 +49,24 @@ if (!isSupportedPlatform(process.platform)) {
|
|
|
48
49
|
process.exit(0);
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/* pindeck login: a new one-time login link from the
|
|
52
|
-
(readable only by this user). With a deck, the link opens that deck.
|
|
53
|
-
|
|
54
|
-
const SERVER_FILE = path.join(HOME_DIR, "server.json");
|
|
55
|
-
if (argv[0] === "login") {
|
|
56
|
-
const deck = argv[1] ? deckTarget(argv[1]) : null;
|
|
57
|
-
let info = null;
|
|
58
|
-
for (const f of [...(deck ? [path.join(HOME_DIR, "decks", deck.key, "server.json")] : []), SERVER_FILE]) {
|
|
59
|
-
try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {}
|
|
60
|
-
}
|
|
61
|
-
if (!info) { console.error("No PinDeck is running."); process.exit(1); }
|
|
62
|
-
const next = deck && info.front ? `/d/${deck.key}/` : "/";
|
|
63
|
-
try {
|
|
64
|
-
const res = await fetch(`${info.url}/api/login-link?` + new URLSearchParams({ next }), { headers: { "x-editor-token": info.token } });
|
|
65
|
-
if (!res.ok) throw new Error(res.status);
|
|
66
|
-
console.log(`PinDeck (${deck ? path.join(deck.dir, deck.file) : info.deckDir}): ${(await res.json()).url}`);
|
|
67
|
-
process.exit(0);
|
|
68
|
-
} catch {
|
|
69
|
-
console.error(`The PinDeck at ${info.url} does not answer. Is it still running?`);
|
|
70
|
-
process.exit(1);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
52
|
+
/* pindeck login: a new one-time login link from the PinDeck that serves the deck (see lib/servers.mjs), asked
|
|
53
|
+
with the token in its server file (readable only by this user). With a deck, the link opens that deck. */
|
|
54
|
+
if (argv[0] === "login") await printLogin(HOME_DIR, argv[1] ? deckTarget(argv[1]) : null);
|
|
73
55
|
|
|
74
56
|
/* The start deck, and the folders whose decks the menu offers. A folder without index.html opens the list
|
|
75
57
|
of its decks; if it has none, its deck server says why, as for any deck that cannot be opened. */
|
|
58
|
+
const homeNote = homeWarning(HOME_DIR);
|
|
59
|
+
if (homeNote) console.warn(homeNote);
|
|
76
60
|
const START = deckTarget(argv[0] || process.cwd());
|
|
77
|
-
|
|
61
|
+
if (process.env.CLAUDE_MODEL) // PinDeck 2 read it
|
|
62
|
+
console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
|
|
78
63
|
let fileConfig = {};
|
|
79
64
|
try { fileConfig = JSON.parse(fs.readFileSync(path.join(HOME_DIR, "config.json"), "utf8")); } catch {}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
if (!fileConfig || typeof fileConfig !== "object" || Array.isArray(fileConfig)) fileConfig = {}; // the deck server says why
|
|
66
|
+
// a missing asset root stops the start, before anything is written; one that goes later lists nothing
|
|
67
|
+
const givenRoots = assetRoots(assetArgs, fileConfig);
|
|
68
|
+
if (givenRoots.missing.length) { console.error(`Asset root ${givenRoots.missing[0]} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
69
|
+
const ROOTS = [...new Set([START.dir, ...givenRoots.roots])];
|
|
83
70
|
const decksNow = () => listDecks(ROOTS);
|
|
84
71
|
const chooser = !START.named && !fs.existsSync(path.join(START.dir, START.file)) && decksNow().some((d) => !d.problem);
|
|
85
72
|
|
|
@@ -93,10 +80,10 @@ const decks = new Map(); // key -> { key, dir, file, label, proc, url, token,
|
|
|
93
80
|
const labelOf = (d) => (d.file === "index.html" ? path.basename(d.dir) : d.file);
|
|
94
81
|
let quitting = false;
|
|
95
82
|
|
|
96
|
-
/* Its output goes to this terminal, each line marked with the deck. hold(): keep the
|
|
83
|
+
/* Its output goes to this terminal, each line marked with the deck. hold(l): keep the line back for now */
|
|
97
84
|
function forward(stream, label, to, keep, hold = () => false) {
|
|
98
85
|
let rest = "";
|
|
99
|
-
const put = (l) => { keep?.(l + "\n"); if (!hold()) to.write(`[${label}] ${l}\n`); };
|
|
86
|
+
const put = (l) => { keep?.(l + "\n"); if (!hold(l)) to.write(`[${label}] ${l}\n`); };
|
|
100
87
|
stream.on("data", (chunk) => {
|
|
101
88
|
const lines = (rest + chunk).split("\n");
|
|
102
89
|
rest = lines.pop();
|
|
@@ -114,15 +101,20 @@ function openDeck(entry, start = false) {
|
|
|
114
101
|
decks.set(d.key, d);
|
|
115
102
|
// its own process group: Ctrl+C reaches the front only, which then ends every deck once
|
|
116
103
|
d.proc = spawn(process.execPath, [SERVER, entry.file === "index.html" ? entry.dir : path.join(entry.dir, entry.file),
|
|
117
|
-
"--port", "0", ...PASS], { env: { ...process.env, PINDECK_FRONT: "1"
|
|
104
|
+
"--port", "0", ...PASS], { env: { ...process.env, PINDECK_FRONT: "1", PINDECK_FRONT_URL: ORIGIN },
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"], detached: true });
|
|
106
|
+
// what it says before it listens: its reason if it stops; the start deck's warnings once it runs
|
|
118
107
|
let said = "";
|
|
108
|
+
const held = [];
|
|
119
109
|
forward(d.proc.stdout, d.label, process.stdout);
|
|
120
|
-
forward(d.proc.stderr, d.label, process.stderr, (l) => {
|
|
110
|
+
forward(d.proc.stderr, d.label, process.stderr, (l) => { if (!d.url) said = (said + l).slice(-20000); },
|
|
111
|
+
(l) => start && !d.url && held.push(l));
|
|
121
112
|
d.exited = new Promise((r) => d.proc.once("exit", r));
|
|
122
113
|
d.ready = new Promise((resolve, reject) => {
|
|
123
114
|
d.proc.on("message", (m) => {
|
|
124
115
|
if (!m?.ready) return;
|
|
125
116
|
Object.assign(d, { url: m.ready.url, token: m.ready.token });
|
|
117
|
+
for (const l of held.splice(0)) console.error(l); // a start that went ahead anyway, as PinDeck said it before the front
|
|
126
118
|
if (!start) console.log(`[${d.label}] opened: ${path.join(d.dir, d.file)}`);
|
|
127
119
|
resolve(d);
|
|
128
120
|
});
|
|
@@ -156,14 +148,18 @@ setInterval(async () => {
|
|
|
156
148
|
|
|
157
149
|
/* The request, without the front's cookie and with the deck's token, to the deck's server; the answer
|
|
158
150
|
streamed back (event streams, media ranges and large uploads included) */
|
|
159
|
-
function proxy(req, res, d, rest) {
|
|
151
|
+
function proxy(req, res, d, rest, anonymous = false) {
|
|
160
152
|
d.last = Date.now();
|
|
161
|
-
const headers = { ...req.headers, host: new URL(d.url).host, "x-editor-token": d.token };
|
|
153
|
+
const headers = { ...req.headers, host: new URL(d.url).host, ...(anonymous ? {} : { "x-editor-token": d.token }) };
|
|
154
|
+
if (anonymous) delete headers["x-editor-token"];
|
|
162
155
|
delete headers.cookie;
|
|
163
156
|
delete headers.origin; // checked here against the front's address; the deck server checks its own
|
|
164
157
|
const up = http.request(d.url + rest, { method: req.method, headers }, (r) => {
|
|
165
158
|
res.writeHead(r.statusCode, r.headers);
|
|
166
159
|
r.pipe(res);
|
|
160
|
+
// the deck server stopped mid-answer (an event stream, say): end ours too, or the browser waits forever
|
|
161
|
+
r.on("aborted", () => res.destroy());
|
|
162
|
+
r.on("error", () => res.destroy());
|
|
167
163
|
});
|
|
168
164
|
up.on("error", () => { if (!res.headersSent) page(res, 502, "This deck stopped answering", "Reload the page to open it again."); else res.destroy(); });
|
|
169
165
|
if (rest.split("?")[0] === "/api/events") {
|
|
@@ -174,6 +170,83 @@ function proxy(req, res, d, rest) {
|
|
|
174
170
|
req.pipe(up);
|
|
175
171
|
}
|
|
176
172
|
|
|
173
|
+
/* ---- One event stream per browser -------------------------------------------------------------------
|
|
174
|
+
Browsers open at most 6 connections to one host and port, and each editor tab held one open for its deck's
|
|
175
|
+
events: a sixth tab never loaded. The tabs of a browser share one stream through a shared worker
|
|
176
|
+
(public/events-worker.js): GET /events?stream=<id> holds it, POST /events/subscribe adds a tab's deck (the
|
|
177
|
+
front opens that deck's events on its server and passes them on, marked with the tab) or removes it. If a
|
|
178
|
+
deck server stops, the deck is opened again and the tab gets its state afresh (its "hello"). */
|
|
179
|
+
const streams = new Map(); // stream id -> { res, tabs: Map(tab -> { stop() }) }
|
|
180
|
+
function eventStream(res, id) {
|
|
181
|
+
streams.get(id)?.res.end(); // the worker reconnected: its old stream is done
|
|
182
|
+
const st = { res, tabs: new Map() };
|
|
183
|
+
streams.set(id, st);
|
|
184
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
185
|
+
res.write("event: ready\ndata: {}\n\n");
|
|
186
|
+
const ping = setInterval(() => res.write(": ping\n\n"), 20000);
|
|
187
|
+
res.on("close", () => {
|
|
188
|
+
clearInterval(ping);
|
|
189
|
+
if (streams.get(id) === st) streams.delete(id);
|
|
190
|
+
for (const t of st.tabs.values()) t.stop();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function follow(st, tab, key) {
|
|
194
|
+
let stopped = false, up = null, tries = 0;
|
|
195
|
+
const send = (type, raw) => {
|
|
196
|
+
if (!stopped && !st.res.writableEnded) st.res.write(`event: deck\ndata: {"tab":${JSON.stringify(tab)},"type":${JSON.stringify(type)},"data":${raw}}\n\n`);
|
|
197
|
+
};
|
|
198
|
+
const retry = () => {
|
|
199
|
+
if (stopped || quitting) return;
|
|
200
|
+
if (++tries > 20) return send("gone", JSON.stringify({ error: "The deck stopped answering. Reload the page." }));
|
|
201
|
+
setTimeout(connect, Math.min(5000, 200 * tries));
|
|
202
|
+
};
|
|
203
|
+
async function connect() {
|
|
204
|
+
if (stopped || quitting) return;
|
|
205
|
+
let d = decks.get(key);
|
|
206
|
+
try {
|
|
207
|
+
if (!d) {
|
|
208
|
+
const entry = START.key === key && !chooser ? { key, dir: START.dir, file: START.file } : decksNow().find((e) => e.key === key);
|
|
209
|
+
if (!entry || entry.problem) return send("gone", JSON.stringify({ error: entry?.problem || "This deck is not in the deck folder or an asset root (any more)." }));
|
|
210
|
+
d = entry;
|
|
211
|
+
}
|
|
212
|
+
d = await openDeck(d);
|
|
213
|
+
} catch (e) { return send("gone", JSON.stringify({ error: e.message })); }
|
|
214
|
+
if (stopped) return;
|
|
215
|
+
up = http.get(d.url + "/api/events", { headers: { "x-editor-token": d.token } }, (r) => {
|
|
216
|
+
if (r.statusCode !== 200) { r.resume(); return retry(); }
|
|
217
|
+
d.sse++;
|
|
218
|
+
tries = 0;
|
|
219
|
+
let buf = "", ended = false;
|
|
220
|
+
r.setEncoding("utf8");
|
|
221
|
+
r.on("data", (chunk) => {
|
|
222
|
+
buf += chunk;
|
|
223
|
+
for (let i; (i = buf.indexOf("\n\n")) >= 0;) {
|
|
224
|
+
const block = buf.slice(0, i);
|
|
225
|
+
buf = buf.slice(i + 2);
|
|
226
|
+
const type = /^event: (.*)$/m.exec(block)?.[1], data = /^data: (.*)$/m.exec(block)?.[1];
|
|
227
|
+
if (type && data) send(type, data);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
const end = () => {
|
|
231
|
+
if (ended) return;
|
|
232
|
+
ended = true;
|
|
233
|
+
d.sse--;
|
|
234
|
+
d.last = Date.now();
|
|
235
|
+
if (!stopped) { send("gone", "{}"); retry(); }
|
|
236
|
+
};
|
|
237
|
+
r.on("end", end); r.on("aborted", end); r.on("error", end); r.on("close", end);
|
|
238
|
+
});
|
|
239
|
+
up.on("error", retry);
|
|
240
|
+
}
|
|
241
|
+
connect();
|
|
242
|
+
return { stop() { stopped = true; up?.destroy(); } };
|
|
243
|
+
}
|
|
244
|
+
async function readJson(req) {
|
|
245
|
+
let s = "";
|
|
246
|
+
for await (const c of req) { s += c; if (s.length > 10000) throw new Error("body too large"); }
|
|
247
|
+
return s ? JSON.parse(s) : {};
|
|
248
|
+
}
|
|
249
|
+
|
|
177
250
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
178
251
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
179
252
|
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(s); };
|
|
@@ -210,12 +283,21 @@ function serveEditorFile(res, rel) {
|
|
|
210
283
|
const server = http.createServer(async (req, res) => {
|
|
211
284
|
const host = String(req.headers.host || "").toLowerCase();
|
|
212
285
|
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
213
|
-
const url = new URL(req.url, ORIGIN);
|
|
214
|
-
const p = url.pathname;
|
|
215
286
|
try {
|
|
287
|
+
const url = new URL(req.url, ORIGIN);
|
|
288
|
+
const p = url.pathname;
|
|
216
289
|
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
217
290
|
// the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
|
|
218
291
|
if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
|
|
292
|
+
/* A deck's pages and files under /d/<deck>/v/<token>/: asked for by the deck itself, which runs sandboxed
|
|
293
|
+
without the login cookie (see VIEW in server.mjs). Passed on without the token, so the deck server's own
|
|
294
|
+
check of the path is all they get; only to a deck that is open, so they never start one. */
|
|
295
|
+
const view = /^\/d\/([\w.-]+)(\/v\/.*)$/.exec(p);
|
|
296
|
+
if (view && !authed(req)) {
|
|
297
|
+
const d = decks.get(view[1]);
|
|
298
|
+
if (!d?.url || (req.method !== "GET" && req.method !== "HEAD")) return text(res, 403, "Not logged in");
|
|
299
|
+
return proxy(req, res, d, view[2] + url.search, true);
|
|
300
|
+
}
|
|
219
301
|
if (!authed(req)) {
|
|
220
302
|
if (req.method === "GET" && /^\/(d\/[^/]+\/(index\.html)?)?$/.test(p)) {
|
|
221
303
|
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
@@ -234,6 +316,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
234
316
|
if (p === "/api/ping") return json(res, 200, { ok: true, front: true });
|
|
235
317
|
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000), url.searchParams.get("next") || "/") });
|
|
236
318
|
if (p === "/api/decks") return json(res, 200, { decks: deckList() });
|
|
319
|
+
if (p === "/events" && req.method === "GET") {
|
|
320
|
+
const id = url.searchParams.get("stream") || "";
|
|
321
|
+
return /^[\w-]{8,64}$/.test(id) ? eventStream(res, id) : text(res, 400, "Bad stream");
|
|
322
|
+
}
|
|
323
|
+
if (p === "/events/subscribe" && req.method === "POST") {
|
|
324
|
+
const b = await readJson(req);
|
|
325
|
+
const st = streams.get(String(b.stream));
|
|
326
|
+
const tab = String(b.tab || "");
|
|
327
|
+
if (!st || !/^\d{1,9}$/.test(tab)) return json(res, 404, { error: "No such stream." });
|
|
328
|
+
st.tabs.get(tab)?.stop();
|
|
329
|
+
st.tabs.delete(tab);
|
|
330
|
+
if (b.on && /^[\w.-]+$/.test(String(b.deck))) st.tabs.set(tab, follow(st, tab, String(b.deck)));
|
|
331
|
+
return json(res, 200, {});
|
|
332
|
+
}
|
|
237
333
|
|
|
238
334
|
const m = /^\/d\/([\w.-]+)(\/.*)?$/.exec(p);
|
|
239
335
|
if (!m) return text(res, 404, "Not found");
|
|
@@ -254,20 +350,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
254
350
|
catch (e) { return page(res, 502, "PinDeck could not open this deck", `<pre>${esc(e.message)}</pre><p><a href="/">All decks</a></p>`); }
|
|
255
351
|
return proxy(req, res, d, m[2] + url.search);
|
|
256
352
|
} catch (e) {
|
|
257
|
-
|
|
353
|
+
const bad = e instanceof SyntaxError || e instanceof URIError || e.message === "body too large";
|
|
354
|
+
if (!res.headersSent) text(res, bad ? 400 : 500, String(e.message || e));
|
|
258
355
|
}
|
|
259
356
|
});
|
|
260
357
|
|
|
261
358
|
/* ---- Start ----------------------------------------------------------------------------------------
|
|
262
359
|
The port is bound first, then the start deck opens (its server checks it before writing anything). If it
|
|
263
360
|
cannot, the front says why and stops: as before the front, nothing is written. */
|
|
361
|
+
let removeServerInfo = () => {};
|
|
264
362
|
function writeServerInfo() {
|
|
265
363
|
fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
266
|
-
|
|
267
|
-
fs.chmodSync(SERVER_FILE, 0o600);
|
|
268
|
-
}
|
|
269
|
-
function removeServerInfo() {
|
|
270
|
-
try { if (JSON.parse(fs.readFileSync(SERVER_FILE, "utf8")).token === TOKEN) fs.rmSync(SERVER_FILE, { force: true }); } catch {}
|
|
364
|
+
removeServerInfo = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, deckDir: START.dir, front: true });
|
|
271
365
|
}
|
|
272
366
|
let listening = false;
|
|
273
367
|
server.on("error", (e) => {
|
|
@@ -291,6 +385,7 @@ server.listen(PORT, "127.0.0.1", async () => {
|
|
|
291
385
|
writeServerInfo();
|
|
292
386
|
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
293
387
|
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
388
|
+
openOnEnter(() => loginUrl(loginCode(3600000)));
|
|
294
389
|
console.log(chooser ? `Decks: ${START.dir}` : `Deck: ${START.dir}`);
|
|
295
390
|
console.log(`Server: ${CAPS.label}`);
|
|
296
391
|
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
package/lib/agent-process.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/* JSON-line subprocess transport. Requests and notifications never outlive their process. */
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import readline from 'node:readline';
|
|
4
5
|
export class AgentProcess {
|
|
@@ -21,7 +22,9 @@ export class AgentProcess {
|
|
|
21
22
|
});
|
|
22
23
|
this.proc.stderr.on('data', d => { this.stderr = (this.stderr + d).slice(-2000); onStderr(String(d)); });
|
|
23
24
|
this.proc.stdin.on('error', e => this.end(e));
|
|
24
|
-
|
|
25
|
+
// spawn reports a missing working folder as the binary's ENOENT: name the folder instead
|
|
26
|
+
this.proc.on('error', e => this.end(e.code === 'ENOENT' && cwd && !fs.existsSync(cwd)
|
|
27
|
+
? new Error(`The deck folder ${cwd} is not there any more (moved or deleted).`) : e));
|
|
25
28
|
this.proc.on('exit', (code, signal) => this.end(new Error(`Agent stopped (${signal || code}). ${this.stderr.trim()}`)));
|
|
26
29
|
}
|
|
27
30
|
write(value) { if (this.closed) throw new Error('Agent is not running.'); this.proc.stdin.write(JSON.stringify(value) + '\n'); }
|
package/lib/agent-tools.mjs
CHANGED
|
@@ -25,8 +25,11 @@ export function createDeckTools({ deckDir, readDirs = [], cliFile, env, pdfTextF
|
|
|
25
25
|
const roots = () => [deck, ...readDirs.flatMap(d => { try { return [fs.realpathSync(d)]; } catch { return []; } })];
|
|
26
26
|
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
27
27
|
const envNow = () => (typeof env === 'function' ? env() : env);
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// compared regardless of case and Unicode form: on macOS's usual volumes .GIT/config and BRAND.CSS are the
|
|
29
|
+
// locked files themselves
|
|
30
|
+
const fold = p => p.normalize('NFC').toLowerCase();
|
|
31
|
+
const isLocked = rel => (typeof locked === 'function' ? locked() : locked).map(fold)
|
|
32
|
+
.some(p => p.endsWith('/**') ? fold(rel) === p.slice(0, -3) || fold(rel).startsWith(p.slice(0, -2)) : fold(rel) === p);
|
|
30
33
|
function checked(file, write = false) {
|
|
31
34
|
if (typeof file !== 'string' || !file || file.includes('\0')) throw new Error('Invalid file path.');
|
|
32
35
|
const full = path.resolve(deck, file);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Asset roots: folders outside the deck whose files the deck may use, from --asset-root (repeatable) and
|
|
2
|
+
"assetRoots" in config.json. Shared by the front, which lists their decks, and each deck server, which
|
|
3
|
+
serves their files, so both read them alike. */
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
10
|
+
|
|
11
|
+
/* { roots, missing }: absolute paths, each once, in the order given; missing: those that are not folders */
|
|
12
|
+
export function assetRoots(args = [], config = {}) {
|
|
13
|
+
const roots = [], missing = [];
|
|
14
|
+
for (const r of [...args, ...(Array.isArray(config?.assetRoots) ? config.assetRoots : [])]) {
|
|
15
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
16
|
+
if (roots.includes(abs) || missing.includes(abs)) continue;
|
|
17
|
+
let dir = false;
|
|
18
|
+
try { dir = fs.statSync(abs).isDirectory(); } catch {}
|
|
19
|
+
(dir ? roots : missing).push(abs);
|
|
20
|
+
}
|
|
21
|
+
return { roots, missing };
|
|
22
|
+
}
|
package/lib/capabilities.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* What this server can do on this machine, decided once on the backend and used everywhere: the editor
|
|
2
2
|
(status), the API (refusals before any side effect), the CLI (its error text) and the assistant's system prompt.
|
|
3
3
|
|
|
4
|
-
macOS: everything, as before. Linux
|
|
4
|
+
macOS: everything, as before. Linux: editing, the chat, live reload and the PowerPoint import;
|
|
5
5
|
pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
|
|
6
6
|
waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
|
|
7
7
|
layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
|
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
|
|
13
13
|
export const SUPPORTED_PLATFORMS = ["darwin", "linux"];
|
|
14
14
|
export const isSupportedPlatform = (platform) => SUPPORTED_PLATFORMS.includes(platform);
|
|
15
|
-
export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux
|
|
15
|
+
export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux" }[platform] || platform);
|
|
16
16
|
|
|
17
17
|
const CHROME_PATHS = {
|
|
18
18
|
darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
package/lib/claude.mjs
CHANGED
|
@@ -12,11 +12,19 @@ export class ClaudeSession {
|
|
|
12
12
|
if (this.running) return;
|
|
13
13
|
this.stopped = false;
|
|
14
14
|
const env = typeof this.env === 'function' ? this.env() : this.env;
|
|
15
|
+
/* The sign-in check: "not signed in" only when the CLI says so. A CLI without `auth status --json` (older
|
|
16
|
+
than PinDeck's tested versions) or with other output starts anyway; the session then reports any problem. */
|
|
17
|
+
let signedIn = null;
|
|
15
18
|
try {
|
|
16
19
|
const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
|
|
17
20
|
{ env: { ...process.env, ...env }, timeout: 15000, maxBuffer: 65536 });
|
|
18
|
-
|
|
19
|
-
} catch (e) {
|
|
21
|
+
signedIn = JSON.parse(stdout).loggedIn;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e.code === 'ENOENT') throw new Error('Claude CLI is not installed (or set CLAUDE_BIN).');
|
|
24
|
+
if (e.killed) throw new Error('Claude did not answer its sign-in check within 15 s. Try again (Refresh models).');
|
|
25
|
+
try { signedIn = JSON.parse(e.stdout).loggedIn; } catch {} // signed out: the CLI answers, and exits 1
|
|
26
|
+
}
|
|
27
|
+
if (signedIn === false) throw new Error('Claude is not signed in. Run claude auth login, then refresh models.');
|
|
20
28
|
if (this.stopped) throw new Error('Agent stopped.');
|
|
21
29
|
const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env,
|
|
22
30
|
args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
package/lib/deck-files.mjs
CHANGED
|
@@ -57,17 +57,19 @@ export function revision(dir, files) {
|
|
|
57
57
|
|
|
58
58
|
/* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
|
|
59
59
|
time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
|
|
60
|
-
export/ folders are left out; so are symlinks, which point elsewhere
|
|
60
|
+
export/ folders are left out; so are symlinks, which point elsewhere, and the paths in skip (other decks
|
|
61
|
+
in the folder: their files and folders, as paths relative to dir). */
|
|
61
62
|
const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
|
|
62
63
|
export const KEEP_BYTES = 2 * 1024 * 1024;
|
|
63
|
-
export function scanFiles(dir, maxBytes = KEEP_BYTES) {
|
|
64
|
-
const files = new Map();
|
|
64
|
+
export function scanFiles(dir, { maxBytes = KEEP_BYTES, skip = [] } = {}) {
|
|
65
|
+
const files = new Map(), skipped = new Set(skip);
|
|
65
66
|
const walk = (rel) => {
|
|
66
67
|
let entries = [];
|
|
67
68
|
try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
|
|
68
69
|
for (const e of entries) {
|
|
69
70
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
70
71
|
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
72
|
+
if (skipped.has(r)) continue;
|
|
71
73
|
if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
|
|
72
74
|
if (!e.isFile()) continue;
|
|
73
75
|
try {
|
package/lib/deck-list.mjs
CHANGED
|
@@ -11,18 +11,36 @@ const SKIP = new Set(["node_modules", "export"]); // dependencies, and PinDeck
|
|
|
11
11
|
const MAX_FILES = 300;
|
|
12
12
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
13
13
|
|
|
14
|
+
// symlinks count as what they point to
|
|
15
|
+
const kind = (dir, e) => { if (!e.isSymbolicLink()) return e; try { return fs.statSync(path.join(dir, e.name)); } catch { return e; } };
|
|
14
16
|
const htmlFiles = (dir) => {
|
|
15
17
|
try {
|
|
16
18
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
17
|
-
.filter((e) =>
|
|
19
|
+
.filter((e) => /\.html$/i.test(e.name) && !e.name.startsWith(".") && kind(dir, e).isFile()).map((e) => e.name).sort();
|
|
18
20
|
} catch { return []; }
|
|
19
21
|
};
|
|
20
22
|
const subfolders = (dir) => {
|
|
21
23
|
try {
|
|
22
24
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
23
|
-
.filter((e) =>
|
|
25
|
+
.filter((e) => !e.name.startsWith(".") && !SKIP.has(e.name) && kind(dir, e).isDirectory()).map((e) => e.name).sort();
|
|
24
26
|
} catch { return []; }
|
|
25
27
|
};
|
|
28
|
+
/* A deck file that is a symlink to a file in another folder cannot be opened: its relative paths (theme.css,
|
|
29
|
+
figures/) would be looked up beside the link, where the files are not. Why, or null. */
|
|
30
|
+
export function linkProblem(dir, file) {
|
|
31
|
+
const abs = path.join(dir, file);
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.lstatSync(abs).isSymbolicLink()) return null;
|
|
34
|
+
const real = fs.realpathSync(abs);
|
|
35
|
+
if (path.dirname(real) === fs.realpathSync(dir)) return null;
|
|
36
|
+
return `${file} is a link to a deck in another folder: open that deck itself (${real}).`;
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* What each file's check found, by path, while its size and modification time stay the same: listing is
|
|
41
|
+
asked for on every deck menu and every first request for a deck, and reading every deck each time held up
|
|
42
|
+
the front, which passes on all decks' requests. */
|
|
43
|
+
const checked = new Map();
|
|
26
44
|
|
|
27
45
|
/* [{ key, dir, file, rel, root, problem }]: rel is the path shown in the menu, relative to its root;
|
|
28
46
|
problem is null for a deck PinDeck can open. A file under several roots is listed once, under the first. */
|
|
@@ -36,9 +54,14 @@ export function listDecks(roots) {
|
|
|
36
54
|
seen.add(abs);
|
|
37
55
|
let problem = null;
|
|
38
56
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
const st = fs.statSync(abs), known = checked.get(abs);
|
|
58
|
+
if (known && known.mtimeMs === st.mtimeMs && known.size === st.size) problem = known.problem;
|
|
59
|
+
else {
|
|
60
|
+
problem = st.size > MAX_BYTES ? `${file} is larger than 5 MB, too large for a deck.`
|
|
61
|
+
: unsupportedMessage(fs.readFileSync(abs, "utf8"), file)?.replace(/ Nothing was changed\.$/, "") || null;
|
|
62
|
+
checked.set(abs, { mtimeMs: st.mtimeMs, size: st.size, problem });
|
|
63
|
+
}
|
|
64
|
+
problem = linkProblem(dir, file) || problem;
|
|
42
65
|
} catch (e) { problem = `${file} cannot be read (${e.code || e.message}).`; }
|
|
43
66
|
const t = deckTarget(file === "index.html" ? dir : abs);
|
|
44
67
|
out.push({ key: t.key, dir, file, rel: path.relative(root, abs).split(path.sep).join("/"), root, problem });
|
package/lib/deck-target.mjs
CHANGED
|
@@ -8,15 +8,33 @@ import crypto from "node:crypto";
|
|
|
8
8
|
|
|
9
9
|
export const homeDir = (env = process.env) => env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
10
10
|
|
|
11
|
-
/*
|
|
11
|
+
/* PinDeck 2 keeps its chats and settings in files of the same names, in other formats: the two versions in one
|
|
12
|
+
folder (one PINDECK_HOME for both) overwrite each other's. PinDeck 3 saves a provider with every chat and
|
|
13
|
+
PinDeck 2 never does, so a chat without one was written by version 2: copied over (which works), or written
|
|
14
|
+
by a PinDeck 2 that uses this folder too (which does not). Returns the warning, or null. */
|
|
15
|
+
export function homeWarning(home) {
|
|
16
|
+
let decks = [];
|
|
17
|
+
try { decks = fs.readdirSync(path.join(home, "decks")).slice(0, 500); } catch { return null; }
|
|
18
|
+
for (const d of decks) {
|
|
19
|
+
let chat;
|
|
20
|
+
try { chat = JSON.parse(fs.readFileSync(path.join(home, "decks", d, "chat.json"), "utf8")); } catch { continue; }
|
|
21
|
+
if (chat && typeof chat === "object" && !("provider" in chat))
|
|
22
|
+
return `${home} holds a chat written by PinDeck 2 (decks/${d}/chat.json). If PinDeck 2 uses this folder too, ` +
|
|
23
|
+
`give PinDeck 3 another one (PINDECK_HOME; by default ~/.pindeck-v3): the two versions overwrite each ` +
|
|
24
|
+
`other's settings and chats. Chats copied over from PinDeck 2 are fine.`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* A file (talk.html), or a folder, which means its index.html. A path ending in .html that is
|
|
12
30
|
not a folder is a file, so a missing file gets a clear message. */
|
|
13
31
|
export function deckTarget(target) {
|
|
14
32
|
const abs = path.resolve(target);
|
|
15
33
|
const isFolder = (() => { try { return fs.statSync(abs).isDirectory(); } catch { return false; } })();
|
|
16
|
-
const dir = isFolder || !/\.html
|
|
34
|
+
const dir = isFolder || !/\.html$/i.test(abs) ? abs : path.dirname(abs);
|
|
17
35
|
const file = dir === abs ? "index.html" : path.basename(abs);
|
|
18
36
|
const named = file !== "index.html"; // a deck file with its own name; several can share a folder
|
|
19
|
-
const name = named ? file.replace(/\.html
|
|
37
|
+
const name = named ? file.replace(/\.html$/i, "") : path.basename(dir); // for exports and titles
|
|
20
38
|
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
21
39
|
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
22
40
|
const key = (named ? `${path.basename(dir)}-${name}` : path.basename(dir)).replace(/[^\w.-]+/g, "_") + "-" +
|
package/lib/eids.mjs
CHANGED
|
@@ -276,7 +276,8 @@ function elementName(node) {
|
|
|
276
276
|
export function assignEids(html) {
|
|
277
277
|
const tree = parse(html);
|
|
278
278
|
const sections = findSlides(tree);
|
|
279
|
-
const
|
|
279
|
+
const sectionSet = new Set(sections);
|
|
280
|
+
const isSlide = slideSelector(tree) ? ((c) => sectionSet.has(c)) : ((c) => c.name === "section");
|
|
280
281
|
const used = new Set();
|
|
281
282
|
const edits = []; // { at, insert } or { start, end, replace }
|
|
282
283
|
const assigned = [], renamed = [];
|
|
@@ -286,9 +287,12 @@ export function assignEids(html) {
|
|
|
286
287
|
const mainCount = {};
|
|
287
288
|
titles.forEach((t) => { if (t.main) mainCount[t.main] = (mainCount[t.main] || 0) + 1; });
|
|
288
289
|
|
|
290
|
+
// each base remembers the next number to try: thousands of "…/li" stay linear, not quadratic
|
|
291
|
+
const next = new Map();
|
|
289
292
|
const unique = (base) => {
|
|
290
|
-
let name = base, k = 2;
|
|
293
|
+
let name = base, k = next.get(base) || 2;
|
|
291
294
|
while (used.has(name)) name = `${base}-${k++}`;
|
|
295
|
+
next.set(base, k);
|
|
292
296
|
used.add(name);
|
|
293
297
|
return name;
|
|
294
298
|
};
|
|
@@ -344,10 +348,13 @@ export function assignEids(html) {
|
|
|
344
348
|
});
|
|
345
349
|
|
|
346
350
|
if (!edits.length) return { html, changed: false, assigned, renamed, slides };
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
+
// in one pass over the file, not one copy of it per edit
|
|
352
|
+
edits.sort((a, b) => a.start - b.start);
|
|
353
|
+
const parts = [];
|
|
354
|
+
let pos = 0;
|
|
355
|
+
for (const e of edits) { parts.push(html.slice(pos, e.start), e.replace); pos = e.end; }
|
|
356
|
+
parts.push(html.slice(pos));
|
|
357
|
+
return { html: parts.join(""), changed: true, assigned, renamed, slides };
|
|
351
358
|
}
|
|
352
359
|
|
|
353
360
|
/* ---- Inline style edits (drag and resize in the editor) ---------------------------------- */
|
|
@@ -406,6 +413,8 @@ export function setStyles(html, edits) {
|
|
|
406
413
|
/* The element with this id, or a descendant reached by element-child indices (path), e.g. a
|
|
407
414
|
table cell [2] under a row that has the id. */
|
|
408
415
|
function nodeAt(html, eid, path = [], { anyKind = false } = {}) {
|
|
416
|
+
// an id and a path of child positions, or nothing (an element without an id must never match a missing one)
|
|
417
|
+
if (typeof eid !== "string" || !eid || !Array.isArray(path) || !path.every((i) => Number.isInteger(i) && i >= 0)) return null;
|
|
409
418
|
let node = null;
|
|
410
419
|
walk(parse(html), (c) => { if (node) return false; if (c.attrs?.["data-eid"]?.value === eid) { node = c; return false; } });
|
|
411
420
|
for (const i of path) node = node?.children[i] || null;
|
package/lib/login.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
share cookies across all ports of a host, so the cookie name carries the server's port. */
|
|
7
7
|
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
9
10
|
|
|
10
11
|
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
11
12
|
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
@@ -50,8 +51,10 @@ export function createLogin({ token, port, origin }) {
|
|
|
50
51
|
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
51
52
|
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
52
53
|
}
|
|
54
|
+
// a path on this server only: browsers drop tabs and line breaks from a URL, so "/\t/site" means "//site"
|
|
53
55
|
let next = url.searchParams.get("next") || "/";
|
|
54
|
-
if (!next.startsWith("/") || next
|
|
56
|
+
if (!next.startsWith("/") || /[\\\x00-\x1f\x7f]/.test(next) || next.startsWith("//") ||
|
|
57
|
+
new URL(next, "http://127.0.0.1").host !== "127.0.0.1") next = "/";
|
|
55
58
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
56
59
|
"set-cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/` });
|
|
57
60
|
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
@@ -59,3 +62,14 @@ export function createLogin({ token, port, origin }) {
|
|
|
59
62
|
}
|
|
60
63
|
return { loginCode, loginUrl, cookieName, authed, login };
|
|
61
64
|
}
|
|
65
|
+
|
|
66
|
+
/* On a Mac, in a terminal: Enter opens a new login link in the default browser (open(1)), so the printed link
|
|
67
|
+
need not be copied. Each Enter asks for a new link: a link works once. Elsewhere (a server reached through an
|
|
68
|
+
SSH tunnel, a test, a pipe) nothing changes. */
|
|
69
|
+
export function openOnEnter(newLink, { platform = process.platform, input = process.stdin, open = (url) => execFile("open", [url], () => {}) } = {}) {
|
|
70
|
+
if (platform !== "darwin" || !input.isTTY) return false;
|
|
71
|
+
console.log(" Press Enter to open PinDeck in your browser.");
|
|
72
|
+
input.setEncoding?.("utf8");
|
|
73
|
+
input.on("data", (d) => { if (/[\r\n]/.test(String(d))) open(newLink()); });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
package/lib/models.mjs
CHANGED
|
@@ -13,10 +13,13 @@ export function normalizeModels(provider, rows = []) {
|
|
|
13
13
|
isDefault: m.isDefault || id === 'default' }];
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
|
+
/* Saved settings as PinDeck uses them. A model only for the provider it was saved with: without one, Claude
|
|
17
|
+
gets Sonnet and Codex the backend's default ('' until the model list is known). */
|
|
16
18
|
export function migrateSettings(raw = {}) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const provider = PROVIDERS.includes(raw?.provider) ? raw.provider : 'claude';
|
|
20
|
+
return { provider,
|
|
21
|
+
model: typeof raw?.model === 'string' && raw.model ? raw.model : provider === 'claude' ? 'sonnet' : '',
|
|
22
|
+
effort: typeof raw?.effort === 'string' ? raw.effort : null };
|
|
20
23
|
}
|
|
21
24
|
export function selectModel(catalog, current, requested, preferences = {}) {
|
|
22
25
|
const provider = requested.provider ?? current.provider;
|
|
@@ -36,7 +39,10 @@ export function selectModel(catalog, current, requested, preferences = {}) {
|
|
|
36
39
|
}
|
|
37
40
|
/* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
|
|
38
41
|
export function handoverSummary(chat) {
|
|
39
|
-
|
|
42
|
+
// the conversation since it last started fresh; nothing to hand over: no summary
|
|
43
|
+
const since = chat.slice(chat.findLastIndex(e => e.fresh) + 1);
|
|
44
|
+
const recent = since.filter(e => ['user', 'assistant'].includes(e.kind) && e.text).slice(-24);
|
|
45
|
+
if (!recent.length) return '';
|
|
40
46
|
return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
|
|
41
47
|
recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
|
|
42
48
|
}
|