pindeck 3.0.0-beta.3 → 3.2.0-beta.1
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 +136 -38
- package/cli.mjs +19 -9
- package/front.mjs +413 -0
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +72 -0
- package/lib/deck-target.mjs +43 -0
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +75 -0
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/servers.mjs +91 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/bridge.js +56 -7
- package/public/editor.css +30 -3
- package/public/editor.js +260 -99
- package/public/events-worker.js +47 -0
- package/public/front.css +14 -0
- package/public/index.html +10 -2
- package/server.mjs +493 -235
package/front.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* PinDeck: the front, which is what `pindeck` starts.
|
|
3
|
+
|
|
4
|
+
pindeck [deck-dir | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>]
|
|
5
|
+
pindeck login [deck-dir | deck.html]
|
|
6
|
+
|
|
7
|
+
One port and one login for several decks. Each deck that is open runs its own deck server (server.mjs, on
|
|
8
|
+
a private port of 127.0.0.1 with a token of its own) and is reached here as /d/<deck key>/: the front logs
|
|
9
|
+
the browser in, then passes each request on with that deck's token. A deck starts when it is first asked
|
|
10
|
+
for (from the editor's deck menu, or a bookmark), so its chat, Claude session and undo stay while it is
|
|
11
|
+
open, and Claude can go on working in one deck while you edit another. A deck with no editor open for
|
|
12
|
+
PINDECK_IDLE_MS (30 minutes) is closed; it opens again when asked for.
|
|
13
|
+
|
|
14
|
+
The menu lists the decks in the start folder and the asset roots, and in their folders one level down
|
|
15
|
+
(lib/deck-list.mjs). Only those can be opened: there is no free path entry. */
|
|
16
|
+
|
|
17
|
+
import http from "node:http";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, parsePort } from "./lib/capabilities.mjs";
|
|
24
|
+
import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
|
|
25
|
+
import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
26
|
+
import { listDecks } from "./lib/deck-list.mjs";
|
|
27
|
+
import { assetRoots } from "./lib/asset-roots.mjs";
|
|
28
|
+
import { registerServer, printLogin } from "./lib/servers.mjs";
|
|
29
|
+
import { USAGE } from "./lib/usage.mjs";
|
|
30
|
+
|
|
31
|
+
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const SERVER = path.join(EDITOR_DIR, "server.mjs");
|
|
33
|
+
const HOME_DIR = homeDir();
|
|
34
|
+
const IDLE_MS = Number(process.env.PINDECK_IDLE_MS) || 30 * 60000;
|
|
35
|
+
|
|
36
|
+
const argv = process.argv.slice(2);
|
|
37
|
+
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
38
|
+
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
39
|
+
const portArg = opt("--port", process.env.PORT || 5180);
|
|
40
|
+
const PASS = []; // what every deck server gets: the backend and the asset roots
|
|
41
|
+
for (const name of ["--provider", "--model"]) { const v = opt(name); if (v !== undefined) PASS.push(name, v); }
|
|
42
|
+
const assetArgs = [];
|
|
43
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) { assetArgs.push(a); PASS.push("--asset-root", a); }
|
|
44
|
+
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
45
|
+
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
46
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
47
|
+
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
48
|
+
await showPlatformNotice(PORT);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
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);
|
|
55
|
+
|
|
56
|
+
/* The start deck, and the folders whose decks the menu offers. A folder without index.html opens the list
|
|
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);
|
|
60
|
+
const START = deckTarget(argv[0] || process.cwd());
|
|
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}.`);
|
|
63
|
+
let fileConfig = {};
|
|
64
|
+
try { fileConfig = JSON.parse(fs.readFileSync(path.join(HOME_DIR, "config.json"), "utf8")); } catch {}
|
|
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])];
|
|
70
|
+
const decksNow = () => listDecks(ROOTS);
|
|
71
|
+
const chooser = !START.named && !fs.existsSync(path.join(START.dir, START.file)) && decksNow().some((d) => !d.problem);
|
|
72
|
+
|
|
73
|
+
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
74
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
75
|
+
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
76
|
+
const CAPS = detectCapabilities();
|
|
77
|
+
|
|
78
|
+
/* ---- Deck servers -------------------------------------------------------------------------------- */
|
|
79
|
+
const decks = new Map(); // key -> { key, dir, file, label, proc, url, token, ready, exited, sse, last }
|
|
80
|
+
const labelOf = (d) => (d.file === "index.html" ? path.basename(d.dir) : d.file);
|
|
81
|
+
let quitting = false;
|
|
82
|
+
|
|
83
|
+
/* Its output goes to this terminal, each line marked with the deck. hold(l): keep the line back for now */
|
|
84
|
+
function forward(stream, label, to, keep, hold = () => false) {
|
|
85
|
+
let rest = "";
|
|
86
|
+
const put = (l) => { keep?.(l + "\n"); if (!hold(l)) to.write(`[${label}] ${l}\n`); };
|
|
87
|
+
stream.on("data", (chunk) => {
|
|
88
|
+
const lines = (rest + chunk).split("\n");
|
|
89
|
+
rest = lines.pop();
|
|
90
|
+
lines.forEach(put);
|
|
91
|
+
});
|
|
92
|
+
stream.on("end", () => { if (rest) put(rest); });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* The deck's server, started once: the promise resolves when it listens, or rejects with what it printed.
|
|
96
|
+
The start deck's complaints are printed by the front as they are, like those of PinDeck before the front. */
|
|
97
|
+
function openDeck(entry, start = false) {
|
|
98
|
+
const known = decks.get(entry.key);
|
|
99
|
+
if (known) return known.ready;
|
|
100
|
+
const d = { key: entry.key, dir: entry.dir, file: entry.file, label: labelOf(entry), sse: 0, last: Date.now() };
|
|
101
|
+
decks.set(d.key, d);
|
|
102
|
+
// its own process group: Ctrl+C reaches the front only, which then ends every deck once
|
|
103
|
+
d.proc = spawn(process.execPath, [SERVER, entry.file === "index.html" ? entry.dir : path.join(entry.dir, entry.file),
|
|
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
|
|
107
|
+
let said = "";
|
|
108
|
+
const held = [];
|
|
109
|
+
forward(d.proc.stdout, d.label, process.stdout);
|
|
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));
|
|
112
|
+
d.exited = new Promise((r) => d.proc.once("exit", r));
|
|
113
|
+
d.ready = new Promise((resolve, reject) => {
|
|
114
|
+
d.proc.on("message", (m) => {
|
|
115
|
+
if (!m?.ready) return;
|
|
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
|
|
118
|
+
if (!start) console.log(`[${d.label}] opened: ${path.join(d.dir, d.file)}`);
|
|
119
|
+
resolve(d);
|
|
120
|
+
});
|
|
121
|
+
d.proc.once("exit", (code, signal) => {
|
|
122
|
+
if (decks.get(d.key) === d) decks.delete(d.key);
|
|
123
|
+
if (d.url && !quitting) console.log(`[${d.label}] closed`);
|
|
124
|
+
reject(Object.assign(new Error(said.trim() || `The deck server stopped (${code ?? signal}).`), { exitCode: code || 1 }));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
d.ready.catch(() => {});
|
|
128
|
+
return d.ready;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stopDeck(d) {
|
|
132
|
+
try { d.proc.kill("SIGTERM"); } catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* A deck with no editor open (no event stream) and nothing to do for IDLE_MS is closed */
|
|
136
|
+
setInterval(async () => {
|
|
137
|
+
for (const d of decks.values()) {
|
|
138
|
+
if (!d.url || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(`${d.url}/api/status`, { headers: { "x-editor-token": d.token } });
|
|
141
|
+
const st = await res.json();
|
|
142
|
+
if (st.busy || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
|
|
143
|
+
} catch { continue; }
|
|
144
|
+
console.log(`[${d.label}] no editor open for ${Math.round(IDLE_MS / 60000)} min: closing it (it opens again when asked for)`);
|
|
145
|
+
stopDeck(d);
|
|
146
|
+
}
|
|
147
|
+
}, Math.min(60000, IDLE_MS / 2)).unref();
|
|
148
|
+
|
|
149
|
+
/* The request, without the front's cookie and with the deck's token, to the deck's server; the answer
|
|
150
|
+
streamed back (event streams, media ranges and large uploads included) */
|
|
151
|
+
function proxy(req, res, d, rest, anonymous = false) {
|
|
152
|
+
d.last = Date.now();
|
|
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"];
|
|
155
|
+
delete headers.cookie;
|
|
156
|
+
delete headers.origin; // checked here against the front's address; the deck server checks its own
|
|
157
|
+
const up = http.request(d.url + rest, { method: req.method, headers }, (r) => {
|
|
158
|
+
res.writeHead(r.statusCode, r.headers);
|
|
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());
|
|
163
|
+
});
|
|
164
|
+
up.on("error", () => { if (!res.headersSent) page(res, 502, "This deck stopped answering", "Reload the page to open it again."); else res.destroy(); });
|
|
165
|
+
if (rest.split("?")[0] === "/api/events") {
|
|
166
|
+
d.sse++;
|
|
167
|
+
res.on("close", () => { d.sse--; d.last = Date.now(); });
|
|
168
|
+
}
|
|
169
|
+
res.on("close", () => { if (!res.writableFinished) up.destroy(); });
|
|
170
|
+
req.pipe(up);
|
|
171
|
+
}
|
|
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
|
+
|
|
250
|
+
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
251
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
252
|
+
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(s); };
|
|
253
|
+
const json = (res, code, o) => { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(o)); };
|
|
254
|
+
function page(res, code, title, body) {
|
|
255
|
+
res.writeHead(code, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
256
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${esc(title)} · PinDeck</title>
|
|
257
|
+
<link rel="icon" href="/editor/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/editor/front.css">
|
|
258
|
+
<main class="front"><h1>${esc(title)}</h1>${body}</main>`);
|
|
259
|
+
}
|
|
260
|
+
/* The decks for the menu (and the start page without a start deck), with which are open and working */
|
|
261
|
+
const deckList = () => decksNow().map((e) => ({ key: e.key, rel: e.rel, root: e.root, problem: e.problem,
|
|
262
|
+
open: !!decks.get(e.key)?.url }));
|
|
263
|
+
function chooserPage(res) {
|
|
264
|
+
const list = deckList(), groups = new Map(ROOTS.map((r) => [r, list.filter((d) => d.root === r)]).filter(([, l]) => l.length));
|
|
265
|
+
const body = [...groups].map(([root, list]) => `<h2>${esc(root)}</h2><ul>${list.map((d) => d.problem
|
|
266
|
+
? `<li class="off" title="${esc(d.problem)}">${esc(d.rel)}<small>${esc(d.problem)}</small></li>`
|
|
267
|
+
: `<li><a href="/d/${esc(d.key)}/">${esc(d.rel)}</a>${d.open ? " <small>open</small>" : ""}</li>`).join("")}</ul>`).join("");
|
|
268
|
+
page(res, 200, "Decks", body || "<p>No decks here.</p>");
|
|
269
|
+
}
|
|
270
|
+
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
271
|
+
svg: "image/svg+xml", png: "image/png" };
|
|
272
|
+
function serveEditorFile(res, rel) {
|
|
273
|
+
const base = path.join(EDITOR_DIR, "public");
|
|
274
|
+
const f = path.resolve(base, "." + path.posix.normalize("/" + decodeURIComponent(rel)));
|
|
275
|
+
if (!f.startsWith(base + path.sep)) return text(res, 403, "Forbidden");
|
|
276
|
+
fs.readFile(f, (err, data) => {
|
|
277
|
+
if (err) return text(res, 404, "Not found");
|
|
278
|
+
res.writeHead(200, { "content-type": MIME[path.extname(f).slice(1)] || "application/octet-stream", "cache-control": "no-store" });
|
|
279
|
+
res.end(data);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const server = http.createServer(async (req, res) => {
|
|
284
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
285
|
+
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
286
|
+
try {
|
|
287
|
+
const url = new URL(req.url, ORIGIN);
|
|
288
|
+
const p = url.pathname;
|
|
289
|
+
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
290
|
+
// the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
|
|
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
|
+
}
|
|
301
|
+
if (!authed(req)) {
|
|
302
|
+
if (req.method === "GET" && /^\/(d\/[^/]+\/(index\.html)?)?$/.test(p)) {
|
|
303
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
304
|
+
return res.end(LOGIN_PAGE);
|
|
305
|
+
}
|
|
306
|
+
return text(res, 403, "Not logged in");
|
|
307
|
+
}
|
|
308
|
+
const origin = req.headers.origin;
|
|
309
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`) return text(res, 403, "Bad origin");
|
|
310
|
+
|
|
311
|
+
if (p === "/") {
|
|
312
|
+
if (chooser) return chooserPage(res);
|
|
313
|
+
res.writeHead(302, { location: `/d/${START.key}/`, "cache-control": "no-store" });
|
|
314
|
+
return res.end();
|
|
315
|
+
}
|
|
316
|
+
if (p === "/api/ping") return json(res, 200, { ok: true, front: true });
|
|
317
|
+
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000), url.searchParams.get("next") || "/") });
|
|
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
|
+
}
|
|
333
|
+
|
|
334
|
+
const m = /^\/d\/([\w.-]+)(\/.*)?$/.exec(p);
|
|
335
|
+
if (!m) return text(res, 404, "Not found");
|
|
336
|
+
if (!m[2]) {
|
|
337
|
+
res.writeHead(301, { location: `/d/${m[1]}/${url.search}` });
|
|
338
|
+
return res.end();
|
|
339
|
+
}
|
|
340
|
+
let d = decks.get(m[1]);
|
|
341
|
+
if (!d) {
|
|
342
|
+
// only a deck from the list, and only one PinDeck can open
|
|
343
|
+
const entry = START.key === m[1] && !chooser ? { key: START.key, dir: START.dir, file: START.file }
|
|
344
|
+
: decksNow().find((e) => e.key === m[1]);
|
|
345
|
+
if (!entry) return page(res, 404, "No such deck", `<p>It is not in the deck folder or an asset root (any more).</p><p><a href="/">All decks</a></p>`);
|
|
346
|
+
if (entry.problem) return page(res, 409, "PinDeck cannot open this deck", `<p>${esc(entry.problem)}</p><p><a href="/">All decks</a></p>`);
|
|
347
|
+
d = entry;
|
|
348
|
+
}
|
|
349
|
+
try { d = await openDeck(d); }
|
|
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>`); }
|
|
351
|
+
return proxy(req, res, d, m[2] + url.search);
|
|
352
|
+
} catch (e) {
|
|
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));
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/* ---- Start ----------------------------------------------------------------------------------------
|
|
359
|
+
The port is bound first, then the start deck opens (its server checks it before writing anything). If it
|
|
360
|
+
cannot, the front says why and stops: as before the front, nothing is written. */
|
|
361
|
+
let removeServerInfo = () => {};
|
|
362
|
+
function writeServerInfo() {
|
|
363
|
+
fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
364
|
+
removeServerInfo = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, deckDir: START.dir, front: true });
|
|
365
|
+
}
|
|
366
|
+
let listening = false;
|
|
367
|
+
server.on("error", (e) => {
|
|
368
|
+
if (listening) return console.error(e);
|
|
369
|
+
console.error(e.code === "EADDRINUSE"
|
|
370
|
+
? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
|
|
371
|
+
: `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
});
|
|
374
|
+
server.listen(PORT, "127.0.0.1", async () => {
|
|
375
|
+
listening = true;
|
|
376
|
+
PORT = server.address().port;
|
|
377
|
+
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
378
|
+
if (!chooser) {
|
|
379
|
+
try { await openDeck({ key: START.key, dir: START.dir, file: START.file }, true); }
|
|
380
|
+
catch (e) {
|
|
381
|
+
console.error(e.message.replace(/\n+$/, ""));
|
|
382
|
+
process.exit(e.exitCode); // 2 for a bad argument, as the deck server says
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
writeServerInfo();
|
|
386
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
387
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
388
|
+
openOnEnter(() => loginUrl(loginCode(3600000)));
|
|
389
|
+
console.log(chooser ? `Decks: ${START.dir}` : `Deck: ${START.dir}`);
|
|
390
|
+
console.log(`Server: ${CAPS.label}`);
|
|
391
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
392
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
/* Ctrl+C or SIGTERM: every deck ends as it does on its own (its assistant gets end of input; at most 6 s),
|
|
396
|
+
then the front. A second Ctrl+C ends them at once. */
|
|
397
|
+
async function quit() {
|
|
398
|
+
if (quitting) {
|
|
399
|
+
for (const d of decks.values()) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
quitting = true;
|
|
403
|
+
server.close();
|
|
404
|
+
server.closeAllConnections?.();
|
|
405
|
+
removeServerInfo();
|
|
406
|
+
const all = [...decks.values()];
|
|
407
|
+
all.forEach(stopDeck);
|
|
408
|
+
await Promise.race([Promise.all(all.map((d) => d.exited)), new Promise((r) => setTimeout(r, 7000))]);
|
|
409
|
+
for (const d of all) if (d.proc.exitCode === null && d.proc.signalCode === null) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
|
|
410
|
+
process.exit(0);
|
|
411
|
+
}
|
|
412
|
+
process.on("SIGINT", quit);
|
|
413
|
+
process.on("SIGTERM", quit);
|
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/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 {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* The decks the front offers in the editor's deck menu: .html files in the start folder and the asset roots,
|
|
2
|
+
and in their folders one level down. Reading only: a deck gets its element ids (and PinDeck writes its
|
|
3
|
+
state) only once it is opened. A file PinDeck cannot open is listed with the reason, as at start-up. */
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { deckTarget } from "./deck-target.mjs";
|
|
8
|
+
import { unsupportedMessage } from "./frameworks.mjs";
|
|
9
|
+
|
|
10
|
+
const SKIP = new Set(["node_modules", "export"]); // dependencies, and PinDeck's own PDF exports
|
|
11
|
+
const MAX_FILES = 300;
|
|
12
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
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; } };
|
|
16
|
+
const htmlFiles = (dir) => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => /\.html$/i.test(e.name) && !e.name.startsWith(".") && kind(dir, e).isFile()).map((e) => e.name).sort();
|
|
20
|
+
} catch { return []; }
|
|
21
|
+
};
|
|
22
|
+
const subfolders = (dir) => {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
25
|
+
.filter((e) => !e.name.startsWith(".") && !SKIP.has(e.name) && kind(dir, e).isDirectory()).map((e) => e.name).sort();
|
|
26
|
+
} catch { return []; }
|
|
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();
|
|
44
|
+
|
|
45
|
+
/* [{ key, dir, file, rel, root, problem }]: rel is the path shown in the menu, relative to its root;
|
|
46
|
+
problem is null for a deck PinDeck can open. A file under several roots is listed once, under the first. */
|
|
47
|
+
export function listDecks(roots) {
|
|
48
|
+
const out = [], seen = new Set();
|
|
49
|
+
for (const root of roots) {
|
|
50
|
+
for (const dir of [root, ...subfolders(root).map((d) => path.join(root, d))]) {
|
|
51
|
+
for (const file of htmlFiles(dir)) {
|
|
52
|
+
const abs = path.join(dir, file);
|
|
53
|
+
if (seen.has(abs) || out.length >= MAX_FILES) continue;
|
|
54
|
+
seen.add(abs);
|
|
55
|
+
let problem = null;
|
|
56
|
+
try {
|
|
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;
|
|
65
|
+
} catch (e) { problem = `${file} cannot be read (${e.code || e.message}).`; }
|
|
66
|
+
const t = deckTarget(file === "index.html" ? dir : abs);
|
|
67
|
+
out.push({ key: t.key, dir, file, rel: path.relative(root, abs).split(path.sep).join("/"), root, problem });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Which deck a path names, and where PinDeck keeps that deck's state: shared by the front (pindeck), which
|
|
2
|
+
lists decks and addresses them as /d/<key>/, and the deck server, which keeps its chat and undo there. */
|
|
3
|
+
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export const homeDir = (env = process.env) => env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
10
|
+
|
|
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
|
|
30
|
+
not a folder is a file, so a missing file gets a clear message. */
|
|
31
|
+
export function deckTarget(target) {
|
|
32
|
+
const abs = path.resolve(target);
|
|
33
|
+
const isFolder = (() => { try { return fs.statSync(abs).isDirectory(); } catch { return false; } })();
|
|
34
|
+
const dir = isFolder || !/\.html$/i.test(abs) ? abs : path.dirname(abs);
|
|
35
|
+
const file = dir === abs ? "index.html" : path.basename(abs);
|
|
36
|
+
const named = file !== "index.html"; // a deck file with its own name; several can share a folder
|
|
37
|
+
const name = named ? file.replace(/\.html$/i, "") : path.basename(dir); // for exports and titles
|
|
38
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
39
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
40
|
+
const key = (named ? `${path.basename(dir)}-${name}` : path.basename(dir)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
41
|
+
crypto.createHash("sha1").update(named ? path.join(dir, file) : dir).digest("hex").slice(0, 8);
|
|
42
|
+
return { dir, file, named, name, key };
|
|
43
|
+
}
|