visualproof 0.3.0 → 0.4.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.
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 +2299 -255
  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
@@ -1,52 +1,25 @@
1
1
  // `visualproof doctor [<lean-project>]`: what this machine, and a project, need for VisualProof to
2
- // run, each checked, with what to do where it falls short. It changes nothing: it runs no Lake (in a
3
- // project's folder that would install its toolchain, and Lake may build), and makes no folder.
2
+ // run, each checked, with what to do where it falls short; and whether a newer VisualProof is out,
3
+ // asked of the npm registry through npm (UpdateSpec decision 1). It changes nothing: it runs no Lake
4
+ // (in a project's folder that would install its toolchain, and Lake may build), and makes no folder.
4
5
  import { spawnSync } from "node:child_process";
5
- import { accessSync, constants, existsSync, readFileSync } from "node:fs";
6
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
7
+ import { filmPin } from "./film.js";
8
+ import { tmpdir } from "node:os";
6
9
  import { dirname, join, relative, resolve } from "node:path";
7
- import { projectDirs, siteHolder } from "./dirs.js";
10
+ import { cacheHome, projectDirs, siteHolder } from "./dirs.js";
11
+ import { cmp, packageVersion, parseVersion, satisfies } from "./version.js";
12
+ import { choose, FROM_PACKAGE, installCommand, newerThan, newest, notesOf, npmCommand, PACKAGE_ROOT, readInfo, sameMinor, Versions } from "./updates.js";
13
+ export { packageVersion, satisfies };
8
14
  import { ProjectError, openProject, targetsInWords } from "./project.js";
9
- export const run = (cmd, args) => {
10
- const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
15
+ export const run = (cmd, args, cwd) => {
16
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], ...(cwd ? { cwd } : {}) });
11
17
  return r.error || r.status !== 0 ? null : `${r.stdout}${r.stderr}`;
12
18
  };
13
19
  /** The package's own file, read where both a checkout and the installed package have it. */
14
20
  function packageFile(rel) {
15
21
  return join(import.meta.dirname, "..", "..", rel);
16
22
  }
17
- export function packageVersion() {
18
- return JSON.parse(readFileSync(packageFile("package.json"), "utf8")).version;
19
- }
20
- function parseVersion(v) {
21
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(v);
22
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
23
- }
24
- function cmp(a, b) {
25
- for (let i = 0; i < 3; i++)
26
- if (a[i] !== b[i])
27
- return a[i] - b[i];
28
- return 0;
29
- }
30
- /** Whether a version meets a range of the forms package.json's `engines` uses: alternatives
31
- * joined by `||`, each of `^x.y.z`, `>=x.y.z`, `<x.y.z`, joined by spaces. */
32
- export function satisfies(version, range) {
33
- const v = parseVersion(version);
34
- if (!v)
35
- return false;
36
- return range.split("||").some((alt) => alt.trim().split(/\s+/).every((part) => {
37
- const m = /^(\^|>=|<|)(\d+\.\d+\.\d+)$/.exec(part);
38
- if (!m)
39
- return false;
40
- const w = parseVersion(m[2]);
41
- if (m[1] === "^")
42
- return v[0] === w[0] && cmp(v, w) >= 0;
43
- if (m[1] === ">=")
44
- return cmp(v, w) >= 0;
45
- if (m[1] === "<")
46
- return cmp(v, w) < 0;
47
- return cmp(v, w) === 0;
48
- }));
49
- }
50
23
  /** A toolchain as elan names it: `leanprover/lean4:v4.33.1`, whether the project writes it so, or
51
24
  * as a bare version, or elan lists it as its folder's name (`leanprover--lean4---v4.33.1`). */
52
25
  export function toolchainName(t) {
@@ -92,6 +65,93 @@ function install(what, platform) {
92
65
  return platform === "darwin" ? "brew install poppler" : "install poppler-utils (Debian, Ubuntu: sudo apt install poppler-utils)";
93
66
  return platform === "darwin" ? "install MacTeX, or brew install --cask mactex-no-gui" : "install TeX Live's XeLaTeX (Debian, Ubuntu: sudo apt install texlive-xetex texlive-latex-extra)";
94
67
  }
68
+ /** What the registry says of the releases beyond this one, in a line (UpdateSpec decisions 1–4, 7):
69
+ * asked through npm under this Node, unless updates are off (VISUALPROOF_UPDATES=off); with what
70
+ * this machine's servers set aside. */
71
+ function updatesLine(exec, node, fromPackage, cacheRoot, mode, site) {
72
+ const running = packageVersion();
73
+ if (!fromPackage)
74
+ return "a checkout: its updates are git's, not npm's";
75
+ if (mode === "off")
76
+ return "not asked (VISUALPROOF_UPDATES=off)";
77
+ // what is set aside now (for the project's site, where one is checked), and where its patches are paused
78
+ const kept = new Versions(cacheRoot);
79
+ const held = kept.reasons(Date.now(), site);
80
+ const paused = kept.paused(site);
81
+ const bad = new Map(held.map((h) => [h.version, h]));
82
+ const aside = (held.length ? `; set aside here: ${held.map((h) => `${h.version} (${h.why})`).join(", ")}` : "") +
83
+ (paused ? `; this site's patches up to ${paused.upTo} are not taken (${paused.why})` : "");
84
+ const { cmd, args } = npmCommand();
85
+ // run where no project's .npmrc applies: the caches' root, else the temporary folder
86
+ const cwd = existsSync(cacheRoot) ? cacheRoot : tmpdir();
87
+ const ask = (more) => exec(cmd, [...args, ...more, "--fetch-timeout=15000", "--fetch-retries=1"], cwd);
88
+ // npm's own warnings come after its answer: the answer alone is read
89
+ const json = (out, re) => {
90
+ const m = out === null ? null : re.exec(out)?.[0];
91
+ try {
92
+ return m ? JSON.parse(m) : undefined;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ };
98
+ const v = json(ask(["view", "visualproof", "versions", "--json"]), /\[[^\]]*\]|"[^"\n]*"/);
99
+ if (v === undefined)
100
+ return `the npm registry could not be asked (offline, or npm not found): not checked${aside}`;
101
+ const versions = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
102
+ // the engines of the newest beyond this one: those the doctor would name
103
+ const engines = new Map();
104
+ const deprecated = new Set();
105
+ const first = choose(running, versions, { bad: new Set(bad.keys()) });
106
+ for (const c of [first.patch, first.release]) {
107
+ if (!c || engines.has(c))
108
+ continue;
109
+ const out = ask(["view", `visualproof@${c}`, "engines", "deprecated", "--json"]);
110
+ const info = out === null ? null : readInfo(/^[\s\S]*?(\{[\s\S]*\}|"[^"\n]*")/.exec(out)?.[1] ?? "");
111
+ if (!info)
112
+ continue;
113
+ engines.set(c, info.engines);
114
+ if (info.deprecated)
115
+ deprecated.add(c);
116
+ }
117
+ // only versions npm said the engines and deprecation of are named: one it could not say of is not
118
+ const { patch, release } = choose(running, versions.filter((x) => engines.has(x)), { node, bad: new Set(bad.keys()), engines: (x) => engines.get(x), deprecated });
119
+ const skipped = newest(versions.filter((x) => newerThan(x, running)));
120
+ const unsaid = [first.patch, first.release].find((c) => c && !engines.has(c));
121
+ if (release && !sameMinor(release, running))
122
+ return `${release} is out: ${installCommand(PACKAGE_ROOT, release)} (${notesOf(release)}); a server restarts into it by itself${aside}`;
123
+ // the pause is said in `aside`: no server here takes this one
124
+ if (patch && paused && !newerThan(patch, paused.upTo))
125
+ return `${patch} is out, not taken${aside}, for a day or until a newer patch`;
126
+ if (patch)
127
+ return `${patch} is out: a server takes it by itself (--updates auto), or ${installCommand(PACKAGE_ROOT, patch)}${aside}`;
128
+ if (unsaid)
129
+ return `${unsaid} is out, but npm could not say what it needs: not checked${aside}`;
130
+ if (skipped && deprecated.has(skipped))
131
+ return `${skipped} is out, but deprecated by its publisher${aside}`;
132
+ if (skipped && engines.has(skipped))
133
+ return `${skipped} is out, but needs Node ${engines.get(skipped)}; this is ${node}${aside}`;
134
+ return `the latest release${aside}`;
135
+ }
136
+ /** The tips' tour film, in a line: kept in the cache, or fetched on its first play, or never. */
137
+ function filmLine(cacheRoot, mode) {
138
+ if (process.env.VISUALPROOF_TOUR_FILM)
139
+ return `the local file ${process.env.VISUALPROOF_TOUR_FILM} (VISUALPROOF_TOUR_FILM)`;
140
+ const pin = filmPin();
141
+ if (!pin)
142
+ return "none pinned in this package";
143
+ const kept = join(cacheRoot, "media", `${pin.sha256}.mp4`);
144
+ try {
145
+ if (statSync(kept).size === pin.size)
146
+ return `kept (${kept})`;
147
+ }
148
+ catch {
149
+ // not kept yet
150
+ }
151
+ if (mode === "off")
152
+ return "never fetched (VISUALPROOF_UPDATES=off): the tour shows its stills and captions";
153
+ return `fetched from the project's GitHub release on its first play (${(pin.size / 1e6).toFixed(0)} MB)`;
154
+ }
95
155
  export function doctor(o) {
96
156
  const exec = o.run ?? run;
97
157
  const node = o.node ?? process.versions.node;
@@ -121,8 +181,10 @@ export function doctor(o) {
121
181
  if (exec("git", ["--version"]) === null) {
122
182
  checks.push({ level: "info", what: "git not found: builds are kept without their commit, and past sources only as the builds kept them" });
123
183
  }
184
+ const film = filmLine(o.cache ? resolve(o.cache) : cacheHome(), o.updates ?? process.env.VISUALPROOF_UPDATES);
185
+ const updates = updatesLine(exec, node, o.fromPackage ?? FROM_PACKAGE, o.cache ? resolve(o.cache) : cacheHome(), o.updates ?? process.env.VISUALPROOF_UPDATES, o.path === null ? null : projectDirs(resolve(o.path), o.site, o.cache).site);
124
186
  if (o.path === null)
125
- return { checks, ready: null };
187
+ return { checks, updates, film, ready: null };
126
188
  let project;
127
189
  try {
128
190
  project = openProject(o.path, o.roots, false);
@@ -131,7 +193,7 @@ export function doctor(o) {
131
193
  if (!(e instanceof ProjectError))
132
194
  throw e;
133
195
  checks.push({ level: "fail", what: e.message });
134
- return { checks, ready: null };
196
+ return { checks, updates, film, ready: null };
135
197
  }
136
198
  const { root, toolchain, roots, libDir } = project;
137
199
  const name = toolchainName(toolchain);
@@ -168,7 +230,7 @@ export function doctor(o) {
168
230
  : { level: "info", what: `site ${dirs.site}: a server (process ${holder}) is serving it; another needs a --site of its own` });
169
231
  const failed = checks.some((c) => c.level === "fail");
170
232
  const args = [o.path, ...o.roots.flatMap((r) => ["--root", r]), ...(o.site ? ["--site", o.site] : []), ...(o.cache ? ["--cache", o.cache] : [])];
171
- return { checks, ready: failed ? null : `visualproof serve ${args.map((a) => (/^[\w./:@%+=-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}` };
233
+ return { checks, updates, film, ready: failed ? null : `visualproof serve ${args.map((a) => (/^[\w./:@%+=-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}` };
172
234
  }
173
235
  const MARK = { ok: "✓", fail: "✗", warn: "!", info: "·" };
174
236
  export function printReport(r, project) {
@@ -178,6 +240,7 @@ export function printReport(r, project) {
178
240
  if (c.fix && c.level !== "ok")
179
241
  lines.push(` ${c.fix}`);
180
242
  }
243
+ lines.push(` updates: ${r.updates}`, ` tour film: ${r.film}`);
181
244
  if (r.ready)
182
245
  lines.push("", `Ready: ${r.ready}`);
183
246
  else if (project === null && !r.checks.some((c) => c.level === "fail"))
@@ -77,6 +77,16 @@ export class Timeline {
77
77
  renameSync(tmp, this.file);
78
78
  }
79
79
  /** Record an event, and tell every page. */
80
+ /** The VisualProof version the timeline last recorded as serving the site (UpdateSpec decision
81
+ * 10), or null where none is kept. */
82
+ lastVersion() {
83
+ for (let i = this.events.length - 1; i >= 0; i--) {
84
+ const e = this.events[i];
85
+ if (e.kind === "tool")
86
+ return e.to;
87
+ }
88
+ return null;
89
+ }
80
90
  add(e) {
81
91
  const full = { ...e, id: (this.events[this.events.length - 1]?.id ?? 0) + 1, at: new Date().toISOString() };
82
92
  this.events.push(full);
@@ -19,6 +19,7 @@
19
19
  // with it in the cache and in the sources' zip; their names say what they are drawn from, so the
20
20
  // .tex names them too.
21
21
  import { spawn, spawnSync } from "node:child_process";
22
+ import { childEnv, DETACH, end, grouped } from "./children.js";
22
23
  import { createHash } from "node:crypto";
23
24
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
24
25
  import { join, resolve } from "node:path";
@@ -58,6 +59,11 @@ export class Exporter {
58
59
  this.maxCached = o.maxCached ?? 20;
59
60
  this.concurrent = o.concurrent ?? 2;
60
61
  }
62
+ /** Whether a document is being typeset, or waits its turn: no moment to restart in (UpdateSpec
63
+ * decision 6). */
64
+ get busy() {
65
+ return this.running > 0 || this.queue.length > 0;
66
+ }
61
67
  /** Whether XeLaTeX can be run here: asked once. */
62
68
  available() {
63
69
  if (this.found === null) {
@@ -260,16 +266,17 @@ export class Exporter {
260
266
  return new Promise((ok) => {
261
267
  if (timeoutMs <= 0)
262
268
  return ok("timeout");
263
- const p = spawn(this.xelatex, ["-no-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "doc.tex"], {
269
+ const p = grouped(spawn(this.xelatex, ["-no-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "doc.tex"], {
264
270
  cwd,
265
271
  // TeX's paranoid file access; and a log whose lines are not wrapped, so that it reads back
266
- env: { ...process.env, openin_any: "p", openout_any: "p", shell_escape: "f", max_print_line: "10000" },
272
+ env: childEnv({ openin_any: "p", openout_any: "p", shell_escape: "f", max_print_line: "10000" }),
267
273
  stdio: ["ignore", "ignore", "ignore"],
268
- });
274
+ detached: DETACH,
275
+ }));
269
276
  let killed = false;
270
277
  const timer = setTimeout(() => {
271
278
  killed = true;
272
- p.kill("SIGKILL");
279
+ end(p, "SIGKILL");
273
280
  }, timeoutMs);
274
281
  p.on("error", () => {
275
282
  clearTimeout(timer);
@@ -1,6 +1,7 @@
1
1
  // Run the Lean extractor against a project, or reuse its last result when nothing it reads has
2
2
  // changed.
3
3
  import { spawn } from "node:child_process";
4
+ import { childEnv, DETACH, grouped } from "./children.js";
4
5
  import { createHash } from "node:crypto";
5
6
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join, relative } from "node:path";
@@ -70,7 +71,7 @@ export function leanProgram(prog, toolchain, leanRoot, log) {
70
71
  writeFileSync(join(work, "lean-toolchain"), toolchain + "\n");
71
72
  writeFileSync(join(work, "lakefile.toml"), `name = "${prog.exe.replace(/-/g, "")}"\n\n[[lean_exe]]\nname = "${prog.exe}"\nroot = "${prog.root}"\nsupportInterpreter = true\n`);
72
73
  log(`building ${prog.exe} for ${toolchain}…`);
73
- const child = spawn("lake", ["build", prog.exe], { cwd: work, stdio: ["ignore", "pipe", "pipe"] });
74
+ const child = grouped(spawn("lake", ["build", prog.exe], { cwd: work, stdio: ["ignore", "pipe", "pipe"], detached: DETACH, env: childEnv() }));
74
75
  let tail = "";
75
76
  child.stdout.on("data", (b) => (tail = (tail + b).slice(-2000)));
76
77
  child.stderr.on("data", (b) => (tail = (tail + b).slice(-2000)));
@@ -111,7 +112,7 @@ function runExtractor(p, out, opts, log) {
111
112
  const args = opts.bin
112
113
  ? ["env", opts.bin, out, p.libDir, ...p.roots, ...tail_]
113
114
  : ["env", "lean", "--run", EXTRACTOR, out, p.libDir, ...p.roots, ...tail_];
114
- const child = spawn("lake", args, { cwd: p.root, stdio: ["ignore", "pipe", "pipe"] });
115
+ const child = grouped(spawn("lake", args, { cwd: p.root, stdio: ["ignore", "pipe", "pipe"], detached: DETACH, env: childEnv() }));
115
116
  let tail = "";
116
117
  const onData = (buf) => {
117
118
  const text = buf.toString();
@@ -8,6 +8,7 @@
8
8
  // server lacks, an image no tool here can turn into a PNG — prints as a reference instead, and the
9
9
  // export's notes say so.
10
10
  import { spawn, spawnSync } from "node:child_process";
11
+ import { childEnv, DETACH, end, grouped } from "./children.js";
11
12
  import { closeSync, mkdtempSync, openSync, readFileSync, readSync, rmSync } from "node:fs";
12
13
  import { tmpdir } from "node:os";
13
14
  import { join } from "node:path";
@@ -112,8 +113,8 @@ async function toPng(path, type) {
112
113
  const out = join(dir, "out.png");
113
114
  try {
114
115
  await new Promise((ok, no) => {
115
- const p = spawn(t, ["-limit", "memory", "256MiB", "-limit", "disk", "1GiB", `${type}:${path}[0]`, "-depth", "8", `png:${out}`], { stdio: ["ignore", "ignore", "ignore"] });
116
- const timer = setTimeout(() => p.kill("SIGKILL"), CONVERT_MS);
116
+ const p = grouped(spawn(t, ["-limit", "memory", "256MiB", "-limit", "disk", "1GiB", `${type}:${path}[0]`, "-depth", "8", `png:${out}`], { stdio: ["ignore", "ignore", "ignore"], detached: DETACH, env: childEnv() }));
117
+ const timer = setTimeout(() => end(p, "SIGKILL"), CONVERT_MS);
117
118
  p.on("error", (e) => {
118
119
  clearTimeout(timer);
119
120
  no(e);