visualproof 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +52 -11
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/mcp/visualproof-mcp.mjs +3 -1
  20. package/dist/server/agent.js +10 -5
  21. package/dist/server/children.js +85 -0
  22. package/dist/server/doctor.js +105 -42
  23. package/dist/server/events.js +10 -0
  24. package/dist/server/export.js +11 -4
  25. package/dist/server/extract.js +3 -2
  26. package/dist/server/figures.js +3 -2
  27. package/dist/server/film.js +380 -0
  28. package/dist/server/history.js +11 -1
  29. package/dist/server/hover.js +8 -5
  30. package/dist/server/live.js +66 -9
  31. package/dist/server/main.js +217 -16
  32. package/dist/server/media.js +114 -0
  33. package/dist/server/pipeline.js +6 -1
  34. package/dist/server/poppler.js +3 -2
  35. package/dist/server/readonlygate.js +4 -4
  36. package/dist/server/sourcestore.js +3 -2
  37. package/dist/server/status.js +3 -2
  38. package/dist/server/supervise.js +219 -0
  39. package/dist/server/updates.js +951 -0
  40. package/dist/server/usage.js +3 -1
  41. package/dist/server/version.js +183 -0
  42. package/dist/shared/timeline.js +5 -0
  43. package/dist/web/bundle/app.js +2298 -254
  44. package/dist/web/index.html +13 -1
  45. package/dist/web/src/canvas.js +22 -0
  46. package/dist/web/style.css +189 -1
  47. package/docs/agent-api/export.md +2 -2
  48. package/docs/agent-api/history.md +3 -2
  49. package/package.json +3 -1
@@ -0,0 +1,951 @@
1
+ // VisualProof's own releases, as a running server follows them (UpdateSpec.md): which versions npm
2
+ // has, which of them this server may take by itself (a patch of the minor it runs, UpdateSpec
3
+ // decision 3) and which it only tells of (a newer minor, decision 4), the patches downloaded beside
4
+ // the installed package (`versions/` in the caches' root, decision 5), and the quiet moment a server restarts
5
+ // into one (decision 6).
6
+ //
7
+ // The registry is asked through npm itself, the npm beside the Node that runs the server, so that
8
+ // the user's registry, proxy and certificates hold as for any `npm install` (decision 1). What npm
9
+ // is asked: the versions of `visualproof` and the engines of the newest ones; nothing of the project.
10
+ //
11
+ // In the root of the caches (`--cache`, else the cache home), which every server of the user may
12
+ // share — so that each folder below is one server's to make and every server's to read:
13
+ //
14
+ // updates.json the last check: when, the versions found, the engines asked
15
+ // versions/ this user's alone (0700): it holds code the servers run
16
+ // versions/<v>/ a patch downloaded: an npm prefix holding visualproof@<v>
17
+ // versions/<v>/ready written once it was checked to run (`--version` says <v>) on a
18
+ // Node its engines allow
19
+ // versions/<v>/running-<pid> a server running it: never removed while that process lives
20
+ // versions/.<v>.tmp-<pid> a download under way (that process's; removed once it is gone)
21
+ // versions/bad.json versions set aside (decision 7): each with when and why
22
+ import { execFile } from "node:child_process";
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { basename, dirname, join } from "node:path";
25
+ import { childEnv, tracked } from "./children.js";
26
+ import { packageVersion, satisfies } from "./version.js";
27
+ export const UPDATE_MODES = ["auto", "notify", "off"];
28
+ /** The package's own folder, holding package.json: the one this code was loaded from. */
29
+ export const PACKAGE_ROOT = join(import.meta.dirname, "..", "..");
30
+ /** Whether this code is an installed package's (`dist/server/`), not a checkout's (`src/server/`):
31
+ * only a package is updated (UpdateSpec decision 13). */
32
+ export const FROM_PACKAGE = basename(dirname(import.meta.dirname)) === "dist";
33
+ /** A release's version, `x.y.z`; null for anything else (a release candidate, `0.0.0-stage`). */
34
+ export function stable(v) {
35
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v);
36
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
37
+ }
38
+ function order(a, b) {
39
+ for (let i = 0; i < 3; i++)
40
+ if (a[i] !== b[i])
41
+ return a[i] - b[i];
42
+ return 0;
43
+ }
44
+ /** Whether release `a` is newer than `b`. */
45
+ export function newerThan(a, b) {
46
+ const x = stable(a), y = stable(b);
47
+ return !!x && !!y && order(x, y) > 0;
48
+ }
49
+ /** Whether two releases are of one minor (`0.4.1` and `0.4.3`): what a server takes by itself. */
50
+ export function sameMinor(a, b) {
51
+ const x = stable(a), y = stable(b);
52
+ return !!x && !!y && x[0] === y[0] && x[1] === y[1];
53
+ }
54
+ /** The newest of `vs` (releases only), or null. */
55
+ export function newest(vs) {
56
+ let best = null;
57
+ for (const v of vs)
58
+ if (stable(v) && (best === null || newerThan(v, best)))
59
+ best = v;
60
+ return best;
61
+ }
62
+ /** What a server running `running` may do with the versions npm has: `patch`, the newest release
63
+ * of its minor beyond it that is not set aside and runs on this Node, which it takes by itself
64
+ * under auto (UpdateSpec decision 3); `release`, the newest release beyond it that runs here,
65
+ * which it tells of (decision 4). `engines` says each version's Node range, where known (unknown:
66
+ * taken as running here). */
67
+ export function choose(running, versions, o = {}) {
68
+ const node = o.node ?? process.versions.node;
69
+ const runs = (v) => {
70
+ const e = o.engines?.(v);
71
+ return e === undefined || satisfies(node, e);
72
+ };
73
+ // a release its publisher deprecated (`npm deprecate`) is neither taken nor told of
74
+ const beyond = versions.filter((v) => newerThan(v, running) && runs(v) && !o.deprecated?.has(v));
75
+ return {
76
+ patch: newest(beyond.filter((v) => sameMinor(v, running) && !o.bad?.has(v))),
77
+ release: newest(beyond),
78
+ };
79
+ }
80
+ /** npm as the Node that runs this server runs it: npm's own script under this Node, not the `npm`
81
+ * command, which runs under whatever Node is first on the PATH; else that command. The script is
82
+ * where the `npm` beside this Node leads (a link to it, as every layout below makes one), else
83
+ * where Node's own layouts keep it, beside this Node's binary or the one a link of it leads to:
84
+ * `<prefix>/lib/node_modules/npm` (Node's installers, nvm, a Homebrew prefix), or the keg's
85
+ * `<Cellar>/node/<version>/libexec/lib/node_modules/npm` (Homebrew's node formula, whose binary
86
+ * `process.execPath` names in the Cellar). */
87
+ export function npmCommand(execPath = process.execPath) {
88
+ const real = (p) => {
89
+ try {
90
+ return realpathSync(p);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ };
96
+ const bins = [...new Set([dirname(execPath), dirname(real(execPath) ?? execPath)])];
97
+ const tail = ["node_modules", "npm", "bin", "npm-cli.js"];
98
+ const candidates = [
99
+ ...bins.map((b) => real(join(b, "npm"))).filter((p) => !!p && p.endsWith("npm-cli.js")),
100
+ ...bins.flatMap((b) => [join(b, "..", "lib", ...tail), join(b, "..", "libexec", "lib", ...tail)]),
101
+ ];
102
+ const cli = candidates.find((c) => existsSync(c));
103
+ return cli ? { cmd: execPath, args: [cli] } : { cmd: "npm", args: [] };
104
+ }
105
+ /** npm as a child of this server (server/children.ts), run in `cwd` (the versions' folder, so that
106
+ * no `.npmrc` of the project's folder, nor of wherever the server was started, applies), without
107
+ * its own nags and without the server's own variables. */
108
+ export function npmIn(cwd) {
109
+ return (args, timeoutMs = 120_000) => new Promise((resolve) => {
110
+ const { cmd, args: pre } = npmCommand();
111
+ const env = childEnv({ npm_config_update_notifier: "false", npm_config_fund: "false", npm_config_audit: "false" });
112
+ tracked(execFile(cmd, [...pre, ...args], { cwd, timeout: timeoutMs, maxBuffer: 16 << 20, env, encoding: "utf8" }, (err, stdout, stderr) => {
113
+ if (!err)
114
+ return resolve({ ok: true, out: stdout });
115
+ const said = `${stderr}`.split("\n").find((l) => /npm (error|ERR!)/.test(l) && !/A complete log/.test(l));
116
+ resolve({ ok: false, why: said?.replace(/^npm (error|ERR!)\s*/, "").trim() || (err.killed ? "npm took too long" : err.message.split("\n")[0]) });
117
+ }));
118
+ });
119
+ }
120
+ /** The versions of visualproof npm has: every release, or why npm could not say. */
121
+ export async function registryVersions(ask) {
122
+ const r = await ask(["view", "visualproof", "versions", "--json"], 60_000);
123
+ if (!r.ok)
124
+ return { why: r.why };
125
+ try {
126
+ const v = JSON.parse(r.out);
127
+ const list = Array.isArray(v) ? v : [v];
128
+ return { versions: list.filter((x) => typeof x === "string") };
129
+ }
130
+ catch {
131
+ return { why: "npm's answer could not be read" };
132
+ }
133
+ }
134
+ /** The Node range a version of visualproof runs on (its `engines.node`), or null when npm cannot say. */
135
+ export async function registryEngines(v, ask) {
136
+ return (await registryInfo(v, ask))?.engines ?? null;
137
+ }
138
+ /** What npm says of a version's `engines` and `deprecated` (`npm view visualproof@<v> engines
139
+ * deprecated --json`), as it says it: the engines object alone where the version is not deprecated,
140
+ * the message alone where it has no engines, both by name where it has both. The Node range ("":
141
+ * anywhere), and the deprecation's message or null. */
142
+ export function readInfo(out) {
143
+ let v;
144
+ try {
145
+ v = out.trim() ? JSON.parse(out) : {};
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ const node = (e) => (e && typeof e === "object" && typeof e.node === "string" ? e.node : "");
151
+ if (typeof v === "string")
152
+ return { engines: "", deprecated: v };
153
+ if (!v || typeof v !== "object")
154
+ return null;
155
+ if ("engines" in v || "deprecated" in v) {
156
+ const d = v.deprecated;
157
+ return { engines: node(v.engines), deprecated: typeof d === "string" && d ? d : null };
158
+ }
159
+ return { engines: node(v), deprecated: null };
160
+ }
161
+ /** A version's Node range and deprecation, or null when npm cannot say. */
162
+ export async function registryInfo(v, ask) {
163
+ const r = await ask(["view", `visualproof@${v}`, "engines", "deprecated", "--json"], 60_000);
164
+ return r.ok ? readInfo(r.out) : null;
165
+ }
166
+ /** The command that installs version `v` where the package at `root` is installed: npm's global
167
+ * folder (`<prefix>/lib/node_modules/visualproof`), or a prefix of its own
168
+ * (`<prefix>/node_modules/visualproof`). */
169
+ export function installCommand(root, v, execPath = process.execPath) {
170
+ const above = dirname(dirname(root));
171
+ if (basename(above) === "lib") {
172
+ const prefix = dirname(above);
173
+ return prefix === dirname(dirname(execPath)) ? `npm install --global visualproof@${v}` : `npm install --global --prefix ${prefix} visualproof@${v}`;
174
+ }
175
+ return `npm install --prefix ${above} visualproof@${v}`;
176
+ }
177
+ /** Where a release's notes are: the GitHub release made from the changelog's section. */
178
+ export function notesOf(v, root = PACKAGE_ROOT) {
179
+ let repo = "https://github.com/LucianoXu/VisualProof";
180
+ try {
181
+ const u = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).repository?.url;
182
+ if (u)
183
+ repo = u.replace(/^git\+/, "").replace(/\.git$/, "");
184
+ }
185
+ catch {
186
+ // the project's own address, as package.json has it today
187
+ }
188
+ return `${repo}/releases/tag/v${v}`;
189
+ }
190
+ /** The version of the package at `root`, or null where none is there. */
191
+ export function versionAt(root) {
192
+ try {
193
+ const v = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
194
+ return typeof v === "string" ? v : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /** A JSON object kept in a file, as a record of the versions' folder: anything else (no file, a
201
+ * file cut short, `null`, a list) reads as none. */
202
+ function readObject(file) {
203
+ try {
204
+ const v = JSON.parse(readFileSync(file, "utf8"));
205
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
206
+ }
207
+ catch {
208
+ return {};
209
+ }
210
+ }
211
+ /** Whether process `pid` lives (EPERM: it does, another user's). */
212
+ export function alive(pid) {
213
+ try {
214
+ process.kill(pid, 0);
215
+ return true;
216
+ }
217
+ catch (e) {
218
+ return e.code === "EPERM";
219
+ }
220
+ }
221
+ /** Whether a folder is this user's alone to write: owned by them, neither group nor others may write
222
+ * it (UpdateSpec decision 12). Where there are no users (Windows), any. */
223
+ export function private_(dir) {
224
+ try {
225
+ const st = statSync(dir);
226
+ if (typeof process.getuid !== "function")
227
+ return st.isDirectory();
228
+ return st.isDirectory() && st.uid === process.getuid() && (st.mode & 0o022) === 0;
229
+ }
230
+ catch {
231
+ return false;
232
+ }
233
+ }
234
+ /** Run the server's code at `main` with `--version`, as a child the server stops with itself: what
235
+ * it says, or why it said nothing. */
236
+ function versionOf(node, main) {
237
+ return new Promise((resolve) => {
238
+ tracked(execFile(node, [main, "--version"], { timeout: 30_000, encoding: "utf8", env: childEnv() }, (err, stdout, stderr) => {
239
+ if (!err)
240
+ return resolve({ out: stdout.trim() });
241
+ resolve({ why: (stderr || stdout || err.message).trim().split("\n")[0] || (err.killed ? "it took too long" : "it failed") });
242
+ }));
243
+ });
244
+ }
245
+ /** The versions downloaded beside the installed package (UpdateSpec decision 5), in a folder every
246
+ * server of the user may share: each one downloads into a folder of its own and moves it into
247
+ * place only where none is there yet, marks the version it runs, and removes only what no living
248
+ * server runs, of its own minor, older than the one it would step back to. */
249
+ export class Versions {
250
+ root;
251
+ constructor(cacheRoot) {
252
+ this.root = join(cacheRoot, "versions");
253
+ }
254
+ /** The folder made where it is not, this user's alone (0700); why it cannot be used, else null. */
255
+ ensure() {
256
+ try {
257
+ if (!existsSync(this.root)) {
258
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
259
+ chmodSync(this.root, 0o700);
260
+ }
261
+ }
262
+ catch (e) {
263
+ return `${this.root} cannot be made: ${e.message}`;
264
+ }
265
+ return private_(this.root) ? null : `${this.root} is not this user's alone (another owns it, or others may write it): no patch is taken from it`;
266
+ }
267
+ /** the package of version `v`, as npm installed it into its prefix */
268
+ packageOf(v) {
269
+ return join(this.root, v, "node_modules", "visualproof");
270
+ }
271
+ mainOf(v) {
272
+ return join(this.packageOf(v), "dist", "server", "main.js");
273
+ }
274
+ /** The versions downloaded and checked to run, in folders this user alone may write. */
275
+ ready() {
276
+ if (!private_(this.root))
277
+ return [];
278
+ let names = [];
279
+ try {
280
+ names = readdirSync(this.root);
281
+ }
282
+ catch {
283
+ return [];
284
+ }
285
+ return names.filter((v) => {
286
+ if (!stable(v) || !private_(join(this.root, v)))
287
+ return false;
288
+ try {
289
+ return readFileSync(join(this.root, v, "ready"), "utf8").trim() === v && existsSync(this.mainOf(v));
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ });
295
+ }
296
+ /** What the servers sharing this folder recorded of each version's failures (UpdateSpec decision
297
+ * 7): before it served (`early`: about the version, whichever site saw it), soon after it served
298
+ * (`sites`: by the site that saw it, which may be about that project alone), and when it was taken
299
+ * back (the fault was found elsewhere). What was recorded is kept, taken back or not. */
300
+ records() {
301
+ const raw = readObject(join(this.root, "bad.json"));
302
+ const strikes = (e) => {
303
+ const x = e;
304
+ if (!x || typeof x !== "object" || typeof x.why !== "string")
305
+ return undefined;
306
+ return { at: String(x.at ?? ""), why: x.why, times: Number.isInteger(x.times) && x.times > 0 ? x.times : 1 };
307
+ };
308
+ const out = new Map();
309
+ for (const [v, e] of Object.entries(raw)) {
310
+ if (!stable(v) || !e || typeof e !== "object")
311
+ continue;
312
+ const r = e;
313
+ // a record of before (one mark per version) reads as a failure before it served
314
+ const early = strikes(r.early) ?? (typeof r.why === "string" ? strikes(r) : undefined);
315
+ const sites = {};
316
+ if (r.sites && typeof r.sites === "object")
317
+ for (const [k, x] of Object.entries(r.sites))
318
+ if (strikes(x))
319
+ sites[k] = strikes(x);
320
+ out.set(v, { ...(early ? { early } : {}), ...(Object.keys(sites).length ? { sites } : {}), ...(typeof r.takenBack === "string" ? { takenBack: r.takenBack } : {}) });
321
+ }
322
+ return out;
323
+ }
324
+ /** Whether `v` is set aside now for a server of `site` (null: whichever site; only what is
325
+ * shared): a failure before it served, or soon after on this site, or soon after on two sites,
326
+ * each for a day after it, and for good once recorded SET_ASIDE_TIMES times; nothing recorded
327
+ * before it was taken back. Why, where it is; else null. */
328
+ heldWhy(v, site, now = Date.now(), r = this.records().get(v)) {
329
+ if (!r)
330
+ return null;
331
+ const back = r.takenBack ? Date.parse(r.takenBack) : -Infinity;
332
+ const holds = (x) => {
333
+ if (!x)
334
+ return false;
335
+ const at = Date.parse(x.at);
336
+ if (Number.isFinite(at) && at <= back)
337
+ return false;
338
+ return x.times >= SET_ASIDE_TIMES || !Number.isFinite(at) || now - at < SET_ASIDE_FOR;
339
+ };
340
+ if (holds(r.early))
341
+ return r.early.why;
342
+ const sites = Object.entries(r.sites ?? {}).filter(([, x]) => holds(x));
343
+ const mine = site === null ? undefined : sites.find(([k]) => k === site);
344
+ if (mine)
345
+ return mine[1].why;
346
+ if (sites.length >= 2)
347
+ return `${sites[0][1].why}, on ${sites.length} sites`;
348
+ return null;
349
+ }
350
+ /** The versions set aside now for a server of `site` (heldWhy). */
351
+ setAside(now = Date.now(), site = null) {
352
+ const out = new Set();
353
+ for (const [v, r] of this.records())
354
+ if (this.heldWhy(v, site, now, r) !== null)
355
+ out.add(v);
356
+ return out;
357
+ }
358
+ /** What is set aside now for `site`, with when and why: what servers and the doctor say. */
359
+ reasons(now = Date.now(), site = null) {
360
+ const out = [];
361
+ for (const [v, r] of this.records()) {
362
+ const why = this.heldWhy(v, site, now, r);
363
+ if (why === null)
364
+ continue;
365
+ const ats = [r.early?.at, ...Object.values(r.sites ?? {}).map((x) => x.at)].filter((x) => !!x).sort();
366
+ out.push({ version: v, at: ats.at(-1) ?? "", why });
367
+ }
368
+ return out;
369
+ }
370
+ /** Write the records, as one file moved into place; false where it cannot be written (a full
371
+ * disk): the caller goes on, without the record. */
372
+ writeRecords(all) {
373
+ if (this.ensure())
374
+ return false;
375
+ const f = join(this.root, "bad.json");
376
+ try {
377
+ writeFileSync(`${f}.tmp-${process.pid}`, JSON.stringify(Object.fromEntries(all), null, 1));
378
+ renameSync(`${f}.tmp-${process.pid}`, f);
379
+ return true;
380
+ }
381
+ catch {
382
+ try {
383
+ unlinkSync(`${f}.tmp-${process.pid}`);
384
+ }
385
+ catch {
386
+ // not there
387
+ }
388
+ return false;
389
+ }
390
+ }
391
+ /** Record a failure of `v`: before it served (`site` null, shared by every site), or soon after
392
+ * it served on `site`. False where the record could not be written. */
393
+ markBad(v, why, site = null, now = Date.now()) {
394
+ const all = this.records();
395
+ const r = { ...(all.get(v) ?? {}) };
396
+ const add = (x) => ({ at: new Date(now).toISOString(), why, times: (x?.times ?? 0) + 1 });
397
+ if (site === null)
398
+ r.early = add(r.early);
399
+ else
400
+ r.sites = { ...(r.sites ?? {}), [site]: add(r.sites?.[site]) };
401
+ all.set(v, r);
402
+ return this.writeRecords(all);
403
+ }
404
+ /** Take `v` back from those set aside: the fault was found elsewhere. What was recorded stays,
405
+ * counted toward its setting aside for good. False where it could not be written. */
406
+ unmarkBad(v, now = Date.now()) {
407
+ const all = this.records();
408
+ const r = all.get(v);
409
+ if (!r)
410
+ return true;
411
+ all.set(v, { ...r, takenBack: new Date(now).toISOString() });
412
+ return this.writeRecords(all);
413
+ }
414
+ /** Where a site's server stopped taking patches (UpdateSpec decision 7): its versions up to
415
+ * `upTo` are not run for it, so that it runs the installed package until a newer patch is out. */
416
+ paused(site, now = Date.now()) {
417
+ if (site === null)
418
+ return null;
419
+ const all = readObject(join(this.root, "paused.json"));
420
+ const p = (Object.hasOwn(all, site) ? all[site] : null);
421
+ if (!p || typeof p !== "object" || typeof p.upTo !== "string" || !stable(p.upTo))
422
+ return null;
423
+ // a pause lasts a day: its patches are tried again then (a newer patch is taken at once)
424
+ const at = Date.parse(String(p.at ?? ""));
425
+ if (Number.isFinite(at) && now - at >= SET_ASIDE_FOR)
426
+ return null;
427
+ return { upTo: p.upTo, at: String(p.at ?? ""), why: String(p.why ?? "") };
428
+ }
429
+ /** Stop taking `site`'s patches up to `upTo` (they failed, and so did the version before them):
430
+ * it runs the installed package for a day, or until a newer patch is out. False where it cannot
431
+ * be written. */
432
+ pause(site, upTo, why, now = Date.now()) {
433
+ if (this.ensure())
434
+ return false;
435
+ const f = join(this.root, "paused.json");
436
+ const all = readObject(f);
437
+ all[site] = { upTo, at: new Date(now).toISOString(), why };
438
+ try {
439
+ writeFileSync(`${f}.tmp-${process.pid}`, JSON.stringify(all, null, 1));
440
+ renameSync(`${f}.tmp-${process.pid}`, f);
441
+ return true;
442
+ }
443
+ catch {
444
+ return false;
445
+ }
446
+ }
447
+ /** The versions the last check found deprecated by their publisher (updates.json, in the caches'
448
+ * root): never run. */
449
+ deprecated() {
450
+ const d = readObject(join(dirname(this.root), "updates.json")).deprecated;
451
+ return new Set(d && typeof d === "object" ? Object.keys(d) : []);
452
+ }
453
+ /** Mark version `v` as run by process `pid` (the supervisor, as it starts it); false where it
454
+ * cannot be marked. */
455
+ markRunning(v, pid) {
456
+ try {
457
+ writeFileSync(join(this.root, v, `running-${pid}`), `${pid}\n`);
458
+ return true;
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }
464
+ /** The mark of process `pid` on `v` taken off: it ended. */
465
+ unmarkRunning(v, pid) {
466
+ try {
467
+ unlinkSync(join(this.root, v, `running-${pid}`));
468
+ }
469
+ catch {
470
+ // gone already
471
+ }
472
+ }
473
+ /** Whether a living server runs version `v`, or downloads it. */
474
+ inUse(v) {
475
+ let names = [];
476
+ try {
477
+ names = readdirSync(join(this.root, v));
478
+ }
479
+ catch {
480
+ return false;
481
+ }
482
+ return names.some((n) => {
483
+ const m = /^running-(\d+)$/.exec(n);
484
+ return !!m && alive(Number(m[1]));
485
+ });
486
+ }
487
+ /** Download version `v` (npm checks what it downloads against the registry's integrity), check
488
+ * that it runs on this Node (its engines) and says it is `v`, and mark it ready: into a folder of
489
+ * its own first, moved into place only where no other server put one meanwhile (whose finished
490
+ * copy is as good). Null when it is ready, else why not. */
491
+ async fetch(v, ask, node = process.execPath, nodeVersion = process.versions.node) {
492
+ if (!stable(v))
493
+ return `${v} is not a release`;
494
+ const bad = this.ensure();
495
+ if (bad)
496
+ return bad;
497
+ if (this.ready().includes(v))
498
+ return null;
499
+ this.sweep();
500
+ const work = join(this.root, `.${v}.tmp-${process.pid}`);
501
+ rmSync(work, { recursive: true, force: true });
502
+ mkdirSync(work, { mode: 0o700 });
503
+ try {
504
+ const r = await ask(["install", `--prefix=${work}`, "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund", "--no-package-lock", `visualproof@${v}`], 600_000);
505
+ if (!r.ok)
506
+ return `npm could not install it: ${r.why}`;
507
+ const pkg = join(work, "node_modules", "visualproof");
508
+ const main = join(pkg, "dist", "server", "main.js");
509
+ if (!existsSync(main))
510
+ return "npm installed it without its server";
511
+ let engines;
512
+ try {
513
+ engines = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")).engines?.node;
514
+ }
515
+ catch {
516
+ return "its package.json cannot be read";
517
+ }
518
+ if (!satisfies(nodeVersion, engines))
519
+ return `it needs Node ${engines}; this is ${nodeVersion}`;
520
+ const says = await versionOf(node, main);
521
+ if ("why" in says || says.out !== v)
522
+ return `it does not run here (${"why" in says ? says.why : `it says it is ${says.out || "nothing"}`})`;
523
+ writeFileSync(join(work, "ready"), `${v}\n`);
524
+ try {
525
+ renameSync(work, join(this.root, v));
526
+ }
527
+ catch {
528
+ // another server's copy is there: as good, once it is whole
529
+ if (!this.ready().includes(v))
530
+ return `${join(this.root, v)} is there, and not ready`;
531
+ }
532
+ return null;
533
+ }
534
+ finally {
535
+ rmSync(work, { recursive: true, force: true });
536
+ }
537
+ }
538
+ /** Remove the downloads left by servers that died midway. */
539
+ sweep() {
540
+ let names = [];
541
+ try {
542
+ names = readdirSync(this.root);
543
+ }
544
+ catch {
545
+ return;
546
+ }
547
+ for (const n of names) {
548
+ const m = /^\.(.+)\.tmp-(\d+)$/.exec(n);
549
+ if (m && !alive(Number(m[2])))
550
+ rmSync(join(this.root, n), { recursive: true, force: true });
551
+ }
552
+ }
553
+ /** Remove the versions of `running`'s minor older than the one it would step back to (the newest
554
+ * ready one before it), that no living server runs; other minors are other servers' to keep. */
555
+ prune(running) {
556
+ this.sweep();
557
+ const mine = this.ready().filter((v) => sameMinor(v, running));
558
+ const before = newest(mine.filter((v) => newerThan(running, v)));
559
+ for (const v of mine) {
560
+ if (!newerThan(running, v) || v === before || this.inUse(v))
561
+ continue;
562
+ rmSync(join(this.root, v), { recursive: true, force: true });
563
+ }
564
+ }
565
+ }
566
+ /** The code a supervisor runs (UpdateSpec decision 5): the installed package's, or — under auto —
567
+ * the newest version downloaded of its minor that is newer, ready, runs on this Node, is neither
568
+ * set aside for this site nor deprecated, and is past where this site's patches were paused. */
569
+ export function codeToRun(installedRoot, versions, o = {}) {
570
+ const installed = versionAt(installedRoot) ?? "0.0.0";
571
+ const own = { main: join(installedRoot, "dist", "server", "main.js"), version: installed, cached: false };
572
+ if (o.mode !== undefined && o.mode !== "auto")
573
+ return own;
574
+ const site = o.site ?? null;
575
+ const bad = versions.setAside(o.now ?? Date.now(), site);
576
+ const deprecated = versions.deprecated();
577
+ const paused = versions.paused(site);
578
+ let best = null;
579
+ for (const v of versions.ready()) {
580
+ if (!sameMinor(v, installed) || !newerThan(v, installed) || bad.has(v) || o.also?.has(v) || deprecated.has(v))
581
+ continue;
582
+ if (paused && !newerThan(v, paused.upTo))
583
+ continue;
584
+ const engines = (() => {
585
+ try {
586
+ return JSON.parse(readFileSync(join(versions.packageOf(v), "package.json"), "utf8")).engines?.node;
587
+ }
588
+ catch {
589
+ return undefined;
590
+ }
591
+ })();
592
+ if (!satisfies(o.node ?? process.versions.node, engines))
593
+ continue;
594
+ if (best === null || newerThan(v, best))
595
+ best = v;
596
+ }
597
+ return best ? { main: versions.mainOf(best), version: best, cached: true } : own;
598
+ }
599
+ // ---------------------------------------------------------------- a server following its releases
600
+ /** VISUALPROOF_UPDATE_TEST_MS, for the tests of the update path alone (scripts/test-updates.mjs):
601
+ * every wait below that many milliseconds, the longest five times it. Honoured only where npm's
602
+ * registry is one on this machine (npm_config_registry a loopback address), as the test's is and a
603
+ * server's never is. */
604
+ const TEST_MS = Number(process.env.VISUALPROOF_UPDATE_TEST_MS) > 0 && /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(process.env.npm_config_registry ?? "") ? Number(process.env.VISUALPROOF_UPDATE_TEST_MS) : 0;
605
+ /** a day between checks, and up to two hours more, so that servers started together do not ask together */
606
+ export const CHECK_EVERY = TEST_MS || 24 * 3600_000;
607
+ const JITTER = TEST_MS ? 0 : 2 * 3600_000;
608
+ /** the first check, once the server listens */
609
+ const FIRST_CHECK = TEST_MS || 30_000;
610
+ /** how often the quiet moment is looked for */
611
+ const LOOK_EVERY = TEST_MS || 60_000;
612
+ /** the longest a patch downloaded waits for a quiet moment (UpdateSpec decision 6) */
613
+ export const PATCH_WAIT = TEST_MS * 5 || 24 * 3600_000;
614
+ /** the longest a package changed under the running server waits: its files are not the ones it
615
+ * started with */
616
+ export const INSTALLED_WAIT = TEST_MS * 5 || 10 * 60_000;
617
+ /** a version that failed is set aside for a day, then tried again; for good after three times */
618
+ export const SET_ASIDE_FOR = TEST_MS * 600 || 24 * 3600_000;
619
+ export const SET_ASIDE_TIMES = 3;
620
+ /** What a server knows and does about its own releases (UpdateSpec decisions 1–7). */
621
+ export class Updates {
622
+ mode;
623
+ running;
624
+ installedRoot;
625
+ versions;
626
+ o;
627
+ file;
628
+ saved = null;
629
+ now;
630
+ /** a patch downloaded and ready, and when it was */
631
+ pending = null;
632
+ /** the installed package as it was when this server started, and since when it is another */
633
+ installedAtStart;
634
+ installedChanged = null;
635
+ downloading = false;
636
+ /** npm, run in the versions' folder (else the caches' root, where that folder cannot be used) */
637
+ npm;
638
+ timers = [];
639
+ restarting = false;
640
+ /** said once: the Node this server runs on is gone */
641
+ nodeGone = false;
642
+ constructor(o) {
643
+ this.o = o;
644
+ this.mode = o.mode;
645
+ this.running = o.running ?? packageVersion();
646
+ this.installedRoot = o.installedRoot ?? PACKAGE_ROOT;
647
+ this.versions = new Versions(o.cacheRoot);
648
+ this.file = join(o.cacheRoot, "updates.json");
649
+ this.now = o.now ?? Date.now;
650
+ this.installedAtStart = versionAt(this.installedRoot);
651
+ this.npm = o.npm ?? npmIn(this.versions.ensure() === null ? this.versions.root : o.cacheRoot);
652
+ try {
653
+ const s = JSON.parse(readFileSync(this.file, "utf8"));
654
+ if (typeof s.checked === "string" && Array.isArray(s.versions))
655
+ this.saved = { checked: s.checked, versions: s.versions.filter((v) => typeof v === "string"), engines: s.engines && typeof s.engines === "object" ? s.engines : {}, ...(s.deprecated && typeof s.deprecated === "object" ? { deprecated: s.deprecated } : {}) };
656
+ }
657
+ catch {
658
+ // never checked here, or the file is unreadable: asked again
659
+ }
660
+ }
661
+ /** The versions newer than this one that it may take, and tell of, as last found. */
662
+ /** The patch this server may take and the release it may tell of, as the last check found them:
663
+ * only versions whose engines and deprecation npm said (one it could not say of waits for the
664
+ * next check), neither set aside for this site nor at or below where its patches are paused. */
665
+ found() {
666
+ if (!this.saved)
667
+ return { patch: null, release: null };
668
+ const e = this.saved.engines;
669
+ const paused = this.versions.paused(this.o.site ?? null, this.now());
670
+ const known = this.saved.versions.filter((v) => Object.hasOwn(e, v));
671
+ const bad = new Set([...this.versions.setAside(this.now(), this.o.site ?? null), ...(this.o.also ?? [])]);
672
+ const r = choose(this.running, known, { bad, engines: (v) => e[v], deprecated: new Set(Object.keys(this.saved.deprecated ?? {})), node: this.o.node });
673
+ return { ...r, patch: r.patch && paused && !newerThan(r.patch, paused.upTo) ? null : r.patch };
674
+ }
675
+ /** What a page is told (UpdateSpec decision 11): the version alone where `full` is not. */
676
+ status(full) {
677
+ if (!full)
678
+ return { running: this.running };
679
+ const film = this.o.film ? { film: this.o.film() } : {};
680
+ if (this.o.checkout)
681
+ return { running: this.running, checkout: true, ...film };
682
+ const installed = versionAt(this.installedRoot);
683
+ const { patch, release } = this.found();
684
+ const s = { running: this.running, mode: this.mode, ...(installed && installed !== this.running ? { installed } : {}), ...(this.saved ? { checked: this.saved.checked } : {}) };
685
+ // a newer minor, which needs someone, is told before a patch this server takes by itself
686
+ if (release && !sameMinor(release, this.running))
687
+ s.newer = { version: release, taken: false, command: installCommand(this.installedRoot, release), notes: notesOf(release, this.installedRoot) };
688
+ else if (patch)
689
+ s.newer = this.mode === "auto" && this.o.canRestart ? { version: patch, taken: true, notes: notesOf(patch, this.installedRoot) } : { version: patch, taken: false, command: installCommand(this.installedRoot, patch), notes: notesOf(patch, this.installedRoot) };
690
+ const bad = this.versions.reasons(this.now(), this.o.site ?? null);
691
+ if (bad.length)
692
+ s.setAside = bad;
693
+ const paused = this.versions.paused(this.o.site ?? null, this.now());
694
+ if (paused)
695
+ s.paused = paused;
696
+ return { ...s, ...film };
697
+ }
698
+ /** Begin: the first check soon after the server listens (unless one was made in the last hour),
699
+ * then one a day; the quiet moment looked for every minute. */
700
+ start() {
701
+ if (this.mode === "off")
702
+ return;
703
+ if (this.o.check) {
704
+ const last = this.saved ? Date.parse(this.saved.checked) : NaN;
705
+ const soon = !TEST_MS && Number.isFinite(last) && this.now() - last < 3600_000 ? last + CHECK_EVERY - this.now() : FIRST_CHECK;
706
+ this.later(() => void this.checkLoop(), Math.max(FIRST_CHECK, soon));
707
+ }
708
+ const look = setInterval(() => void this.look(), LOOK_EVERY);
709
+ look.unref();
710
+ this.timers.push(look);
711
+ // a patch downloaded by this server before it restarted for another reason is taken as well
712
+ const kept = this.found().patch;
713
+ if (kept && this.mode === "auto" && this.versions.ready().includes(kept))
714
+ this.pending = { version: kept, since: this.now() };
715
+ // and a patch it runs that was deprecated meanwhile is stepped back from
716
+ else
717
+ this.stepBack();
718
+ }
719
+ stop() {
720
+ for (const t of this.timers)
721
+ clearTimeout(t);
722
+ this.timers = [];
723
+ }
724
+ later(f, ms) {
725
+ const t = setTimeout(f, ms);
726
+ t.unref();
727
+ this.timers.push(t);
728
+ }
729
+ /** A check, then the next a day on, whatever this one met. */
730
+ async checkLoop() {
731
+ try {
732
+ await this.check();
733
+ }
734
+ catch (e) {
735
+ this.o.log(`updates: the check failed: ${e instanceof Error ? e.message : String(e)}; asked again in a day`);
736
+ }
737
+ finally {
738
+ this.later(() => void this.checkLoop(), CHECK_EVERY + Math.random() * JITTER);
739
+ }
740
+ }
741
+ /** Ask the registry (UpdateSpec decision 1): the versions, and the engines of the newest beyond
742
+ * this one; then, under auto, download the patch this server may take. */
743
+ async check() {
744
+ const ask = this.npm;
745
+ const r = await registryVersions(ask);
746
+ if ("why" in r) {
747
+ this.o.log(`updates: the registry could not be asked (${r.why}); asked again in a day`);
748
+ return;
749
+ }
750
+ const engines = {};
751
+ const deprecated = {};
752
+ /** versions npm could not say of: not taken now, asked again at the next check */
753
+ const unknown = new Set();
754
+ const bad = this.versions.setAside(this.now(), this.o.site ?? null);
755
+ // the newest beyond this one, in its minor and beyond it: what their Node needs, and whether
756
+ // their publisher deprecated them — asked afresh, newest first, until one is fit (a few at most)
757
+ const beyond = r.versions.filter((v) => newerThan(v, this.running));
758
+ const descending = (vs) => [...vs].sort((a, b) => (newerThan(a, b) ? -1 : newerThan(b, a) ? 1 : 0));
759
+ for (const list of [descending(beyond.filter((x) => sameMinor(x, this.running) && !bad.has(x))), descending(beyond)]) {
760
+ for (const v of list.slice(0, 5)) {
761
+ if (unknown.has(v))
762
+ break;
763
+ if (!Object.hasOwn(engines, v)) {
764
+ const info = await registryInfo(v, ask);
765
+ if (info === null) {
766
+ // nothing older is taken in its place: it may be the newest, which a later check finds
767
+ unknown.add(v);
768
+ break;
769
+ }
770
+ engines[v] = info.engines;
771
+ if (info.deprecated)
772
+ deprecated[v] = info.deprecated;
773
+ }
774
+ if (!Object.hasOwn(deprecated, v) && satisfies(this.o.node ?? process.versions.node, engines[v]))
775
+ break;
776
+ }
777
+ }
778
+ // what this server runs, and the patches of its minor the cache holds: a deprecation of a
779
+ // version already taken withdraws it (a server running it steps back)
780
+ for (const v of [this.running, ...this.versions.ready().filter((x) => sameMinor(x, this.running))]) {
781
+ if (Object.hasOwn(engines, v) || unknown.has(v) || !stable(v))
782
+ continue;
783
+ const info = await registryInfo(v, ask);
784
+ if (info === null) {
785
+ unknown.add(v);
786
+ continue;
787
+ }
788
+ engines[v] = info.engines;
789
+ if (info.deprecated)
790
+ deprecated[v] = info.deprecated;
791
+ }
792
+ // merged with what the cache's updates.json says, as another server may have written it: a
793
+ // deprecation stays until an answer says otherwise, never dropped for a version not asked or
794
+ // not answered
795
+ const was = readObject(this.file);
796
+ const kept = {};
797
+ for (const [v, m] of Object.entries(was.deprecated && typeof was.deprecated === "object" ? was.deprecated : {})) {
798
+ if (typeof m === "string" && stable(v) && !(Object.hasOwn(engines, v) && !Object.hasOwn(deprecated, v)))
799
+ kept[v] = m;
800
+ }
801
+ for (const [v, m] of Object.entries(this.saved?.deprecated ?? {}))
802
+ if (!Object.hasOwn(kept, v) && !(Object.hasOwn(engines, v) && !Object.hasOwn(deprecated, v)))
803
+ kept[v] = m;
804
+ Object.assign(kept, deprecated);
805
+ const wasEngines = was.engines && typeof was.engines === "object" ? was.engines : {};
806
+ const allEngines = {};
807
+ for (const [v, x] of Object.entries(wasEngines))
808
+ if (typeof x === "string" && stable(v))
809
+ allEngines[v] = x;
810
+ Object.assign(allEngines, engines);
811
+ this.saved = { checked: new Date(this.now()).toISOString(), versions: r.versions, engines: allEngines, ...(Object.keys(kept).length ? { deprecated: kept } : {}) };
812
+ try {
813
+ mkdirSync(dirname(this.file), { recursive: true });
814
+ writeFileSync(`${this.file}.tmp-${process.pid}`, JSON.stringify(this.saved));
815
+ renameSync(`${this.file}.tmp-${process.pid}`, this.file);
816
+ }
817
+ catch {
818
+ // kept in memory: asked again at the next start
819
+ }
820
+ const { patch, release } = this.found();
821
+ if (release && !sameMinor(release, this.running))
822
+ this.o.log(`updates: VisualProof ${release} is out (this is ${this.running}; ${notesOf(release, this.installedRoot)}): it is installed by ${installCommand(this.installedRoot, release)}, which this server then restarts into by itself`);
823
+ else if (!patch) {
824
+ const out = newest(beyond);
825
+ const paused = this.versions.paused(this.o.site ?? null, this.now());
826
+ const why = !out
827
+ ? ""
828
+ : paused && sameMinor(out, this.running) && !newerThan(out, paused.upTo)
829
+ ? `this site's patches up to ${paused.upTo} are paused (${paused.why}), for a day or until a newer one`
830
+ : bad.has(out)
831
+ ? `set aside here: ${this.versions.heldWhy(out, this.o.site ?? null, this.now()) ?? "it failed"}`
832
+ : unknown.has(out) || !Object.hasOwn(engines, out)
833
+ ? "npm could not say what it needs; asked again at the next check"
834
+ : Object.hasOwn(deprecated, out)
835
+ ? `deprecated: ${deprecated[out]}`
836
+ : `it needs Node ${engines[out]}; this is ${this.o.node ?? process.versions.node}`;
837
+ this.o.log(out ? `updates: VisualProof ${out} is out, not taken: ${why}` : `updates: VisualProof ${this.running} is the latest`);
838
+ }
839
+ if (patch) {
840
+ if (this.mode === "auto" && this.o.canRestart)
841
+ await this.download(patch);
842
+ else
843
+ this.o.log(`updates: VisualProof ${patch} is out (this is ${this.running}): ${installCommand(this.installedRoot, patch)}`);
844
+ }
845
+ else
846
+ this.stepBack();
847
+ this.o.changed?.();
848
+ }
849
+ /** A patch this server runs from the cache, deprecated by its publisher since: it steps back at a
850
+ * quiet moment — to the newest ready version of its minor that is not, else the installed
851
+ * package (UpdateSpec decision 1). */
852
+ stepBack() {
853
+ const why = this.saved?.deprecated?.[this.running];
854
+ if (why === undefined || !this.o.canRestart || versionAt(this.installedRoot) === this.running || this.pending)
855
+ return;
856
+ const to = codeToRun(this.installedRoot, this.versions, { node: this.o.node, mode: this.mode, site: this.o.site ?? null, also: this.o.also }).version;
857
+ if (to === this.running)
858
+ return;
859
+ this.o.log(`updates: VisualProof ${this.running}, which this server runs, is deprecated by its publisher (${why}): it steps back to ${to} at a quiet moment`);
860
+ this.pending = { version: to, since: this.now(), back: true };
861
+ }
862
+ /** Download patch `v` into the cache, unless it is there; once ready, it waits for a quiet moment. */
863
+ async download(v) {
864
+ if (this.downloading || this.pending?.version === v)
865
+ return;
866
+ this.downloading = true;
867
+ try {
868
+ if (!this.versions.ready().includes(v)) {
869
+ this.o.log(`updates: VisualProof ${v} is out (this is ${this.running}): downloading it into ${this.versions.root}`);
870
+ const why = await this.versions.fetch(v, this.npm);
871
+ if (why) {
872
+ this.o.log(`updates: VisualProof ${v} could not be downloaded: ${why}; tried again at the next check`);
873
+ return;
874
+ }
875
+ }
876
+ this.pending = { version: v, since: this.now() };
877
+ this.o.log(`updates: VisualProof ${v} is ready; this server restarts into it when nobody is on a page and nothing runs, within a day`);
878
+ // what goes: this minor's versions older than the one this server would step back to
879
+ this.versions.prune(this.running);
880
+ }
881
+ finally {
882
+ this.downloading = false;
883
+ }
884
+ }
885
+ /** Every minute: the installed package changed on disk (an `npm install` while this ran), or a
886
+ * patch is ready; restart into it at a quiet moment, or once it waited its longest — where the
887
+ * supervisor would run it (codeToRun says the same), and where this Node is still there to run it. */
888
+ async look() {
889
+ if (this.restarting || !this.o.canRestart)
890
+ return;
891
+ const now = this.now();
892
+ const installed = versionAt(this.installedRoot);
893
+ if (installed !== this.installedAtStart)
894
+ this.installedChanged ??= now;
895
+ else
896
+ this.installedChanged = null;
897
+ const runs = () => codeToRun(this.installedRoot, this.versions, { node: this.o.node, mode: this.mode, site: this.o.site ?? null, also: this.o.also }).version;
898
+ let to = null;
899
+ if (this.installedChanged !== null) {
900
+ if (!installed || !(this.o.quiet() || now - this.installedChanged >= INSTALLED_WAIT))
901
+ return;
902
+ to = runs();
903
+ // a package changed under the server, which runs a patch from the cache it still runs: its
904
+ // files are not those that changed, and nothing changes for it
905
+ if (to === this.running && PACKAGE_ROOT !== this.installedRoot) {
906
+ this.installedAtStart = installed;
907
+ this.installedChanged = null;
908
+ return;
909
+ }
910
+ }
911
+ else if (this.pending?.back) {
912
+ // stepping back from a deprecated patch: to what the supervisor would run now
913
+ if (!(this.o.quiet() || now - this.pending.since >= PATCH_WAIT))
914
+ return;
915
+ to = runs();
916
+ if (to === this.running) {
917
+ this.pending = null;
918
+ return;
919
+ }
920
+ }
921
+ else if (this.pending && this.mode === "auto") {
922
+ if (!(this.o.quiet() || now - this.pending.since >= PATCH_WAIT))
923
+ return;
924
+ to = this.pending.version;
925
+ // what the supervisor would run is another: a newer one ready meanwhile is restarted into;
926
+ // else (the download's own engines, set aside, paused) no restart, which would start this
927
+ // one again
928
+ const would = runs();
929
+ if (would !== to) {
930
+ if (!newerThan(would, this.running)) {
931
+ this.o.log(`updates: VisualProof ${to} is downloaded, but the supervisor would run ${would}: not restarted`);
932
+ this.pending = null;
933
+ return;
934
+ }
935
+ to = would;
936
+ }
937
+ }
938
+ if (to === null)
939
+ return;
940
+ // the Node this server runs on is gone (an upgrade of it removed it): nothing could start again
941
+ if (!existsSync(this.o.execPath ?? process.execPath)) {
942
+ if (!this.nodeGone)
943
+ this.o.log(`updates: ${this.o.execPath ?? process.execPath} is gone (Node upgraded?): this server keeps running VisualProof ${this.running}; restart it to take ${to}`);
944
+ this.nodeGone = true;
945
+ return;
946
+ }
947
+ this.restarting = true;
948
+ this.o.log(`updates: restarting into VisualProof ${to}`);
949
+ this.o.restart(to);
950
+ }
951
+ }