prolog-notebook 0.4.2 → 0.4.3

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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.3] — 2026-08-30
4
+
5
+ ### Added
6
+
7
+ - **It offers to update itself, and then carries on** — one command, no re-run:
8
+
9
+ $ prolog-notebook run ch04.prolog.md
10
+ You have Prolog Notebook 0.4.2. The latest is 0.4.3.
11
+ Update and continue on the new version? [Y/n] y
12
+ Updating with npm i -g prolog-notebook@0.4.3
13
+ You now have Prolog Notebook 0.4.3.
14
+ Continuing on the new version.
15
+ ✓ p-family
16
+ ✓ q-is-son — 6 solutions
17
+
18
+ The offer comes **before** the work, because that is the only point at which the answer can
19
+ change anything. It costs a network round trip once a day rather than once a run, since the
20
+ answer is cached. Continuing is a child process on the same path — npm replaces the contents
21
+ of the package directory, so the command the reader typed now holds the new version — with
22
+ stdio inherited, the exit code proxied, and an environment marker that stops the new process
23
+ checking again.
24
+
25
+ - **`prolog-notebook upgrade`**, for when you already know. It replaces this copy only where
26
+ it can prove how it was installed: a global `npm i -g` it will do, a dependency of somebody's
27
+ project it will not touch, and a source checkout is git's business. It installs the exact
28
+ version you were told about rather than `@latest`, which can move in between.
29
+
30
+ Both only where there is somebody to ask — stdin and stderr must be terminals. In a pipe, a
31
+ script or CI the notice comes after the work, with the command to type.
32
+
3
33
  ## [0.4.2] — 2026-08-30
4
34
 
5
35
  ### Fixed
package/README.md CHANGED
@@ -93,6 +93,10 @@ chapter changes nothing.
93
93
  | `--version` | the tool's version, **the SWI-Prolog version it will run your chapters with**, and the copyright |
94
94
  | `--check-update` | ask npm whether a newer one exists, and say so either way |
95
95
 
96
+ ```sh
97
+ prolog-notebook upgrade # fetch the latest
98
+ ```
99
+
96
100
  Two things it will not do. A query stopped at the limit is written **without** a terminator,
97
101
  which is the format's way of saying the search was never exhausted — `false.` there would be a
98
102
  forgery. And if a program cell fails to load, nothing is written at all: every answer below it
@@ -105,6 +109,29 @@ reach is reported once a day rather than on every command — silence there woul
105
109
  indistinguishable from *you are up to date*. Ask outright with `--check-update` and it answers
106
110
  either way.
107
111
 
112
+ When it finds something newer **and you are at a terminal**, it offers to fetch it *before*
113
+ doing the work — and if you say yes it upgrades, then runs your command on the new version.
114
+ One command, no re-run:
115
+
116
+ ```
117
+ $ prolog-notebook run ch04.prolog.md
118
+ You have Prolog Notebook 0.4.0. The latest is 0.4.2.
119
+ Update and continue on the new version? [Y/n] y
120
+ Updating with npm i -g prolog-notebook@0.4.2
121
+ You now have Prolog Notebook 0.4.2.
122
+ Continuing on the new version.
123
+ ✓ p-family
124
+ ✓ q-is-son — 6 solutions
125
+ ```
126
+
127
+ That costs a network round trip once a day rather than once a run, because the answer is
128
+ cached. Down a pipe or in a script there is nobody to ask, so it prints `Update with:
129
+ prolog-notebook upgrade` after the work instead — a question nobody can answer is a hang.
130
+
131
+ `upgrade` replaces this copy only when it can prove how it was installed. A global `npm i -g`
132
+ it will do; a dependency of somebody's project it will not touch, and a source checkout is
133
+ git's business. Guessing wrong there breaks a project while trying to help.
134
+
108
135
  It has no defence against a non-terminating goal yet — the engine runs in this process, so
109
136
  `loop :- loop.` hangs the command. Say the word `--limit` all you like; a runaway *consult* is
110
137
  not a solution count. Fixing it properly means a worker thread, and it is the prerequisite for
@@ -11,6 +11,7 @@ import { prologVersion } from '../src/engine.js';
11
11
  import { buildLine, currentBuild } from '../src/build-info.js';
12
12
  import { banner, VERSION } from '../src/version.js';
13
13
  import { updateNotice } from '../src/update.js';
14
+ import { confirm, describeInstall, globalRoot, install, relaunch, upgradePlan } from '../src/upgrade.js';
14
15
  import { exportSource } from '../src/export.js';
15
16
  import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
16
17
 
@@ -37,6 +38,7 @@ const require = createRequire(import.meta.url);
37
38
  const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
38
39
 
39
40
  prolog-notebook run <file.prolog.md>... run every cell, write the answers back
41
+ prolog-notebook upgrade fetch the latest version
40
42
 
41
43
  Options
42
44
  --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
@@ -91,6 +93,63 @@ async function version() {
91
93
  return `${lines.join('\n')}\n\n`;
92
94
  }
93
95
 
96
+ /**
97
+ * Fetch the latest, if this copy is one we know how to replace.
98
+ *
99
+ * @param {string} version what to install
100
+ * @returns {Promise<number>} an exit code
101
+ */
102
+ async function upgrade(version) {
103
+ const packageRoot = new URL('..', import.meta.url).pathname;
104
+ const kind = describeInstall({ packageRoot, globalRoot: await globalRoot() });
105
+ const plan = upgradePlan(kind, version);
106
+ if (plan.say) {
107
+ process.stderr.write(`${plan.say}\n`);
108
+ return 1;
109
+ }
110
+ process.stderr.write(`Updating with ${NPM_LINE} ${plan.argv.join(' ')}\n`);
111
+ if (!(await install(plan.argv))) {
112
+ process.stderr.write('npm could not complete the update.\n');
113
+ return 1;
114
+ }
115
+ process.stderr.write(`You now have Prolog Notebook ${version}.\n`);
116
+ return 0;
117
+ }
118
+
119
+ /**
120
+ * Offer it, but only where a question is a question.
121
+ *
122
+ * A pipe, a script and CI are all places where waiting for an answer is a hang,
123
+ * so the notice is simply printed there. Both streams are checked because the
124
+ * question goes to stderr and the answer comes from stdin.
125
+ */
126
+ /**
127
+ * Is there somebody at the other end?
128
+ *
129
+ * Both streams, because the question goes to stderr and the answer comes back on
130
+ * stdin. A pipe, a script and CI are all places where a question is a hang.
131
+ */
132
+ function canAsk() {
133
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY);
134
+ }
135
+
136
+ async function offerUpgrade(newer) {
137
+ if (!canAsk()) {
138
+ // Nobody to ask, so say what to type instead. `prolog-notebook upgrade`
139
+ // rather than the npm line: it knows how this copy was installed, and the
140
+ // npm line is wrong for a project dependency.
141
+ process.stderr.write('Update with: prolog-notebook upgrade\n');
142
+ return null;
143
+ }
144
+ if (!(await confirm('Update now?'))) {
145
+ process.stderr.write(' (run `prolog-notebook upgrade` whenever you like)\n');
146
+ return null;
147
+ }
148
+ return upgrade(newer);
149
+ }
150
+
151
+ const NPM_LINE = process.platform === 'win32' ? 'npm.cmd' : 'npm';
152
+
94
153
  async function main(argv) {
95
154
  const args = argv.slice(2);
96
155
  if (!args.length || args.includes('-h') || args.includes('--help')) {
@@ -108,18 +167,25 @@ async function main(argv) {
108
167
  // forces the check that would otherwise wait for the day to turn over.
109
168
  const asked = args.includes('--check-update');
110
169
  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;
170
+ const { message, newer } = await updateNotice({ version: VERSION, force: true });
171
+ if (message) process.stderr.write(`${message}\n`);
172
+ return newer ? (await offerUpgrade(newer)) ?? 0 : 0;
114
173
  }
115
174
 
116
175
  const command = args.shift();
176
+ if (command === 'upgrade') {
177
+ const { message, newer } = await updateNotice({ version: VERSION, force: true });
178
+ if (message) process.stderr.write(`${message}\n`);
179
+ return newer ? upgrade(newer) : 0;
180
+ }
117
181
  if (command !== 'run') {
118
182
  process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
119
183
  return 2;
120
184
  }
121
185
 
122
186
  const options = { limit: DEFAULT_LIMIT, stdout: false, quiet: false };
187
+ // Whether the offer has already been made, before the work started.
188
+ let checked = false;
123
189
  const files = [];
124
190
  while (args.length) {
125
191
  const arg = args.shift();
@@ -145,6 +211,31 @@ async function main(argv) {
145
211
  }
146
212
  if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
147
213
 
214
+ // BEFORE THE WORK, when there is somebody to ask — because the point of asking
215
+ // is to run the NEW version, and that is only possible while there is still
216
+ // something to run. Afterwards the files are written and the answer comes too
217
+ // late to change them.
218
+ //
219
+ // It costs a network round trip once a day, not once a run: the rest of the day
220
+ // is a file read. Measured at 25-120 ms against npm, against a run that spends
221
+ // seconds in Prolog.
222
+ if (canAsk() && !options.quiet) {
223
+ const ahead = await updateNotice({ version: VERSION, force: asked })
224
+ .catch(() => ({ message: null, newer: null }));
225
+ if (ahead.message) process.stderr.write(`${ahead.message}\n`);
226
+ if (ahead.newer && await confirm('Update and continue on the new version?')) {
227
+ if ((await upgrade(ahead.newer)) === 0) {
228
+ process.stderr.write('Continuing on the new version.\n');
229
+ // The path has not changed — npm replaced what is behind it — so this is
230
+ // the same command, running the bytes that have just arrived.
231
+ return relaunch(process.argv);
232
+ }
233
+ process.stderr.write('Carrying on with the version you have.\n');
234
+ }
235
+ // Asked and answered: the check below has nothing left to say.
236
+ checked = true;
237
+ }
238
+
148
239
  // STARTED NOW, READ AT THE END. The registry is somebody else's machine on
149
240
  // somebody else's network, and none of that should stand between the reader
150
241
  // and their answers — so the question is asked while the work happens and the
@@ -153,9 +244,11 @@ async function main(argv) {
153
244
  // Not asked at all under --quiet, unless it was asked for outright: --quiet
154
245
  // means "report only failures", and news about a newer version is not one. Not
155
246
  // 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);
247
+ // The other half: nobody to ask, so the question is asked ALONGSIDE the work
248
+ // and reported at the end. A terminal has had its offer already.
249
+ const update = checked || (options.quiet && !asked)
250
+ ? Promise.resolve({ message: null, newer: null })
251
+ : updateNotice({ version: VERSION, force: asked }).catch(() => ({ message: null, newer: null }));
159
252
 
160
253
  // One engine for the whole invocation, restarted between files. A notebook is
161
254
  // a world of its own — one cell is one virtual file, and two chapters may
@@ -172,8 +265,11 @@ async function main(argv) {
172
265
 
173
266
  // stderr, always: `run --stdout` is a notebook going down a pipe, and a version
174
267
  // 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`);
268
+ const { message, newer } = await update;
269
+ if (message) process.stderr.write(`${message}\n`);
270
+ // Offered AFTER the work, and never re-running it: the files are written, and
271
+ // a command that repeated itself on a newer version would write them twice.
272
+ if (newer) await offerUpgrade(newer);
177
273
  return status;
178
274
  }
179
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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": "a4ae8f0",
3
- "built": "2026-08-30 15:34:45 UTC"
2
+ "commit": "bd4d4d6",
3
+ "built": "2026-08-30 15:55:33 UTC"
4
4
  }
package/src/update.js CHANGED
@@ -124,7 +124,11 @@ export function isNewer(latest, current) {
124
124
  }
125
125
 
126
126
  /**
127
- * The line to print, or null for silence.
127
+ * What to say, and what was found.
128
+ *
129
+ * An object rather than a string because a caller may want to ACT on it — offer
130
+ * to upgrade — and parsing our own sentence back into a version number would be
131
+ * a worse way to learn what we already knew.
128
132
  *
129
133
  * @param {object} options
130
134
  * @param {string} options.version what this copy is
@@ -134,7 +138,7 @@ export function isNewer(latest, current) {
134
138
  * @param {{read: Function, write: Function}} [options.store]
135
139
  * @param {() => Promise<string|null>} [options.latest]
136
140
  * @param {object} [options.env]
137
- * @returns {Promise<string|null>}
141
+ * @returns {Promise<{message: string|null, newer: string|null}>}
138
142
  */
139
143
  export async function updateNotice({
140
144
  version,
@@ -145,7 +149,12 @@ export async function updateNotice({
145
149
  latest = latestFromRegistry,
146
150
  env = process.env,
147
151
  } = {}) {
148
- if (!force && (env.CI || env.NO_UPDATE_NOTIFIER)) return null;
152
+ const quiet = { message: null, newer: null };
153
+ // UPGRADED is the loop guard: the process that replaced itself re-runs this
154
+ // command on the new version, and that one must not go round again. A failed
155
+ // or partial upgrade would otherwise re-exec for ever.
156
+ if (!force && (env.CI || env.NO_UPDATE_NOTIFIER || env.PROLOG_NOTEBOOK_UPGRADED)) return quiet;
157
+ if (env.PROLOG_NOTEBOOK_UPGRADED) return quiet;
149
158
 
150
159
  const remembered = store.read();
151
160
  const fresh = !force && remembered && now - remembered.checked < ttl;
@@ -155,14 +164,20 @@ export async function updateNotice({
155
164
  if (!fresh) store.write({ checked: now, latest: newest ?? null });
156
165
 
157
166
  if (!newest) {
158
- return fresh && !force ? null : 'Could not reach the npm registry to check for updates.';
167
+ return fresh && !force
168
+ ? quiet
169
+ : { message: 'Could not reach the npm registry to check for updates.', newer: null };
159
170
  }
160
171
  // BOTH LINES START WITH WHAT YOU HAVE, because that is the question being
161
172
  // asked. "Prolog Notebook 0.4.0 is the latest" states a fact about the world
162
173
  // and leaves the reader to work out that it is also a fact about them.
163
174
  if (isNewer(newest, version)) {
164
- return `You have Prolog Notebook ${version}. The latest is ${newest}.\n`
165
- + 'Update with: npm i -g prolog-notebook';
175
+ return {
176
+ message: `You have Prolog Notebook ${version}. The latest is ${newest}.`,
177
+ newer: newest,
178
+ };
166
179
  }
167
- return force ? `You have the latest version of Prolog Notebook, ${version}.` : null;
180
+ return force
181
+ ? { message: `You have the latest version of Prolog Notebook, ${version}.`, newer: null }
182
+ : quiet;
168
183
  }
package/src/upgrade.js ADDED
@@ -0,0 +1,128 @@
1
+ // Updating itself, which is mostly a question of knowing how it was installed.
2
+ //
3
+ // A global `npm i -g`, a project dependency, an npx run, a pnpm or bun global,
4
+ // a git checkout — each needs a different answer, and running `npm i -g` from
5
+ // the wrong one either fails or upgrades something the reader did not mean. So
6
+ // this proves the global case and REFUSES THE REST WITH THE RIGHT COMMAND
7
+ // rather than guessing: a tool that breaks somebody's project while trying to
8
+ // help is worse than one that tells them what to type.
9
+ //
10
+ // Nothing here happens without being asked. See bin/prolog-notebook.mjs for the
11
+ // prompt, which only appears at a terminal — a pipe, a script and CI are all
12
+ // places where a question is a hang.
13
+ import { spawn } from 'node:child_process';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+
17
+ const run = promisify(execFile);
18
+
19
+ /** npm is a batch file on Windows, and spawn will not find it otherwise. */
20
+ export const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
21
+
22
+ /**
23
+ * Which kind of copy is this?
24
+ *
25
+ * @param {{packageRoot: string, globalRoot: string|null}} where
26
+ * @returns {'global'|'local'|'source'}
27
+ */
28
+ export function describeInstall({ packageRoot, globalRoot }) {
29
+ if (globalRoot && packageRoot.startsWith(globalRoot)) return 'global';
30
+ // `node_modules` in the path and not the global root: somebody's project
31
+ // depends on this, and upgrading it globally would leave that project on the
32
+ // version it pinned while changing a tool they did not ask about.
33
+ if (packageRoot.includes(`${'node_modules'}`)) return 'local';
34
+ return 'source';
35
+ }
36
+
37
+ /**
38
+ * What to do about it — a command to run, or words to print.
39
+ *
40
+ * @param {'global'|'local'|'source'} kind
41
+ * @param {string} version
42
+ * @returns {{argv: string[]}|{say: string}}
43
+ */
44
+ export function upgradePlan(kind, version) {
45
+ if (kind === 'global') return { argv: ['i', '-g', `prolog-notebook@${version}`] };
46
+ if (kind === 'local') {
47
+ return {
48
+ say: 'This copy is a dependency of a project rather than a global install.\n'
49
+ + `Upgrade it there: npm i prolog-notebook@${version}`,
50
+ };
51
+ }
52
+ return {
53
+ say: 'This copy is a source checkout, not an install. Upgrade it with git.',
54
+ };
55
+ }
56
+
57
+ /** Where npm keeps global packages, or null if it will not say. */
58
+ export async function globalRoot(exec = run) {
59
+ try {
60
+ const { stdout } = await exec(NPM, ['root', '-g'], { timeout: 5000 });
61
+ return stdout.trim() || null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Ask a yes/no question, defaulting to yes.
69
+ *
70
+ * ON STDERR, always: `run --stdout` is a notebook going down a pipe, and a
71
+ * question in the middle of it would corrupt the file it is writing.
72
+ *
73
+ * @returns {Promise<boolean>}
74
+ */
75
+ export async function confirm(question, { input = process.stdin, output = process.stderr } = {}) {
76
+ const { createInterface } = await import('node:readline/promises');
77
+ const rl = createInterface({ input, output });
78
+ try {
79
+ const answer = await rl.question(`${question} [Y/n] `);
80
+ return !/^n/i.test(answer.trim());
81
+ } catch {
82
+ // Ctrl-C, a closed stream: not an answer, so not a yes.
83
+ return false;
84
+ } finally {
85
+ rl.close();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Do it, showing npm's own output rather than a spinner of our own.
91
+ *
92
+ * @returns {Promise<boolean>} whether npm was happy
93
+ */
94
+ export function install(argv, spawnImpl = spawn) {
95
+ return new Promise((resolve) => {
96
+ const child = spawnImpl(NPM, argv, { stdio: ['ignore', 'inherit', 'inherit'] });
97
+ child.on('error', () => resolve(false));
98
+ child.on('close', (code) => resolve(code === 0));
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Run this same command again, on the version that has just replaced us.
104
+ *
105
+ * THE PATH DOES NOT CHANGE, which is what makes this work: npm replaces the
106
+ * contents of the package directory, and the bin the reader typed still points
107
+ * at the same file. So the script to run is the one we are already running — its
108
+ * bytes are simply new.
109
+ *
110
+ * A child rather than a true exec, because Node has no execve: stdio is
111
+ * inherited so it looks like one process, and the child's exit code becomes
112
+ * ours. The marker in the environment stops the new process checking for updates
113
+ * again, which is what would otherwise turn a failed upgrade into a loop.
114
+ *
115
+ * @param {string[]} argv the original process.argv
116
+ * @param {Function} [spawnImpl]
117
+ * @returns {Promise<number>} the exit code to leave with
118
+ */
119
+ export function relaunch(argv, spawnImpl = spawn) {
120
+ return new Promise((resolve) => {
121
+ const child = spawnImpl(argv[0], argv.slice(1), {
122
+ stdio: 'inherit',
123
+ env: { ...process.env, PROLOG_NOTEBOOK_UPGRADED: '1' },
124
+ });
125
+ child.on('error', () => resolve(1));
126
+ child.on('close', (code) => resolve(code ?? 0));
127
+ });
128
+ }
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.4.2';
13
+ export const VERSION = '0.4.3';
14
14
 
15
15
  /** The two facts a licence notice is actually made of. */
16
16
  export const YEAR = '2026';