pindeck 3.0.0-beta.3 → 3.1.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 +38 -9
- package/front.mjs +318 -0
- package/lib/deck-list.mjs +49 -0
- package/lib/deck-target.mjs +25 -0
- package/lib/login.mjs +61 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/editor.css +24 -3
- package/public/editor.js +72 -19
- package/public/front.css +14 -0
- package/public/index.html +9 -1
- package/server.mjs +50 -89
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
-
> **macOS, and Linux as a beta · Version 3 beta preview** (`3.
|
|
3
|
+
> **macOS, and Linux as a beta · Version 3 beta preview** (`3.1.0-beta.1`). On Linux, PinDeck runs as a
|
|
4
4
|
> headless server that you use from your Mac's browser through an SSH tunnel (see
|
|
5
5
|
> [Running on a Linux server](#running-on-a-linux-server-beta)). Windows is not supported. The beta is on
|
|
6
6
|
> npm’s `beta` channel and the [`v3-beta`](https://github.com/ehabets/PinDeck/tree/v3-beta) branch; stable
|
|
@@ -42,7 +42,7 @@ Install the beta from npm:
|
|
|
42
42
|
```bash
|
|
43
43
|
npx pindeck@beta /path/to/deck
|
|
44
44
|
# Or select this exact beta:
|
|
45
|
-
npx pindeck@3.
|
|
45
|
+
npx pindeck@3.1.0-beta.1 /path/to/deck
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Or run it from the `v3-beta` branch, which includes the example deck:
|
|
@@ -50,14 +50,14 @@ Or run it from the `v3-beta` branch, which includes the example deck:
|
|
|
50
50
|
```bash
|
|
51
51
|
git clone --branch v3-beta --single-branch https://github.com/ehabets/PinDeck.git PinDeck-v3
|
|
52
52
|
cd PinDeck-v3
|
|
53
|
-
node
|
|
53
|
+
node front.mjs ocean-example
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Use `npx pindeck@latest /path/to/deck` for the stable release; the beta channel does not replace it.
|
|
57
57
|
Releases, with notes for each beta, are on [GitHub](https://github.com/ehabets/PinDeck/releases).
|
|
58
58
|
|
|
59
59
|
Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
|
|
60
|
-
once and logs this browser in; for a new one, run `pindeck login` (or `node
|
|
60
|
+
once and logs this browser in; for a new one, run `pindeck login` (or `node front.mjs login`).
|
|
61
61
|
|
|
62
62
|
The deck is a file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`; without
|
|
63
63
|
one, the current folder. Several decks can share a folder, each with its own chat and undo history;
|
|
@@ -68,6 +68,17 @@ model in `~/.pindeck-v3/config.json`), `--asset-root <folder>` (see [Files outsi
|
|
|
68
68
|
`--help`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck-v3/` (change with
|
|
69
69
|
`PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
|
|
70
70
|
|
|
71
|
+
### Several decks
|
|
72
|
+
|
|
73
|
+
Click the deck's name above the slide for the other decks: the `.html` files in the deck's folder and in
|
|
74
|
+
the asset roots, and in their folders one level down (`lecture/index.html`). A deck opens in the same
|
|
75
|
+
tab, or with ⌘-click in a new one, on the same port and login, so one SSH tunnel serves them all. Each keeps
|
|
76
|
+
its own chat and undo history while it is open, and the assistant finishes a turn in a deck you leave. A
|
|
77
|
+
file PinDeck cannot open is greyed out with the reason; listing a deck changes nothing, and only opening it
|
|
78
|
+
adds its element ids. Started with a folder that has no `index.html`, PinDeck shows the list of its decks.
|
|
79
|
+
A deck with no editor open for 30 minutes is closed (its undo history goes, as on a restart) and opens
|
|
80
|
+
again when you choose it.
|
|
81
|
+
|
|
71
82
|
## Compatibility and requirements
|
|
72
83
|
|
|
73
84
|
* **Runtime:** Node.js 20 or later, plus a modern browser. The release workflow uses Node.js 24.
|
|
@@ -242,7 +253,7 @@ The v3 preview defaults to **`~/.pindeck-v3`**, keeping v2 settings and conversa
|
|
|
242
253
|
not copy the v2 directory automatically. To run both versions at once, use another port, for example:
|
|
243
254
|
|
|
244
255
|
```bash
|
|
245
|
-
node
|
|
256
|
+
node front.mjs /path/to/a/deck-copy --port 5183 --provider codex
|
|
246
257
|
```
|
|
247
258
|
|
|
248
259
|
## Backend permissions and attachments
|
|
@@ -284,7 +295,7 @@ the `claude` or `codex` CLI installed and logged in under your account there, if
|
|
|
284
295
|
editing by hand still works. Then, in one SSH session on the server:
|
|
285
296
|
|
|
286
297
|
```bash
|
|
287
|
-
node
|
|
298
|
+
node front.mjs path/to/deck --port 5180
|
|
288
299
|
```
|
|
289
300
|
|
|
290
301
|
and on your Mac, in another terminal (`myserver` is your usual SSH host):
|
|
@@ -297,7 +308,7 @@ Open the login link PinDeck printed on the server in the Mac's browser. The loca
|
|
|
297
308
|
the server's: with `-L 127.0.0.1:8080:127.0.0.1:5180`, change 5180 in the link to 8080. PinDeck accepts any
|
|
298
309
|
port, as long as the address is `127.0.0.1` or `localhost`. If the tunnel drops, a message you have already sent keeps running on the
|
|
299
310
|
server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
|
|
300
|
-
message twice. If PinDeck restarts, the editor asks you to log in again: run `node
|
|
311
|
+
message twice. If PinDeck restarts, the editor asks you to log in again: run `node front.mjs login
|
|
301
312
|
path/to/deck` on the server for a new link.
|
|
302
313
|
|
|
303
314
|
Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
|
|
@@ -411,6 +422,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
411
422
|
|
|
412
423
|
## How it works
|
|
413
424
|
|
|
425
|
+
* **One port, a server per deck.** `pindeck` starts the front (`front.mjs`): it holds the port, the login
|
|
426
|
+
and the deck list, and serves each open deck at `/d/<deck>/`. Every deck runs its own deck server
|
|
427
|
+
(`server.mjs`) on a private port of 127.0.0.1 with a token of its own; the front passes each request on
|
|
428
|
+
with that token. A deck's server, its assistant and its undo history are those of that deck alone.
|
|
414
429
|
* **Element ids.** On start, the server gives every slide and every visible element a readable
|
|
415
430
|
`data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
|
|
416
431
|
inserted; the rest of the file stays as it was. A backup of the file before the first change is
|
|
@@ -462,7 +477,9 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
462
477
|
|
|
463
478
|
| File | Purpose |
|
|
464
479
|
|---|---|
|
|
465
|
-
| `
|
|
480
|
+
| `front.mjs` | What `pindeck` starts (127.0.0.1 only, random token per run, one-time login links): the login, the deck list and one deck server per open deck at `/d/<deck>/`; `login`. |
|
|
481
|
+
| `server.mjs` | The server for one deck: UI, deck files, events, backend session, undo, CLI endpoints. Behind the front, or on its own (`node server.mjs <deck>`). |
|
|
482
|
+
| `lib/login.mjs`, `lib/deck-target.mjs`, `lib/deck-list.mjs` | Login links and cookies, which deck a path names (and its state folder), and the decks the menu lists. |
|
|
466
483
|
| `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
|
|
467
484
|
| `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
|
|
468
485
|
| `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
|
|
@@ -491,13 +508,25 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
491
508
|
* Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
|
|
492
509
|
* No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask the assistant for those.
|
|
493
510
|
* Moves are offsets (`translate`), so the space an element came from stays reserved. Ask the assistant to turn hand edits into proper CSS when a layout should really change.
|
|
494
|
-
* The undo history lives on the server and starts fresh when PinDeck restarts
|
|
511
|
+
* The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
|
|
512
|
+
30 minutes without an open editor.
|
|
495
513
|
* One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
|
|
496
514
|
* Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
|
|
497
515
|
Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
|
|
498
516
|
|
|
499
517
|
## Versions
|
|
500
518
|
|
|
519
|
+
* **3.1.0-beta.1**: several decks on one port, and screenshots you can open from the chat.
|
|
520
|
+
- A deck menu: click the deck's name above the slide to open another deck from its folder or the
|
|
521
|
+
asset roots (and one folder down). Each open deck keeps its own chat, assistant session and Undo;
|
|
522
|
+
listing decks changes no file. `pindeck` now starts a small front that holds the port and the
|
|
523
|
+
login and runs one deck server per open deck. A deck with no editor open for 30 minutes closes
|
|
524
|
+
(its Undo history goes, as on a restart) and reopens when chosen again.
|
|
525
|
+
- A folder without `index.html` opens a page listing its decks instead of failing.
|
|
526
|
+
- `pindeck login talk.html` prints a link that opens that deck.
|
|
527
|
+
- Screenshots the assistant took show as a small picture on the chat line; click it to see it large.
|
|
528
|
+
A screenshot PinDeck no longer keeps says so.
|
|
529
|
+
|
|
501
530
|
* **3.0.0-beta.3**: everything from 2.10.0, for both assistants.
|
|
502
531
|
- Decks made without reveal.js: a deck that names its slides with `<meta name="pindeck:slides">`
|
|
503
532
|
and their size with `<meta name="pindeck:size">` is edited like a reveal.js deck (contributed by
|
package/front.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
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 os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
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";
|
|
27
|
+
import { listDecks } from "./lib/deck-list.mjs";
|
|
28
|
+
import { USAGE } from "./lib/usage.mjs";
|
|
29
|
+
|
|
30
|
+
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const SERVER = path.join(EDITOR_DIR, "server.mjs");
|
|
32
|
+
const HOME_DIR = homeDir();
|
|
33
|
+
const IDLE_MS = Number(process.env.PINDECK_IDLE_MS) || 30 * 60000;
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
37
|
+
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
38
|
+
const portArg = opt("--port", process.env.PORT || 5180);
|
|
39
|
+
const PASS = []; // what every deck server gets: the backend and the asset roots
|
|
40
|
+
for (const name of ["--provider", "--model"]) { const v = opt(name); if (v !== undefined) PASS.push(name, v); }
|
|
41
|
+
const assetArgs = [];
|
|
42
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) { assetArgs.push(a); PASS.push("--asset-root", a); }
|
|
43
|
+
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
44
|
+
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
45
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
46
|
+
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
47
|
+
await showPlatformNotice(PORT);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* pindeck login: a new one-time login link from the running front, asked with the token in its server file
|
|
52
|
+
(readable only by this user). With a deck, the link opens that deck. A deck server started on its own
|
|
53
|
+
(node server.mjs) for that deck answers instead, as before the front. */
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
/* The start deck, and the folders whose decks the menu offers. A folder without index.html opens the list
|
|
75
|
+
of its decks; if it has none, its deck server says why, as for any deck that cannot be opened. */
|
|
76
|
+
const START = deckTarget(argv[0] || process.cwd());
|
|
77
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
78
|
+
let fileConfig = {};
|
|
79
|
+
try { fileConfig = JSON.parse(fs.readFileSync(path.join(HOME_DIR, "config.json"), "utf8")); } catch {}
|
|
80
|
+
const ROOTS = [START.dir, ...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])]
|
|
81
|
+
.map((r) => path.resolve(expandHome(String(r))))
|
|
82
|
+
.filter((r, i, all) => all.indexOf(r) === i && (() => { try { return fs.statSync(r).isDirectory(); } catch { return false; } })());
|
|
83
|
+
const decksNow = () => listDecks(ROOTS);
|
|
84
|
+
const chooser = !START.named && !fs.existsSync(path.join(START.dir, START.file)) && decksNow().some((d) => !d.problem);
|
|
85
|
+
|
|
86
|
+
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
87
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
88
|
+
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
89
|
+
const CAPS = detectCapabilities();
|
|
90
|
+
|
|
91
|
+
/* ---- Deck servers -------------------------------------------------------------------------------- */
|
|
92
|
+
const decks = new Map(); // key -> { key, dir, file, label, proc, url, token, ready, exited, sse, last }
|
|
93
|
+
const labelOf = (d) => (d.file === "index.html" ? path.basename(d.dir) : d.file);
|
|
94
|
+
let quitting = false;
|
|
95
|
+
|
|
96
|
+
/* Its output goes to this terminal, each line marked with the deck. hold(): keep the lines back for now */
|
|
97
|
+
function forward(stream, label, to, keep, hold = () => false) {
|
|
98
|
+
let rest = "";
|
|
99
|
+
const put = (l) => { keep?.(l + "\n"); if (!hold()) to.write(`[${label}] ${l}\n`); };
|
|
100
|
+
stream.on("data", (chunk) => {
|
|
101
|
+
const lines = (rest + chunk).split("\n");
|
|
102
|
+
rest = lines.pop();
|
|
103
|
+
lines.forEach(put);
|
|
104
|
+
});
|
|
105
|
+
stream.on("end", () => { if (rest) put(rest); });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* The deck's server, started once: the promise resolves when it listens, or rejects with what it printed.
|
|
109
|
+
The start deck's complaints are printed by the front as they are, like those of PinDeck before the front. */
|
|
110
|
+
function openDeck(entry, start = false) {
|
|
111
|
+
const known = decks.get(entry.key);
|
|
112
|
+
if (known) return known.ready;
|
|
113
|
+
const d = { key: entry.key, dir: entry.dir, file: entry.file, label: labelOf(entry), sse: 0, last: Date.now() };
|
|
114
|
+
decks.set(d.key, d);
|
|
115
|
+
// its own process group: Ctrl+C reaches the front only, which then ends every deck once
|
|
116
|
+
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" }, stdio: ["ignore", "pipe", "pipe", "ipc"], detached: true });
|
|
118
|
+
let said = "";
|
|
119
|
+
forward(d.proc.stdout, d.label, process.stdout);
|
|
120
|
+
forward(d.proc.stderr, d.label, process.stderr, (l) => { said += l; }, () => start && !d.url);
|
|
121
|
+
d.exited = new Promise((r) => d.proc.once("exit", r));
|
|
122
|
+
d.ready = new Promise((resolve, reject) => {
|
|
123
|
+
d.proc.on("message", (m) => {
|
|
124
|
+
if (!m?.ready) return;
|
|
125
|
+
Object.assign(d, { url: m.ready.url, token: m.ready.token });
|
|
126
|
+
if (!start) console.log(`[${d.label}] opened: ${path.join(d.dir, d.file)}`);
|
|
127
|
+
resolve(d);
|
|
128
|
+
});
|
|
129
|
+
d.proc.once("exit", (code, signal) => {
|
|
130
|
+
if (decks.get(d.key) === d) decks.delete(d.key);
|
|
131
|
+
if (d.url && !quitting) console.log(`[${d.label}] closed`);
|
|
132
|
+
reject(Object.assign(new Error(said.trim() || `The deck server stopped (${code ?? signal}).`), { exitCode: code || 1 }));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
d.ready.catch(() => {});
|
|
136
|
+
return d.ready;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stopDeck(d) {
|
|
140
|
+
try { d.proc.kill("SIGTERM"); } catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* A deck with no editor open (no event stream) and nothing to do for IDLE_MS is closed */
|
|
144
|
+
setInterval(async () => {
|
|
145
|
+
for (const d of decks.values()) {
|
|
146
|
+
if (!d.url || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`${d.url}/api/status`, { headers: { "x-editor-token": d.token } });
|
|
149
|
+
const st = await res.json();
|
|
150
|
+
if (st.busy || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
|
|
151
|
+
} catch { continue; }
|
|
152
|
+
console.log(`[${d.label}] no editor open for ${Math.round(IDLE_MS / 60000)} min: closing it (it opens again when asked for)`);
|
|
153
|
+
stopDeck(d);
|
|
154
|
+
}
|
|
155
|
+
}, Math.min(60000, IDLE_MS / 2)).unref();
|
|
156
|
+
|
|
157
|
+
/* The request, without the front's cookie and with the deck's token, to the deck's server; the answer
|
|
158
|
+
streamed back (event streams, media ranges and large uploads included) */
|
|
159
|
+
function proxy(req, res, d, rest) {
|
|
160
|
+
d.last = Date.now();
|
|
161
|
+
const headers = { ...req.headers, host: new URL(d.url).host, "x-editor-token": d.token };
|
|
162
|
+
delete headers.cookie;
|
|
163
|
+
delete headers.origin; // checked here against the front's address; the deck server checks its own
|
|
164
|
+
const up = http.request(d.url + rest, { method: req.method, headers }, (r) => {
|
|
165
|
+
res.writeHead(r.statusCode, r.headers);
|
|
166
|
+
r.pipe(res);
|
|
167
|
+
});
|
|
168
|
+
up.on("error", () => { if (!res.headersSent) page(res, 502, "This deck stopped answering", "Reload the page to open it again."); else res.destroy(); });
|
|
169
|
+
if (rest.split("?")[0] === "/api/events") {
|
|
170
|
+
d.sse++;
|
|
171
|
+
res.on("close", () => { d.sse--; d.last = Date.now(); });
|
|
172
|
+
}
|
|
173
|
+
res.on("close", () => { if (!res.writableFinished) up.destroy(); });
|
|
174
|
+
req.pipe(up);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
178
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
179
|
+
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(s); };
|
|
180
|
+
const json = (res, code, o) => { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(o)); };
|
|
181
|
+
function page(res, code, title, body) {
|
|
182
|
+
res.writeHead(code, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
183
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${esc(title)} · PinDeck</title>
|
|
184
|
+
<link rel="icon" href="/editor/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/editor/front.css">
|
|
185
|
+
<main class="front"><h1>${esc(title)}</h1>${body}</main>`);
|
|
186
|
+
}
|
|
187
|
+
/* The decks for the menu (and the start page without a start deck), with which are open and working */
|
|
188
|
+
const deckList = () => decksNow().map((e) => ({ key: e.key, rel: e.rel, root: e.root, problem: e.problem,
|
|
189
|
+
open: !!decks.get(e.key)?.url }));
|
|
190
|
+
function chooserPage(res) {
|
|
191
|
+
const list = deckList(), groups = new Map(ROOTS.map((r) => [r, list.filter((d) => d.root === r)]).filter(([, l]) => l.length));
|
|
192
|
+
const body = [...groups].map(([root, list]) => `<h2>${esc(root)}</h2><ul>${list.map((d) => d.problem
|
|
193
|
+
? `<li class="off" title="${esc(d.problem)}">${esc(d.rel)}<small>${esc(d.problem)}</small></li>`
|
|
194
|
+
: `<li><a href="/d/${esc(d.key)}/">${esc(d.rel)}</a>${d.open ? " <small>open</small>" : ""}</li>`).join("")}</ul>`).join("");
|
|
195
|
+
page(res, 200, "Decks", body || "<p>No decks here.</p>");
|
|
196
|
+
}
|
|
197
|
+
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
198
|
+
svg: "image/svg+xml", png: "image/png" };
|
|
199
|
+
function serveEditorFile(res, rel) {
|
|
200
|
+
const base = path.join(EDITOR_DIR, "public");
|
|
201
|
+
const f = path.resolve(base, "." + path.posix.normalize("/" + decodeURIComponent(rel)));
|
|
202
|
+
if (!f.startsWith(base + path.sep)) return text(res, 403, "Forbidden");
|
|
203
|
+
fs.readFile(f, (err, data) => {
|
|
204
|
+
if (err) return text(res, 404, "Not found");
|
|
205
|
+
res.writeHead(200, { "content-type": MIME[path.extname(f).slice(1)] || "application/octet-stream", "cache-control": "no-store" });
|
|
206
|
+
res.end(data);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const server = http.createServer(async (req, res) => {
|
|
211
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
212
|
+
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
213
|
+
const url = new URL(req.url, ORIGIN);
|
|
214
|
+
const p = url.pathname;
|
|
215
|
+
try {
|
|
216
|
+
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
217
|
+
// the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
|
|
218
|
+
if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
|
|
219
|
+
if (!authed(req)) {
|
|
220
|
+
if (req.method === "GET" && /^\/(d\/[^/]+\/(index\.html)?)?$/.test(p)) {
|
|
221
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
222
|
+
return res.end(LOGIN_PAGE);
|
|
223
|
+
}
|
|
224
|
+
return text(res, 403, "Not logged in");
|
|
225
|
+
}
|
|
226
|
+
const origin = req.headers.origin;
|
|
227
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`) return text(res, 403, "Bad origin");
|
|
228
|
+
|
|
229
|
+
if (p === "/") {
|
|
230
|
+
if (chooser) return chooserPage(res);
|
|
231
|
+
res.writeHead(302, { location: `/d/${START.key}/`, "cache-control": "no-store" });
|
|
232
|
+
return res.end();
|
|
233
|
+
}
|
|
234
|
+
if (p === "/api/ping") return json(res, 200, { ok: true, front: true });
|
|
235
|
+
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000), url.searchParams.get("next") || "/") });
|
|
236
|
+
if (p === "/api/decks") return json(res, 200, { decks: deckList() });
|
|
237
|
+
|
|
238
|
+
const m = /^\/d\/([\w.-]+)(\/.*)?$/.exec(p);
|
|
239
|
+
if (!m) return text(res, 404, "Not found");
|
|
240
|
+
if (!m[2]) {
|
|
241
|
+
res.writeHead(301, { location: `/d/${m[1]}/${url.search}` });
|
|
242
|
+
return res.end();
|
|
243
|
+
}
|
|
244
|
+
let d = decks.get(m[1]);
|
|
245
|
+
if (!d) {
|
|
246
|
+
// only a deck from the list, and only one PinDeck can open
|
|
247
|
+
const entry = START.key === m[1] && !chooser ? { key: START.key, dir: START.dir, file: START.file }
|
|
248
|
+
: decksNow().find((e) => e.key === m[1]);
|
|
249
|
+
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>`);
|
|
250
|
+
if (entry.problem) return page(res, 409, "PinDeck cannot open this deck", `<p>${esc(entry.problem)}</p><p><a href="/">All decks</a></p>`);
|
|
251
|
+
d = entry;
|
|
252
|
+
}
|
|
253
|
+
try { d = await openDeck(d); }
|
|
254
|
+
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
|
+
return proxy(req, res, d, m[2] + url.search);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (!res.headersSent) text(res, 500, String(e.message || e));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/* ---- Start ----------------------------------------------------------------------------------------
|
|
262
|
+
The port is bound first, then the start deck opens (its server checks it before writing anything). If it
|
|
263
|
+
cannot, the front says why and stops: as before the front, nothing is written. */
|
|
264
|
+
function writeServerInfo() {
|
|
265
|
+
fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
266
|
+
fs.writeFileSync(SERVER_FILE, JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: START.dir, pid: process.pid, front: true }, null, 2), { mode: 0o600 });
|
|
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 {}
|
|
271
|
+
}
|
|
272
|
+
let listening = false;
|
|
273
|
+
server.on("error", (e) => {
|
|
274
|
+
if (listening) return console.error(e);
|
|
275
|
+
console.error(e.code === "EADDRINUSE"
|
|
276
|
+
? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
|
|
277
|
+
: `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
});
|
|
280
|
+
server.listen(PORT, "127.0.0.1", async () => {
|
|
281
|
+
listening = true;
|
|
282
|
+
PORT = server.address().port;
|
|
283
|
+
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
284
|
+
if (!chooser) {
|
|
285
|
+
try { await openDeck({ key: START.key, dir: START.dir, file: START.file }, true); }
|
|
286
|
+
catch (e) {
|
|
287
|
+
console.error(e.message.replace(/\n+$/, ""));
|
|
288
|
+
process.exit(e.exitCode); // 2 for a bad argument, as the deck server says
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
writeServerInfo();
|
|
292
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
293
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
294
|
+
console.log(chooser ? `Decks: ${START.dir}` : `Deck: ${START.dir}`);
|
|
295
|
+
console.log(`Server: ${CAPS.label}`);
|
|
296
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
297
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/* Ctrl+C or SIGTERM: every deck ends as it does on its own (its assistant gets end of input; at most 6 s),
|
|
301
|
+
then the front. A second Ctrl+C ends them at once. */
|
|
302
|
+
async function quit() {
|
|
303
|
+
if (quitting) {
|
|
304
|
+
for (const d of decks.values()) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
quitting = true;
|
|
308
|
+
server.close();
|
|
309
|
+
server.closeAllConnections?.();
|
|
310
|
+
removeServerInfo();
|
|
311
|
+
const all = [...decks.values()];
|
|
312
|
+
all.forEach(stopDeck);
|
|
313
|
+
await Promise.race([Promise.all(all.map((d) => d.exited)), new Promise((r) => setTimeout(r, 7000))]);
|
|
314
|
+
for (const d of all) if (d.proc.exitCode === null && d.proc.signalCode === null) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
|
|
315
|
+
process.exit(0);
|
|
316
|
+
}
|
|
317
|
+
process.on("SIGINT", quit);
|
|
318
|
+
process.on("SIGTERM", quit);
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
const htmlFiles = (dir) => {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
17
|
+
.filter((e) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith(".")).map((e) => e.name).sort();
|
|
18
|
+
} catch { return []; }
|
|
19
|
+
};
|
|
20
|
+
const subfolders = (dir) => {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
23
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => e.name).sort();
|
|
24
|
+
} catch { return []; }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/* [{ key, dir, file, rel, root, problem }]: rel is the path shown in the menu, relative to its root;
|
|
28
|
+
problem is null for a deck PinDeck can open. A file under several roots is listed once, under the first. */
|
|
29
|
+
export function listDecks(roots) {
|
|
30
|
+
const out = [], seen = new Set();
|
|
31
|
+
for (const root of roots) {
|
|
32
|
+
for (const dir of [root, ...subfolders(root).map((d) => path.join(root, d))]) {
|
|
33
|
+
for (const file of htmlFiles(dir)) {
|
|
34
|
+
const abs = path.join(dir, file);
|
|
35
|
+
if (seen.has(abs) || out.length >= MAX_FILES) continue;
|
|
36
|
+
seen.add(abs);
|
|
37
|
+
let problem = null;
|
|
38
|
+
try {
|
|
39
|
+
const size = fs.statSync(abs).size;
|
|
40
|
+
problem = size > MAX_BYTES ? `${file} is larger than 5 MB, too large for a deck.`
|
|
41
|
+
: unsupportedMessage(fs.readFileSync(abs, "utf8"), file)?.replace(/ Nothing was changed\.$/, "") || null;
|
|
42
|
+
} catch (e) { problem = `${file} cannot be read (${e.code || e.message}).`; }
|
|
43
|
+
const t = deckTarget(file === "index.html" ? dir : abs);
|
|
44
|
+
out.push({ key: t.key, dir, file, rel: path.relative(root, abs).split(path.sep).join("/"), root, problem });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
/* A file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm that is
|
|
12
|
+
not a folder is a file, so a missing file gets a clear message. */
|
|
13
|
+
export function deckTarget(target) {
|
|
14
|
+
const abs = path.resolve(target);
|
|
15
|
+
const isFolder = (() => { try { return fs.statSync(abs).isDirectory(); } catch { return false; } })();
|
|
16
|
+
const dir = isFolder || !/\.html?$/i.test(abs) ? abs : path.dirname(abs);
|
|
17
|
+
const file = dir === abs ? "index.html" : path.basename(abs);
|
|
18
|
+
const named = file !== "index.html"; // a deck file with its own name; several can share a folder
|
|
19
|
+
const name = named ? file.replace(/\.html?$/i, "") : path.basename(dir); // for exports and titles
|
|
20
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
21
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
22
|
+
const key = (named ? `${path.basename(dir)}-${name}` : path.basename(dir)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
23
|
+
crypto.createHash("sha1").update(named ? path.join(dir, file) : dir).digest("hex").slice(0, 8);
|
|
24
|
+
return { dir, file, named, name, key };
|
|
25
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Who may read the editor, the deck and the API: shared by the front (pindeck) and each deck server.
|
|
2
|
+
|
|
3
|
+
The token never appears in a page or on a command line, where other users of a shared server could read
|
|
4
|
+
it (ps, curl). A browser logs in once with a one-time code (printed at start, or by `pindeck login`) and
|
|
5
|
+
then holds the token in an HttpOnly cookie; the CLI, Claude and the front send it in a header. Browsers
|
|
6
|
+
share cookies across all ports of a host, so the cookie name carries the server's port. */
|
|
7
|
+
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
11
|
+
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
12
|
+
export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
|
|
13
|
+
|
|
14
|
+
export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
15
|
+
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
16
|
+
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
17
|
+
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node front.mjs login</code> in a
|
|
18
|
+
PinDeck checkout).</p>`;
|
|
19
|
+
|
|
20
|
+
export function cookie(req, name) {
|
|
21
|
+
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
22
|
+
const i = part.indexOf("=");
|
|
23
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* port() and origin() are read on use: --port 0 learns the port only once listening */
|
|
29
|
+
export function createLogin({ token, port, origin }) {
|
|
30
|
+
const loginCodes = new Map(); // code -> expiry (ms)
|
|
31
|
+
function loginCode(ttl) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
34
|
+
const code = crypto.randomBytes(16).toString("hex");
|
|
35
|
+
loginCodes.set(code, now + ttl);
|
|
36
|
+
return code;
|
|
37
|
+
}
|
|
38
|
+
const loginUrl = (code, next = "/") => `${origin()}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
39
|
+
const cookieName = () => `pindeck-${port()}`;
|
|
40
|
+
const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
|
|
41
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
|
|
42
|
+
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
43
|
+
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
44
|
+
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
45
|
+
function login(res, url) {
|
|
46
|
+
const code = url.searchParams.get("code") || "";
|
|
47
|
+
const exp = loginCodes.get(code);
|
|
48
|
+
loginCodes.delete(code);
|
|
49
|
+
if (!exp || exp < Date.now()) {
|
|
50
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
51
|
+
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
52
|
+
}
|
|
53
|
+
let next = url.searchParams.get("next") || "/";
|
|
54
|
+
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
55
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
56
|
+
"set-cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/` });
|
|
57
|
+
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
58
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
59
|
+
}
|
|
60
|
+
return { loginCode, loginUrl, cookieName, authed, login };
|
|
61
|
+
}
|
package/lib/usage.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
|
|
2
|
+
take the same arguments. */
|
|
3
|
+
export const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
|
|
4
|
+
pindeck login [deck-folder | deck-file.html]
|
|
5
|
+
|
|
6
|
+
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
7
|
+
folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
|
|
8
|
+
otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
|
|
9
|
+
<meta name="pindeck:size" content="1440x1080">.
|
|
10
|
+
In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
|
|
11
|
+
folder down), each with its own chat and undo, on the same port and login.
|
|
12
|
+
login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
|
|
13
|
+
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
14
|
+
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
15
|
+
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0-beta.1",
|
|
4
4
|
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
"bugs": "https://github.com/ehabets/PinDeck/issues",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"bin": {
|
|
26
|
-
"pindeck": "
|
|
26
|
+
"pindeck": "front.mjs"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
+
"front.mjs",
|
|
29
30
|
"server.mjs",
|
|
30
31
|
"cli.mjs",
|
|
31
32
|
"lib/",
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"LICENSE"
|
|
35
36
|
],
|
|
36
37
|
"scripts": {
|
|
37
|
-
"start": "node
|
|
38
|
+
"start": "node front.mjs",
|
|
38
39
|
"test": "node --test"
|
|
39
40
|
},
|
|
40
41
|
"os": [
|
package/public/editor.css
CHANGED
|
@@ -59,12 +59,28 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
|
|
|
59
59
|
min-height: 44px; padding: 5px 12px; background: var(--panel); border-bottom: 1px solid var(--line); flex: none; }
|
|
60
60
|
.toolbar .group { display: flex; align-items: center; gap: 6px; min-width: 0; flex: none; }
|
|
61
61
|
.toolbar .group:first-child { flex: 1 1 auto; }
|
|
62
|
-
.toolbar .group:first-child > :not(.deck-
|
|
62
|
+
.toolbar .group:first-child > :not(.deck-wrap) { flex: none; }
|
|
63
63
|
@container (max-width: 640px) {
|
|
64
|
-
.toolbar kbd, .toolbar .deck-
|
|
64
|
+
.toolbar kbd, .toolbar .deck-wrap { display: none; }
|
|
65
65
|
}
|
|
66
66
|
.pos { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 52px; text-align: center; }
|
|
67
|
-
|
|
67
|
+
/* the deck's name; behind the front (several decks on one port) it opens the deck menu */
|
|
68
|
+
.deck-wrap { min-width: 0; margin-left: 2px; }
|
|
69
|
+
.deck-btn { display: flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0; padding: 2px 6px;
|
|
70
|
+
border: 0; background: none; color: inherit; font: inherit; }
|
|
71
|
+
.deck-btn:hover { background: none; }
|
|
72
|
+
.deck-btn:disabled { opacity: 1; cursor: default; }
|
|
73
|
+
.deck-btn:not(:disabled):hover, .deck-btn[aria-expanded="true"] { background: var(--bg); }
|
|
74
|
+
.deck-btn .caret { color: var(--muted); font-size: 11px; }
|
|
75
|
+
.deck-name, #deck-name { min-width: 0; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
76
|
+
.menu.deck-menu { width: min(360px, 90vw); max-height: min(520px, 70vh); overflow-y: auto; }
|
|
77
|
+
.deck-menu a.opt { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; border-radius: 7px; color: inherit; text-decoration: none; }
|
|
78
|
+
.deck-menu a.opt:hover, .deck-menu a.opt:focus-visible { background: var(--bg); }
|
|
79
|
+
.deck-menu .opt b { font-weight: 500; overflow-wrap: anywhere; }
|
|
80
|
+
.deck-menu .opt .d { margin-left: auto; color: var(--muted); font-size: 12px; flex: none; }
|
|
81
|
+
.deck-menu button.opt { align-items: flex-start; }
|
|
82
|
+
.deck-menu button.opt span.why { display: block; font-size: 12px; }
|
|
83
|
+
.deck-menu .menu-note { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted); }
|
|
68
84
|
.server-note { flex: none; margin-left: 8px; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: #6b4a12; background: #fbf0dc; cursor: help; }
|
|
69
85
|
.toggle { display: inline-flex; align-items: center; gap: 6px; }
|
|
70
86
|
.toggle .dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff;
|
|
@@ -202,6 +218,10 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
202
218
|
border-radius: 12px; padding: 1px 4px 1px 2px; font-size: 12px; margin: 0 4px 4px 0; cursor: pointer; }
|
|
203
219
|
.chip .n { width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff; font-weight: 700;
|
|
204
220
|
font-size: 11px; line-height: 18px; text-align: center; }
|
|
221
|
+
.shot-chip { font: inherit; color: inherit; }
|
|
222
|
+
.shot-chip img { height: 18px; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: var(--line); }
|
|
223
|
+
.shot-chip.gone { color: var(--muted); cursor: default; padding: 1px 6px; }
|
|
224
|
+
.msg.tool .shot-chip { margin: 0; vertical-align: middle; }
|
|
205
225
|
.chip .x { border: 0; background: none; padding: 0 2px; color: var(--muted); font-size: 14px; line-height: 1; }
|
|
206
226
|
.chip .x:hover { color: var(--danger); }
|
|
207
227
|
.chip .lock { color: var(--muted); }
|
|
@@ -270,6 +290,7 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
270
290
|
.diff-dialog::backdrop { background: rgba(20,22,25,.35); }
|
|
271
291
|
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
|
|
272
292
|
border-bottom: 1px solid var(--line); font-weight: 600; }
|
|
293
|
+
.shot-dialog img { display: block; max-width: 100%; max-height: calc(86vh - 50px); margin: 0 auto; }
|
|
273
294
|
.diff { margin: 0; padding: 10px 0; overflow: auto; max-height: calc(86vh - 50px); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
274
295
|
.diff span { display: block; padding: 0 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
275
296
|
.diff .a { background: #e9f6ee; }
|
package/public/editor.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
|
|
2
2
|
|
|
3
3
|
const $ = (id) => document.getElementById(id);
|
|
4
|
+
/* The editor's own folder: / when it runs alone, /d/<deck>/ behind the front (pindeck), which serves several
|
|
5
|
+
decks on one port. Every URL of this deck's server starts here. */
|
|
6
|
+
const BASE = new URL(".", location.href).pathname;
|
|
7
|
+
const FRONT = BASE.startsWith("/d/");
|
|
8
|
+
const SLIDE_KEY = FRONT ? `editor.slide:${BASE}` : "editor.slide"; // decks share the origin, not their slide
|
|
4
9
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
5
10
|
/* Changes to the deck carry the revision of the files this tab shows (state.rev); the server refuses them
|
|
6
11
|
(409, code "stale") if the files have changed since. A change that went through gives the new revision. */
|
|
@@ -9,7 +14,7 @@ const CHANGES = new Set(["edit", "text", "split", "remove", "notes", "slide-hidd
|
|
|
9
14
|
const api = async (p, body) => {
|
|
10
15
|
const change = body !== undefined && CHANGES.has(p.split("?")[0]);
|
|
11
16
|
if (change && state.rev) body = { ...body, rev: state.rev };
|
|
12
|
-
const res = await fetch("
|
|
17
|
+
const res = await fetch(BASE + "api/" + p, {
|
|
13
18
|
method: body === undefined ? "GET" : "POST",
|
|
14
19
|
headers: { "content-type": "application/json" },
|
|
15
20
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -18,7 +23,7 @@ const api = async (p, body) => {
|
|
|
18
23
|
return res;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
|
-
const savedSlide = (() => { try { return +localStorage.getItem(
|
|
26
|
+
const savedSlide = (() => { try { return +localStorage.getItem(SLIDE_KEY) || 0; } catch { return 0; } })();
|
|
22
27
|
const state = {
|
|
23
28
|
status: {}, slides: [], current: savedSlide, pins: [], mode: "select", sel: null, // mode: "select" (move, resize) or "pin"
|
|
24
29
|
busy: false, status: {}, turns: new Map(), // turnId -> { undone }
|
|
@@ -31,8 +36,8 @@ let measure = null; // { iframe, bridge }
|
|
|
31
36
|
let measureReady = null, measureResolve = null;
|
|
32
37
|
|
|
33
38
|
// showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
|
|
34
|
-
//
|
|
35
|
-
function deckSrc(mode, extra = "") { return
|
|
39
|
+
// deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
|
|
40
|
+
function deckSrc(mode, extra = "") { return `${BASE}deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
|
|
36
41
|
|
|
37
42
|
function loadMain() {
|
|
38
43
|
const f = document.createElement("iframe");
|
|
@@ -83,7 +88,7 @@ window.__editorHost = {
|
|
|
83
88
|
},
|
|
84
89
|
slideChanged(index) {
|
|
85
90
|
state.current = index;
|
|
86
|
-
try { localStorage.setItem(
|
|
91
|
+
try { localStorage.setItem(SLIDE_KEY, String(index)); } catch {}
|
|
87
92
|
renderSlides();
|
|
88
93
|
renderToolbar();
|
|
89
94
|
loadNotes();
|
|
@@ -297,7 +302,7 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
|
|
|
297
302
|
if (!res.ok) return toast(d.error || "Could not move the slide.");
|
|
298
303
|
if (d.unchanged) return;
|
|
299
304
|
state.current = d.index; // the reload opens the moved slide
|
|
300
|
-
try { localStorage.setItem(
|
|
305
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
301
306
|
loadMain();
|
|
302
307
|
}
|
|
303
308
|
|
|
@@ -366,7 +371,7 @@ notesBox.addEventListener("keydown", (e) => {
|
|
|
366
371
|
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
367
372
|
addEventListener("pagehide", () => {
|
|
368
373
|
if (notesDirty() && !state.busy)
|
|
369
|
-
navigator.sendBeacon("
|
|
374
|
+
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
|
|
370
375
|
});
|
|
371
376
|
function setNotesOpen(open) {
|
|
372
377
|
$("notes-panel").classList.toggle("collapsed", !open);
|
|
@@ -465,6 +470,31 @@ function wireMenu(btnId, menuId, beforeOpen) {
|
|
|
465
470
|
const closeMenus = () => setMenu(null, null, false);
|
|
466
471
|
document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
|
|
467
472
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
|
|
473
|
+
/* The deck menu, behind the front only: the decks in the deck's folder and the asset roots (and one folder
|
|
474
|
+
down). Each is a link, so a deck opens in place, or in a new tab with ⌘-click; the deck left behind keeps
|
|
475
|
+
its chat and undo, and the assistant goes on with a turn it has begun. */
|
|
476
|
+
if (FRONT) {
|
|
477
|
+
$("deck-btn").disabled = false;
|
|
478
|
+
$("deck-btn").title = "Open another deck";
|
|
479
|
+
$("deck-btn").querySelector(".caret").hidden = false;
|
|
480
|
+
wireMenu("deck-btn", "deck-menu", async () => {
|
|
481
|
+
const menu = $("deck-menu");
|
|
482
|
+
menu.innerHTML = '<div class="menu-note">Looking for decks…</div>';
|
|
483
|
+
let decks = [];
|
|
484
|
+
try { decks = (await (await fetch("/api/decks")).json()).decks; }
|
|
485
|
+
catch { menu.innerHTML = '<div class="menu-note">The deck list is not available.</div>'; return; }
|
|
486
|
+
const roots = [...new Set(decks.map((d) => d.root))];
|
|
487
|
+
menu.innerHTML = roots.map((root) => (roots.length > 1 ? `<div class="group" title="${esc(root)}">${esc(root.split("/").pop())}</div>` : "") +
|
|
488
|
+
decks.filter((d) => d.root === root).map((d) => {
|
|
489
|
+
const here = BASE === `/d/${d.key}/`;
|
|
490
|
+
return d.problem
|
|
491
|
+
? `<button class="opt" role="menuitem" disabled title="${esc(d.problem)}"><span class="tick"></span><span><b>${esc(d.rel)}</b><span class="why">${esc(d.problem.replace(/^\S+ /, "").split(". ")[0])}.</span></span></button>`
|
|
492
|
+
: `<a class="opt" role="menuitem" href="/d/${esc(d.key)}/" ${here ? 'aria-current="page"' : ""}><span class="tick">${here ? "✓" : ""}</span>` +
|
|
493
|
+
`<b>${esc(d.rel)}</b>${here ? "" : d.open ? '<span class="d">open</span>' : ""}</a>`;
|
|
494
|
+
}).join("")).join("") +
|
|
495
|
+
'<div class="menu-note">A deck opens here (⌘-click: in a new tab), with its own chat and undo. The assistant finishes its turn in the deck you leave.</div>';
|
|
496
|
+
});
|
|
497
|
+
}
|
|
468
498
|
const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
|
|
469
499
|
wireMenu("export-btn", "export-menu");
|
|
470
500
|
$("export-menu").addEventListener("click", async (e) => {
|
|
@@ -493,7 +523,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
493
523
|
});
|
|
494
524
|
|
|
495
525
|
$("present").onclick = () => {
|
|
496
|
-
window.open(
|
|
526
|
+
window.open(`${BASE}deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
|
|
497
527
|
};
|
|
498
528
|
|
|
499
529
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -571,17 +601,21 @@ function md(s) {
|
|
|
571
601
|
}).join("");
|
|
572
602
|
return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
|
|
573
603
|
}
|
|
604
|
+
/* A screenshot in the chat: a small picture that opens large. Only the newest ones are kept on disk;
|
|
605
|
+
an older one says so (see the error listener below). */
|
|
606
|
+
const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
|
|
607
|
+
`<img src="${BASE}shots/${encodeURIComponent(file)}" alt="" loading="lazy">${label ? ` ${esc(label)}` : ""}</button>`;
|
|
574
608
|
function entryHtml(e) {
|
|
575
609
|
switch (e.kind) {
|
|
576
610
|
case "user":
|
|
577
611
|
return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
|
|
578
612
|
`<span class="chip sel" title="${esc(e.selection.eid)}"><span class="n">▢</span> S${e.selection.slide} · ${esc(e.selection.label)}</span>` : ""}${(e.pins || []).map((p) =>
|
|
579
613
|
`<span class="chip" title="${esc(p.eid)}"><span class="n">${p.n}</span> S${p.slide} · ${esc(p.label || p.eid)}</span>`).join("")}${
|
|
580
|
-
e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
|
|
581
|
-
`<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="
|
|
614
|
+
e.screenshot?.file ? shotChip(e.screenshot.file, `screenshot S${e.screenshot.slide}`) : e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
|
|
615
|
+
`<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="${BASE}deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
|
|
582
616
|
case "assistant": return md(e.text);
|
|
583
617
|
case "tool":
|
|
584
|
-
return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
|
|
618
|
+
return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.shot ? " " + shotChip(e.shot) : ""}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
|
|
585
619
|
case "edit": {
|
|
586
620
|
const t = state.turns.get(e.turnId);
|
|
587
621
|
return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
|
|
@@ -629,7 +663,23 @@ function clearChat() {
|
|
|
629
663
|
$("chat-empty").hidden = false;
|
|
630
664
|
}
|
|
631
665
|
|
|
666
|
+
// a picture that is no longer kept: the chip says so and opens nothing (errors do not bubble: capture)
|
|
667
|
+
chatEl.addEventListener("error", (e) => {
|
|
668
|
+
const chip = e.target.closest?.("[data-shot]");
|
|
669
|
+
if (!chip || e.target.tagName !== "IMG") return;
|
|
670
|
+
chip.removeAttribute("data-shot");
|
|
671
|
+
chip.classList.add("gone");
|
|
672
|
+
chip.title = "Only the newest screenshots are kept";
|
|
673
|
+
chip.textContent = "📷 no longer kept";
|
|
674
|
+
}, true);
|
|
632
675
|
chatEl.addEventListener("click", async (e) => {
|
|
676
|
+
const s = e.target.closest("[data-shot]");
|
|
677
|
+
if (s) {
|
|
678
|
+
$("shot-img").src = `${BASE}shots/${encodeURIComponent(s.dataset.shot)}`;
|
|
679
|
+
$("shot-title").textContent = s.dataset.shot;
|
|
680
|
+
$("shot-dialog").showModal();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
633
683
|
const d = e.target.closest("[data-diff]");
|
|
634
684
|
if (d) {
|
|
635
685
|
const res = await api(`turn/${d.dataset.diff}/diff`);
|
|
@@ -651,6 +701,9 @@ chatEl.addEventListener("click", async (e) => {
|
|
|
651
701
|
}
|
|
652
702
|
});
|
|
653
703
|
$("diff-close").onclick = () => $("diff-dialog").close();
|
|
704
|
+
$("shot-close").onclick = () => $("shot-dialog").close();
|
|
705
|
+
// Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
|
|
706
|
+
$("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
|
|
654
707
|
|
|
655
708
|
function setBusy(busy) {
|
|
656
709
|
state.busy = busy;
|
|
@@ -747,7 +800,7 @@ function applyStatus(s) {
|
|
|
747
800
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
748
801
|
const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
|
|
749
802
|
$("deck-name").textContent = name;
|
|
750
|
-
$("deck-
|
|
803
|
+
$("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
|
|
751
804
|
if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
|
|
752
805
|
document.title = `${name} · PinDeck`;
|
|
753
806
|
}
|
|
@@ -792,7 +845,7 @@ function applyStatus(s) {
|
|
|
792
845
|
renderToolbar();
|
|
793
846
|
}
|
|
794
847
|
function connect() {
|
|
795
|
-
const es = new EventSource("
|
|
848
|
+
const es = new EventSource(BASE + "api/events");
|
|
796
849
|
es.addEventListener("hello", (ev) => {
|
|
797
850
|
const d = JSON.parse(ev.data);
|
|
798
851
|
// reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
|
|
@@ -868,7 +921,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
|
|
|
868
921
|
if (!res.ok) return toast(d.error || "Could not add the slide.");
|
|
869
922
|
state.current = d.index; // the reload opens the new slide ...
|
|
870
923
|
state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
|
|
871
|
-
try { localStorage.setItem(
|
|
924
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
872
925
|
loadMain();
|
|
873
926
|
});
|
|
874
927
|
|
|
@@ -881,7 +934,7 @@ $("import-file").addEventListener("change", async () => {
|
|
|
881
934
|
$("import-file").value = "";
|
|
882
935
|
if (!file) return;
|
|
883
936
|
toast(`Reading ${file.name}…`);
|
|
884
|
-
const res = await fetch("
|
|
937
|
+
const res = await fetch(BASE + "api/import", { method: "POST", body: file,
|
|
885
938
|
headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
|
|
886
939
|
const d = await res.json().catch(() => ({}));
|
|
887
940
|
if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
|
|
@@ -925,7 +978,7 @@ function showImportThumbs(ev) {
|
|
|
925
978
|
for (const c of importChecks()) {
|
|
926
979
|
const img = new Image();
|
|
927
980
|
img.alt = "";
|
|
928
|
-
img.src =
|
|
981
|
+
img.src = `${BASE}api/import-thumb?id=${importing.id}&n=${c.value}`;
|
|
929
982
|
c.closest(".thumb").append(img);
|
|
930
983
|
}
|
|
931
984
|
}
|
|
@@ -943,7 +996,7 @@ $("import-dialog").addEventListener("close", async () => {
|
|
|
943
996
|
toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
|
|
944
997
|
(d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
|
|
945
998
|
state.current = d.index;
|
|
946
|
-
try { localStorage.setItem(
|
|
999
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
947
1000
|
loadMain();
|
|
948
1001
|
});
|
|
949
1002
|
|
|
@@ -1090,7 +1143,7 @@ const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "
|
|
|
1090
1143
|
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
|
|
1091
1144
|
|
|
1092
1145
|
async function uploadBlob(blob, name) {
|
|
1093
|
-
const res = await fetch("
|
|
1146
|
+
const res = await fetch(BASE + "api/upload", { method: "POST", body: blob,
|
|
1094
1147
|
headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
|
|
1095
1148
|
const d = await res.json().catch(() => ({}));
|
|
1096
1149
|
if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
|
|
@@ -1178,7 +1231,7 @@ function renderAttachments() {
|
|
|
1178
1231
|
a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
|
|
1179
1232
|
fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
|
|
1180
1233
|
return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
|
|
1181
|
-
${a.kind === "image" ? `<img class="thumb" src="${a.preview || "
|
|
1234
|
+
${a.kind === "image" ? `<img class="thumb" src="${a.preview || BASE + "deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
|
|
1182
1235
|
<span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
|
|
1183
1236
|
${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
|
|
1184
1237
|
<button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;
|
package/public/front.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* The front's own pages: the list of decks (a folder without index.html) and why a deck cannot be opened */
|
|
2
|
+
body { margin: 0; background: #f6f6f4; color: #1d1f22; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
3
|
+
.front { max-width: 44em; margin: 3em auto; padding: 0 16px; }
|
|
4
|
+
.front h1 { font-size: 22px; margin: 0 0 .8em; }
|
|
5
|
+
.front h2 { font-size: 12px; font-weight: 600; color: #6b6f76; margin: 1.6em 0 .4em; overflow-wrap: anywhere; }
|
|
6
|
+
.front ul { list-style: none; margin: 0; padding: 0; border: 1px solid #e3e3df; border-radius: 10px; background: #fff; }
|
|
7
|
+
.front li { padding: 9px 14px; border-top: 1px solid #ececea; }
|
|
8
|
+
.front li:first-child { border-top: 0; }
|
|
9
|
+
.front li a { color: #1d1f22; font-weight: 500; text-decoration: none; }
|
|
10
|
+
.front li a:hover { color: #e8710a; }
|
|
11
|
+
.front li small { color: #6b6f76; margin-left: 6px; }
|
|
12
|
+
.front li.off { color: #8a8e94; }
|
|
13
|
+
.front li.off small { display: block; margin: 2px 0 0; font-size: 12px; }
|
|
14
|
+
.front pre { white-space: pre-wrap; background: #fff; border: 1px solid #e3e3df; border-radius: 8px; padding: 10px 12px; font-size: 13px; }
|
package/public/index.html
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
<button id="prev" class="icon" title="Previous slide (←)" aria-label="Previous slide">‹</button>
|
|
36
36
|
<span id="slide-pos" class="pos">– / –</span>
|
|
37
37
|
<button id="next" class="icon" title="Next slide (→)" aria-label="Next slide">›</button>
|
|
38
|
-
<span
|
|
38
|
+
<span class="menu-wrap deck-wrap">
|
|
39
|
+
<button id="deck-btn" class="deck-btn" aria-haspopup="menu" aria-expanded="false" disabled><span id="deck-name"></span><span class="caret" hidden>▾</span></button>
|
|
40
|
+
<div id="deck-menu" class="menu left deck-menu" role="menu" hidden></div>
|
|
41
|
+
</span>
|
|
39
42
|
<span id="server-note" class="server-note" hidden></span>
|
|
40
43
|
</div>
|
|
41
44
|
<div class="group">
|
|
@@ -172,6 +175,11 @@
|
|
|
172
175
|
</form>
|
|
173
176
|
</dialog>
|
|
174
177
|
|
|
178
|
+
<dialog id="shot-dialog" class="diff-dialog shot-dialog">
|
|
179
|
+
<div class="dialog-head"><span id="shot-title">Screenshot</span><button class="small" id="shot-close">Close</button></div>
|
|
180
|
+
<img id="shot-img" alt="Screenshot">
|
|
181
|
+
</dialog>
|
|
182
|
+
|
|
175
183
|
<dialog id="diff-dialog" class="diff-dialog">
|
|
176
184
|
<div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
|
|
177
185
|
<pre id="diff-body" class="diff"></pre>
|
package/server.mjs
CHANGED
|
@@ -39,19 +39,14 @@ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capability
|
|
|
39
39
|
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
40
40
|
import { readInstructions } from "./lib/instructions.mjs";
|
|
41
41
|
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
42
|
+
import { deckTarget, homeDir } from "./lib/deck-target.mjs";
|
|
43
|
+
import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
|
|
44
|
+
import { USAGE } from "./lib/usage.mjs";
|
|
42
45
|
|
|
43
46
|
/* ---- Config ------------------------------------------------------------------ */
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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. A deck made otherwise names its slides in its <head> with
|
|
49
|
-
<meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
|
|
50
|
-
login prints a new one-time login link for the PinDeck running for that deck
|
|
51
|
-
(without a deck: for the PinDeck started last).
|
|
52
|
-
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
53
|
-
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
54
|
-
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
|
47
|
+
/* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in,
|
|
48
|
+
prints the links and owns ~/.pindeck-v3/server.json; this server tells it its address over IPC. */
|
|
49
|
+
const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
|
|
55
50
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
56
51
|
const argv = process.argv.slice(2);
|
|
57
52
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
@@ -70,23 +65,14 @@ const LOGIN = argv[0] === "login";
|
|
|
70
65
|
if (LOGIN) argv.shift();
|
|
71
66
|
const PROVIDER = opt("--provider", "");
|
|
72
67
|
const MODEL = opt("--model", "");
|
|
73
|
-
/* The deck: a file (talk.html), or a folder, which means its index.html
|
|
74
|
-
|
|
75
|
-
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
76
|
-
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
77
|
-
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
78
|
-
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
79
|
-
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
80
|
-
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
68
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
|
|
69
|
+
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
|
|
81
70
|
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
82
71
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
83
|
-
const HOME_DIR =
|
|
84
|
-
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
85
|
-
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
86
|
-
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
87
|
-
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
72
|
+
const HOME_DIR = homeDir();
|
|
88
73
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
89
74
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
75
|
+
const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
|
|
90
76
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
91
77
|
/* pindeck login: a new one-time login link from the running server, asked with the token in its server
|
|
92
78
|
file (readable only by this user) */
|
|
@@ -136,7 +122,9 @@ if (frameworkProblem) {
|
|
|
136
122
|
}
|
|
137
123
|
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
138
124
|
port is bound (see Start), so a failed start leaves no state behind. */
|
|
139
|
-
|
|
125
|
+
// the second: the CLI's fallback. Behind the front none: its own server.json leads pindeck login to the front
|
|
126
|
+
// (a deck server's port is no use to a browser behind a tunnel), and the assistant's CLI gets this server's address.
|
|
127
|
+
const SERVER_FILES = FRONT ? [] : [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")];
|
|
140
128
|
function writeServerInfo() {
|
|
141
129
|
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
142
130
|
const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
|
|
@@ -400,7 +388,7 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
400
388
|
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
401
389
|
finally { snapshots.delete(id); }
|
|
402
390
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
403
|
-
url:
|
|
391
|
+
url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
404
392
|
};
|
|
405
393
|
const job = exportQueue.then(run, run);
|
|
406
394
|
exportQueue = job.catch(() => {});
|
|
@@ -666,11 +654,13 @@ function toolSummary(name, input = {}) {
|
|
|
666
654
|
const r = path.relative(DECK_DIR, p);
|
|
667
655
|
return !r || r.startsWith("..") ? path.basename(p) : r;
|
|
668
656
|
};
|
|
657
|
+
// a picture in the screenshots folder: the chat shows it
|
|
658
|
+
const shotOf = (p) => (p && realOr(path.dirname(path.resolve(p))) === realOr(SHOT_DIR) && SHOT_NAME.test(path.basename(p)) ? { shot: path.basename(p) } : {});
|
|
669
659
|
switch (name) {
|
|
670
|
-
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
660
|
+
case "pindeck_read": return { name: "Read", detail: base(input.path), ...shotOf(input.path) };
|
|
671
661
|
case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
|
|
672
662
|
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
673
|
-
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
663
|
+
case "Read": return { name: "Read", detail: base(input.file_path), ...shotOf(input.file_path) };
|
|
674
664
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
675
665
|
case "Bash": {
|
|
676
666
|
// a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
|
|
@@ -1161,57 +1151,9 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
1161
1151
|
|
|
1162
1152
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
1163
1153
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
1164
|
-
/*
|
|
1165
|
-
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
/* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
|
|
1169
|
-
where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
|
|
1170
|
-
code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
|
|
1171
|
-
and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
|
|
1172
|
-
carries this server's port. */
|
|
1173
|
-
const loginCodes = new Map(); // code -> expiry (ms)
|
|
1174
|
-
function loginCode(ttl) {
|
|
1175
|
-
const now = Date.now();
|
|
1176
|
-
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
1177
|
-
const code = crypto.randomBytes(16).toString("hex");
|
|
1178
|
-
loginCodes.set(code, now + ttl);
|
|
1179
|
-
return code;
|
|
1180
|
-
}
|
|
1181
|
-
const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
1182
|
-
const cookieName = () => `pindeck-${PORT}`;
|
|
1183
|
-
const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
|
|
1184
|
-
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
|
|
1185
|
-
function cookie(req, name) {
|
|
1186
|
-
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
1187
|
-
const i = part.indexOf("=");
|
|
1188
|
-
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
1189
|
-
}
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
1193
|
-
const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
1194
|
-
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
1195
|
-
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
1196
|
-
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
|
|
1197
|
-
PinDeck checkout).</p>`;
|
|
1198
|
-
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
1199
|
-
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
1200
|
-
function login(res, url) {
|
|
1201
|
-
const code = url.searchParams.get("code") || "";
|
|
1202
|
-
const exp = loginCodes.get(code);
|
|
1203
|
-
loginCodes.delete(code);
|
|
1204
|
-
if (!exp || exp < Date.now()) {
|
|
1205
|
-
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1206
|
-
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
1207
|
-
}
|
|
1208
|
-
let next = url.searchParams.get("next") || "/";
|
|
1209
|
-
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
1210
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1211
|
-
"set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
|
|
1212
|
-
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
1213
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
1214
|
-
}
|
|
1154
|
+
/* Who may read the editor, the deck and the API: see lib/login.mjs. Behind the front, browsers log in
|
|
1155
|
+
there, and the front sends this server's token in a header. */
|
|
1156
|
+
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
1215
1157
|
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1216
1158
|
its command line: the page sends it to a one-time login code, which leads on to the target. */
|
|
1217
1159
|
function chromeStart(profile, target) {
|
|
@@ -1285,9 +1227,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1285
1227
|
return res.end(html);
|
|
1286
1228
|
}
|
|
1287
1229
|
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1288
|
-
// /files/, so that ../ paths reach the root: /deck/ alone redirects there
|
|
1230
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there (a relative redirect, which
|
|
1231
|
+
// stays under the front's /d/<deck>/).
|
|
1289
1232
|
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1290
|
-
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1233
|
+
res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1291
1234
|
return res.end();
|
|
1292
1235
|
}
|
|
1293
1236
|
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
@@ -1309,6 +1252,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
1309
1252
|
});
|
|
1310
1253
|
}
|
|
1311
1254
|
|
|
1255
|
+
// /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
|
|
1256
|
+
if (req.method === "GET" && p.startsWith("/shots/")) {
|
|
1257
|
+
if (!ok) return text(res, 403, "Not logged in");
|
|
1258
|
+
const name = decodeURIComponent(p.slice(7));
|
|
1259
|
+
if (!SHOT_NAME.test(name)) return text(res, 403, "Forbidden");
|
|
1260
|
+
const f = path.join(SHOT_DIR, name);
|
|
1261
|
+
let real;
|
|
1262
|
+
try { real = fs.realpathSync(f); } catch { return text(res, 404, "Not found"); }
|
|
1263
|
+
return path.dirname(real) === realOr(SHOT_DIR) ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1312
1266
|
// API: token required (header or login cookie); POSTs must come from the editor page itself
|
|
1313
1267
|
if (!p.startsWith("/api/")) return text(res, 404, "Not found");
|
|
1314
1268
|
if (!ok) return text(res, 403, "Bad token");
|
|
@@ -1365,7 +1319,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1365
1319
|
// only files PinDeck itself stored can be attached
|
|
1366
1320
|
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
1367
1321
|
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
1368
|
-
const entry = addEntry({ kind: "user", text: b.text,
|
|
1322
|
+
const entry = addEntry({ kind: "user", text: b.text,
|
|
1323
|
+
screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
|
|
1369
1324
|
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1370
1325
|
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1371
1326
|
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
@@ -1398,7 +1353,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1398
1353
|
const rel = attachPath(original);
|
|
1399
1354
|
writeDeckFile(rel, buf);
|
|
1400
1355
|
state.uploads.add(rel);
|
|
1401
|
-
return json(res, 200, { path: rel, kind, bytes: buf.length, url:
|
|
1356
|
+
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
|
|
1402
1357
|
}
|
|
1403
1358
|
if (p === "/api/upload-remove" && req.method === "POST") {
|
|
1404
1359
|
const b = await body(req);
|
|
@@ -1727,12 +1682,18 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1727
1682
|
process.exit(1);
|
|
1728
1683
|
}
|
|
1729
1684
|
ready = true;
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1685
|
+
if (FRONT) {
|
|
1686
|
+
// the front prints the login link and the capabilities; it ends this server when it goes, even killed
|
|
1687
|
+
process.send({ ready: { url: ORIGIN, token: TOKEN } });
|
|
1688
|
+
process.on("disconnect", () => { if (!quitting) quit(); }); // a second quit() would exit at once
|
|
1689
|
+
} else {
|
|
1690
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1691
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1692
|
+
console.log(`Deck: ${DECK_DIR}`);
|
|
1693
|
+
console.log(`Server: ${CAPS.label}`);
|
|
1694
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1695
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1696
|
+
}
|
|
1736
1697
|
discoverModels().then(() => {
|
|
1737
1698
|
if (!settings.model) {
|
|
1738
1699
|
const models = catalog[settings.provider].models;
|