prolog-notebook 0.3.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] — 2026-08-30
4
+
5
+ ### Added
6
+
7
+ - **The CLI notices when it is out of date.** On real work — `run` — it asks npm at most once
8
+ a day whether there is something newer, and says nothing unless there is:
9
+
10
+ A newer Prolog Notebook is available: 0.4.0 → 0.5.0
11
+ Update with: npm i -g prolog-notebook
12
+
13
+ `--check-update` forces the question and answers it either way, including *you are on the
14
+ latest* — which the daily check deliberately never says, because a tool that congratulates
15
+ you on every command is one you learn to read past.
16
+
17
+ It stays out of the way by design: never for `--help` or `--version`, never under `--quiet`,
18
+ never when `CI` or `NO_UPDATE_NOTIFIER` is set, and never between the reader and their
19
+ answers — the request is started before the run and collected after it. The notice goes to
20
+ **stderr**, because `run --stdout` is a notebook going down a pipe.
21
+
22
+ A registry it cannot reach is reported once a day rather than on every command: silence
23
+ there would be indistinguishable from *you are up to date*, which is the one thing a broken
24
+ check must not look like. The answer is remembered in `~/.cache/prolog-notebook/`
25
+ (XDG-aware), not beside the package, because a global install is often read-only. A private
26
+ registry is honoured through npm's own `registry` config.
27
+
3
28
  ## [0.3.1] — 2026-08-30
4
29
 
5
30
  ### Changed
package/README.md CHANGED
@@ -91,12 +91,20 @@ chapter changes nothing.
91
91
  | `--stdout` | print the result instead of writing the file |
92
92
  | `--quiet` | report only failures |
93
93
  | `--version` | the tool's version, **the SWI-Prolog version it will run your chapters with**, and the copyright |
94
+ | `--check-update` | ask npm whether a newer one exists, and say so either way |
94
95
 
95
96
  Two things it will not do. A query stopped at the limit is written **without** a terminator,
96
97
  which is the format's way of saying the search was never exhausted — `false.` there would be a
97
98
  forgery. And if a program cell fails to load, nothing is written at all: every answer below it
98
99
  was produced against a chapter that does not exist.
99
100
 
101
+ When it does real work it asks npm, at most once a day, whether there is a newer version, and
102
+ says nothing unless there is. Never for `--help` or `--version`, never under `--quiet`, never
103
+ when `CI` or `NO_UPDATE_NOTIFIER` is set, and never blocking the run. A registry it cannot
104
+ reach is reported once a day rather than on every command — silence there would be
105
+ indistinguishable from *you are up to date*. Ask outright with `--check-update` and it answers
106
+ either way.
107
+
100
108
  It has no defence against a non-terminating goal yet — the engine runs in this process, so
101
109
  `loop :- loop.` hangs the command. Say the word `--limit` all you like; a runaway *consult* is
102
110
  not a solution count. Fixing it properly means a worker thread, and it is the prerequisite for
@@ -9,7 +9,8 @@ import { basename } from 'node:path';
9
9
  import { parse, NotebookError } from '../src/format.js';
10
10
  import { prologVersion } from '../src/engine.js';
11
11
  import { buildLine, currentBuild } from '../src/build-info.js';
12
- import { banner } from '../src/version.js';
12
+ import { banner, VERSION } from '../src/version.js';
13
+ import { updateNotice } from '../src/update.js';
13
14
  import { exportSource } from '../src/export.js';
14
15
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
15
16
 
@@ -38,11 +39,12 @@ const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
38
39
  prolog-notebook run <file.prolog.md>... run every cell, write the answers back
39
40
 
40
41
  Options
41
- --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
42
- --stdout print the result instead of writing the file
43
- --quiet report only failures
44
- --version version, engine and copyright
45
- -h, --help this
42
+ --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
43
+ --stdout print the result instead of writing the file
44
+ --quiet report only failures
45
+ --version version, engine and copyright
46
+ --check-update ask npm whether a newer one exists, and say so either way
47
+ -h, --help this
46
48
 
47
49
  A query that stops at the limit is written without a terminator, which is the
48
50
  format's way of saying the search was never exhausted. Nothing is invented.
@@ -96,10 +98,21 @@ async function main(argv) {
96
98
  return 0;
97
99
  }
98
100
  if (args.includes('--version') || args.includes('-V')) {
101
+ // No update check here, deliberately: --version and --help are what someone
102
+ // types at an install that is not working, and they stay instant and offline.
99
103
  process.stdout.write(await version());
100
104
  return 0;
101
105
  }
102
106
 
107
+ // Asked for explicitly. On its own it is the whole command; alongside `run` it
108
+ // forces the check that would otherwise wait for the day to turn over.
109
+ const asked = args.includes('--check-update');
110
+ if (asked && args.filter((a) => !a.startsWith('-')).length === 0) {
111
+ const notice = await updateNotice({ version: VERSION, force: true });
112
+ process.stderr.write(`${notice}\n`);
113
+ return 0;
114
+ }
115
+
103
116
  const command = args.shift();
104
117
  if (command !== 'run') {
105
118
  process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
@@ -119,6 +132,7 @@ async function main(argv) {
119
132
  options.limit = value;
120
133
  } else if (arg === '--stdout') options.stdout = true;
121
134
  else if (arg === '--quiet') options.quiet = true;
135
+ else if (arg === '--check-update') { /* handled above, and not a file */ }
122
136
  else if (arg.startsWith('-')) {
123
137
  process.stderr.write(`unknown option "${arg}"\n\n${USAGE}`);
124
138
  return 2;
@@ -131,6 +145,18 @@ async function main(argv) {
131
145
  }
132
146
  if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
133
147
 
148
+ // STARTED NOW, READ AT THE END. The registry is somebody else's machine on
149
+ // somebody else's network, and none of that should stand between the reader
150
+ // and their answers — so the question is asked while the work happens and the
151
+ // answer is collected once it is done.
152
+ //
153
+ // Not asked at all under --quiet, unless it was asked for outright: --quiet
154
+ // means "report only failures", and news about a newer version is not one. Not
155
+ // starting the request is better than starting it and discarding the answer.
156
+ const update = options.quiet && !asked
157
+ ? Promise.resolve(null)
158
+ : updateNotice({ version: VERSION, force: asked }).catch(() => null);
159
+
134
160
  // One engine for the whole invocation, restarted between files. A notebook is
135
161
  // a world of its own — one cell is one virtual file, and two chapters may
136
162
  // define the same predicate — so carrying clauses across would let a file pass
@@ -143,6 +169,11 @@ async function main(argv) {
143
169
  await session.restart();
144
170
  status = Math.max(status, await runFile(file, session, options));
145
171
  }
172
+
173
+ // stderr, always: `run --stdout` is a notebook going down a pipe, and a version
174
+ // notice in the middle of it would corrupt the file it is printing.
175
+ const notice = await update;
176
+ if (notice) process.stderr.write(`${notice}\n`);
146
177
  return status;
147
178
  }
148
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
5
  "type": "module",
6
6
  "main": "./src/node.js",
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "82ab5c7",
3
- "built": "2026-08-30 14:50:00 UTC"
2
+ "commit": "0e3e8fa",
3
+ "built": "2026-08-30 15:12:30 UTC"
4
4
  }
package/src/update.js ADDED
@@ -0,0 +1,158 @@
1
+ // Is there a newer one? Asked at most once a day, answered quietly, and never in
2
+ // the way (869erkqpc).
3
+ //
4
+ // THE RULES, because an update notifier is the easiest thing in a CLI to make
5
+ // annoying:
6
+ //
7
+ // - It says NOTHING when you are up to date. A tool that congratulates you on
8
+ // every command is one you learn to read past, and then it is not there when
9
+ // it has something to say. `--check-update` is the exception: you asked, so it
10
+ // answers either way.
11
+ // - It never runs for `--help` or `--version`. Those are what someone types at a
12
+ // broken install, and they must stay instant and offline.
13
+ // - It never runs in CI, and honours NO_UPDATE_NOTIFIER. `--check` will run this
14
+ // command on every push one day; a build that fails because a registry was slow
15
+ // is worse than no notice at all.
16
+ // - A registry it cannot reach is said ONCE A DAY, not on every command. Silence
17
+ // there would be indistinguishable from "you are up to date", which is the one
18
+ // thing a broken check must not look like — but a proxy that blocks npm should
19
+ // not put a line of red in front of somebody on every run either.
20
+ // - Everything else that can go wrong is silent: no cache directory, a registry
21
+ // that answers with nonsense. None of it is the reader's problem.
22
+ //
23
+ // The clock, the cache and "what is the latest version" are all arguments, so
24
+ // everything above is tested without touching the wire; the fetch itself is the
25
+ // only part that ever does.
26
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { dirname, join } from 'node:path';
29
+
30
+ export const DAY = 24 * 60 * 60 * 1000;
31
+
32
+ /** The npm registry's smallest useful answer: one document, one field. */
33
+ export const REGISTRY = 'https://registry.npmjs.org';
34
+
35
+ /**
36
+ * Where the last answer is remembered.
37
+ *
38
+ * XDG when it is set, `~/.cache` otherwise. Not beside the package: a global
39
+ * install is often read-only, and a check that needs write access to node_modules
40
+ * is a check that quietly stops happening.
41
+ */
42
+ export function cachePath(env = process.env, home = homedir()) {
43
+ const base = env.XDG_CACHE_HOME || join(home, '.cache');
44
+ return join(base, 'prolog-notebook', 'update-check.json');
45
+ }
46
+
47
+ /** Reading and writing that file, and shrugging when it cannot. */
48
+ export function fileStore(path = cachePath()) {
49
+ return {
50
+ read() {
51
+ try {
52
+ return JSON.parse(readFileSync(path, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ },
57
+ write(state) {
58
+ try {
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ writeFileSync(path, JSON.stringify(state));
61
+ } catch {
62
+ // A read-only or missing home directory means the check happens every
63
+ // time instead of once a day. That is a slower notifier, not a failure.
64
+ }
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Ask the registry which version is `latest`.
71
+ *
72
+ * The abbreviated document rather than the full packument: the full one carries
73
+ * every version ever published and can be megabytes, for a question with a
74
+ * one-line answer.
75
+ *
76
+ * @returns {Promise<string|null>} null for anything that goes wrong
77
+ */
78
+ export async function latestFromRegistry({
79
+ // A private registry is a fact about the machine, not about this package, so
80
+ // npm's own setting is honoured before the default — and an explicit override
81
+ // exists because a test needs somewhere to point that is not the internet.
82
+ registry = process.env.PROLOG_NOTEBOOK_REGISTRY || process.env.npm_config_registry || REGISTRY,
83
+ name = 'prolog-notebook',
84
+ timeout = 2000,
85
+ fetchImpl = fetch,
86
+ } = {}) {
87
+ try {
88
+ const base = String(registry).replace(/\/+$/, '');
89
+ const response = await fetchImpl(`${base}/${name}/latest`, {
90
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
91
+ signal: AbortSignal.timeout(timeout),
92
+ });
93
+ if (!response.ok) return null;
94
+ const body = await response.json();
95
+ return typeof body?.version === 'string' ? body.version : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Newer, older, or the same — by the numbers only.
103
+ *
104
+ * A prerelease is never newer than the release it precedes, and comparing them
105
+ * properly is a semver library's job. This one is deciding whether to print a
106
+ * sentence, so `0.4.0-rc.1` is simply not an upgrade from `0.3.1`.
107
+ */
108
+ export function isNewer(latest, current) {
109
+ if (/-/.test(String(latest))) return false;
110
+ const parts = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10));
111
+ const [a, b] = [parts(latest), parts(current)];
112
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
113
+ for (let i = 0; i < 3; i++) {
114
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
115
+ }
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * The line to print, or null for silence.
121
+ *
122
+ * @param {object} options
123
+ * @param {string} options.version what this copy is
124
+ * @param {boolean} [options.force] the reader asked, so answer either way
125
+ * @param {number} [options.now]
126
+ * @param {number} [options.ttl]
127
+ * @param {{read: Function, write: Function}} [options.store]
128
+ * @param {() => Promise<string|null>} [options.latest]
129
+ * @param {object} [options.env]
130
+ * @returns {Promise<string|null>}
131
+ */
132
+ export async function updateNotice({
133
+ version,
134
+ force = false,
135
+ now = Date.now(),
136
+ ttl = DAY,
137
+ store = fileStore(),
138
+ latest = latestFromRegistry,
139
+ env = process.env,
140
+ } = {}) {
141
+ if (!force && (env.CI || env.NO_UPDATE_NOTIFIER)) return null;
142
+
143
+ const remembered = store.read();
144
+ const fresh = !force && remembered && now - remembered.checked < ttl;
145
+ const newest = fresh ? remembered.latest : await latest();
146
+ // A failed attempt is remembered too, so an offline machine asks once a day and
147
+ // says so once a day, rather than asking — and complaining — on every command.
148
+ if (!fresh) store.write({ checked: now, latest: newest ?? null });
149
+
150
+ if (!newest) {
151
+ return fresh && !force ? null : 'Could not reach the npm registry to check for updates.';
152
+ }
153
+ if (isNewer(newest, version)) {
154
+ return `A newer Prolog Notebook is available: ${version} → ${newest}\n`
155
+ + 'Update with: npm i -g prolog-notebook';
156
+ }
157
+ return force ? `Prolog Notebook ${version} is the latest.` : null;
158
+ }
package/src/version.js CHANGED
@@ -10,7 +10,7 @@
10
10
  export const NAME = 'Prolog Notebook';
11
11
 
12
12
  /** Must equal package.json's `version` — test/run.test.mjs enforces it. */
13
- export const VERSION = '0.3.1';
13
+ export const VERSION = '0.4.0';
14
14
 
15
15
  /** The two facts a licence notice is actually made of. */
16
16
  export const YEAR = '2026';