bantamkit-mcp 0.29.2 → 0.31.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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Ask the package index what the latest version is, and update when it differs — `--update`.
3
+ *
4
+ * THE PORT OF `runtime-py/src/bantamkit/selfupdate.py`, AND THE SENTENCES ARE COPIED FROM IT
5
+ * BYTE FOR BYTE. That module's docstring carries the whole argument — why `docs/roadmap-agent-
6
+ * stack.md` AS-7's "do NOT build `--update`" was reversed by the user on 2026-09-11, and why
7
+ * three of AS-7's four reasons survive the reversal as facts that decide what the flag PRINTS
8
+ * rather than whether it exists. It is not restated here; read it there. What belongs HERE is
9
+ * the part that could not be copied.
10
+ *
11
+ * EXACTLY THREE THINGS DIFFER FROM THE REFERENCE, and each is a genuinely different object
12
+ * rather than a different spelling of one object:
13
+ *
14
+ * 1. THE URL AND THE JSON PATH. PyPI answers `pypi.org/pypi/bantamkit/json` and the version
15
+ * sits at `info.version`; the npm registry answers `registry.npmjs.org/bantamkit-mcp/
16
+ * latest` and it sits at the top level as `version`. Measured 2026-09-11 against the real
17
+ * registry: the `/latest` document is the manifest of the `latest` dist-tag, 26 keys, and
18
+ * `version` is `0.30.0`.
19
+ * 2. THE COMMAND. `pip install --upgrade` there; `npm install` here, and WHICH `npm install`
20
+ * is a measured question — see `upgradeCommand`.
21
+ * 3. THE FOUR `ROUTES` SENTENCES, which name those commands and the things they update.
22
+ * `ephemeral`'s is not a translation of the reference's and must not be made into one:
23
+ * see the note on that row.
24
+ *
25
+ * `COMPARISON`, `UP_TO_DATE`, `AHEAD`, `UPDATING`, `PRINTED`, `UPDATED`, `RESTART`, `NO_ROUTE`,
26
+ * `TIMED_OUT`, `UNREACHABLE`, `NOT_A_VERSION`, `COMMAND_FAILED`, `SHAPE_UNKNOWN`, `NOT_JSON`,
27
+ * `NO_VERSION_FIELD` and `NO_OUTPUT` are byte-for-byte identical to the reference's. They
28
+ * deliberately say `bantamkit-mcp` and never a package name: the PyPI distribution is
29
+ * `bantamkit`, the npm package is `bantamkit-mcp`, and only the COMMAND the operator typed is
30
+ * the same word on both sides — so naming the command is what keeps these sentences identical
31
+ * instead of ruled, and still names something true.
32
+ *
33
+ * NO NEW DEPENDENCY. The HTTP call is the platform's own `fetch` (Node >= 20, which
34
+ * `package.json` already requires) with `AbortSignal.timeout`; the version comparison, the
35
+ * `shlex.join` rendering and the `str.format` substitution are hand-rolled here in the
36
+ * `pyyaml.ts` / `pyjson.ts` / `pyargparse.ts` idiom.
37
+ *
38
+ * Layer 5 (Composition): this module reaches the network and shells out to an installer.
39
+ * `cli.ts` is the ONLY module in `src/` that imports it — asserted structurally in
40
+ * `test/selfupdate.test.mjs` — and it calls `runUpdate` from the flag and returns before a
41
+ * memory store or a transport exists, the same shape `--assets-root` and `--install` use.
42
+ */
43
+ import { spawnSync } from 'node:child_process';
44
+ import { readFileSync } from 'node:fs';
45
+ import { basename, dirname, join, resolve } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { currentInstall, Undetermined } from './mcp/identity.js';
48
+ /**
49
+ * The command the operator typed, on BOTH runtimes. Not the package name — see the module
50
+ * docstring: PyPI has `bantamkit`, npm has `bantamkit-mcp`, and only this word is the same on
51
+ * both, so only this word can appear in a sentence the reference and this file share verbatim.
52
+ */
53
+ export const PROGRAM = 'bantamkit-mcp';
54
+ /** The npm package this install would upgrade. Divergent by construction (`DISTRIBUTION`). */
55
+ export const PACKAGE = 'bantamkit-mcp';
56
+ /**
57
+ * The one URL this toolbox ever fetches. Divergent by construction (`INDEX_URL`).
58
+ *
59
+ * `/latest` and not the full packument: the packument carries every version ever published and
60
+ * grows without bound, and the question here is one string. Measured 2026-09-11 — this document
61
+ * is 26 keys and carries `version` at the top level.
62
+ */
63
+ export const INDEX_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
64
+ /**
65
+ * Explicit, and short enough that a person who typed the flag does not think it hung.
66
+ * AS-7(3): the network is not on any other path here, so nothing else inherits this.
67
+ */
68
+ export const DEFAULT_TIMEOUT_SECONDS = 10;
69
+ /**
70
+ * What stands in for an installer that printed nothing, so the report has ONE shape rather
71
+ * than a line that is sometimes there. A conditional line is a second thing to port.
72
+ */
73
+ export const NO_OUTPUT = '(nothing)';
74
+ // --- the sentences ------------------------------------------------------------------
75
+ // Every one of these was copied out of `runtime-py/src/bantamkit/selfupdate.py`, which is
76
+ // where they are DEFINED. Changing one is a change to both runtimes and to the conformance
77
+ // suite, never to this file alone.
78
+ /**
79
+ * Line 1 of every report, and the opening of the no-route refusal. It carries BOTH numbers
80
+ * because "up to date" without them is unfalsifiable by the person reading it.
81
+ */
82
+ export const COMPARISON = '{program} {installed} is installed; the package index has {latest}.';
83
+ /** The user asked for this word specifically: "ถ้า match ให้แสดงคำ uptodate". */
84
+ export const UP_TO_DATE = 'up to date.';
85
+ /**
86
+ * The registry BEHIND the installed version. A real state, not a curiosity: a checkout build,
87
+ * or a release that has not been published yet, both land here.
88
+ */
89
+ export const AHEAD = 'the installed version is ahead of the package index; there is nothing to update to.';
90
+ /** What the command is about to run. The command itself is divergent; this line is not. */
91
+ export const UPDATING = 'updating from the package index: {command}';
92
+ /** The installer's own words, never summarised. Both runtimes print the block the same way. */
93
+ export const PRINTED = 'the command printed:';
94
+ export const UPDATED = 'updated {program} from {installed} to {latest}.';
95
+ /** AS-7(1), measured. This is the line that makes the success true rather than plausible. */
96
+ export const RESTART = 'restart the server: a running {program} keeps serving the code it loaded at startup, ' +
97
+ 'so bantamkit_status will report {installed} until the host reconnects.';
98
+ // --- the refusals -------------------------------------------------------------------
99
+ // Thrown as `UpdateRefused`; `runUpdate` prints `error: <message>` on stderr and exits 1.
100
+ // Exit 1 and not 0 for ALL of them, including the no-route one: an operator who typed
101
+ // `--update` asked for an update, and a command that exits 0 having changed nothing is the
102
+ // J46-4 defect. Exit 1 and not 2, because argparse already owns 2 for a usage error and
103
+ // `--update` is not one.
104
+ export const NO_ROUTE = '{program} {installed} is installed and the package index has {latest}, but this is a ' +
105
+ '{shape} install, which --update will not touch. {route}';
106
+ export const TIMED_OUT = 'the package index did not answer within {timeout} seconds; --update needs the network, ' +
107
+ 'and nothing was changed.';
108
+ export const UNREACHABLE = 'the package index could not be reached: {reason}; --update needs the network, and ' +
109
+ 'nothing was changed.';
110
+ export const NOT_A_VERSION = 'the package index answered, but not with a version for {program}: {why}; nothing was ' +
111
+ 'changed.';
112
+ /**
113
+ * Multi-line on purpose. The installer's output is the only thing that says WHY it failed,
114
+ * and a refusal that swallowed it would send the operator to re-run the command by hand.
115
+ */
116
+ export const COMMAND_FAILED = 'the update command exited {code}: {command}\n' +
117
+ '{program} {installed} is still installed; nothing was changed.\n' +
118
+ '{printed}\n' +
119
+ '{output}';
120
+ /**
121
+ * The install shape itself could not be derived — `identity.Undetermined`, which is what an
122
+ * origin that is neither an index nor a path on this machine raises (`git+https://…`). That
123
+ * exception already carries the operator's next step, so this sentence quotes it rather than
124
+ * paraphrasing it, and refuses rather than picking a route.
125
+ */
126
+ export const SHAPE_UNKNOWN = '--update could not tell how this install was made, so it will not guess an update ' +
127
+ 'route: {reason}';
128
+ /**
129
+ * The two ways a well-formed HTTP response can still not carry a version, as the `{why}` of
130
+ * `NOT_A_VERSION`. Both runtimes ask a different URL and read a different JSON path, so what
131
+ * is compared is that the same two failures produce the same two sentences.
132
+ */
133
+ export const NOT_JSON = 'the response is not JSON';
134
+ export const NO_VERSION_FIELD = 'the response carries no version string';
135
+ /**
136
+ * The fallthrough for a shape word `ROUTES` has never heard of — `INSTALL_SHAPES` growing a
137
+ * sixth word. Not divergent: it names no package manager, so it is the reference's sentence.
138
+ */
139
+ export const NO_RECORDED_ROUTE = 'There is no recorded update route for a {shape} install, so --update will not guess one.';
140
+ /**
141
+ * Per shape, what the real update route is — the answer to "then how DO I update this?".
142
+ * DIVERGENT BY CONSTRUCTION and registered in `docs/porting.md` by J46-31: the reference's
143
+ * rows name pip and a Python tree; these name npm and a BUILT one.
144
+ *
145
+ * `registry` is absent on purpose, exactly as it is there. It is the one shape that HAS a
146
+ * route through this flag, so a sentence telling its operator to go do it by hand would never
147
+ * be reachable, and an unreachable sentence is one more thing for nobody to check.
148
+ *
149
+ * `{command}` is the SAME string `upgradeCommand` builds and `UPDATING` prints, never a second
150
+ * spelling of it — `local-file` is the one row whose advice is an actual command, and the
151
+ * README's measured fix for the real incident is exactly this: `npm i --prefix
152
+ * ~/.local/share/bantamkit-mcp bantamkit-mcp@latest`.
153
+ *
154
+ * `linked` and `checkout` BOTH say "rebuild it". That is not padding and it is the difference
155
+ * from the reference that matters most on this side: an editable Python install serves the
156
+ * `.py` files a `git pull` just changed, and a Node install serves `dist/`, which is build
157
+ * output. `README.md#updating`'s checkout row already says `git pull && npm ci && npm run
158
+ * build`, and a route that stopped at `git pull` would be a remedy that changes nothing.
159
+ *
160
+ * `ephemeral` IS NOT A TRANSLATION OF THE REFERENCE'S ROW, and making it one would make it
161
+ * false. There it says "the next run fetches {latest} by itself", which is true of a `pipx
162
+ * run` environment; here the shape is an `npx` cache, and `README.md#silent-version-float`
163
+ * measured that `npx -y bantamkit-mcp` RESOLVES `latest` ONCE AND CACHES IT — two people with
164
+ * byte-identical config can be running builds resolved weeks apart. So this row is true of the
165
+ * cache that actually exists rather than of a fresh resolve that does not happen, and it names
166
+ * the only thing that changes it: the spec on the host's command line.
167
+ */
168
+ export const ROUTES = {
169
+ 'local-file': 'It was installed from the file {source}, which is not the package index: reinstall it ' +
170
+ 'from that path, or run {command} to move it onto the index.',
171
+ linked: 'It is a linked install of the tree at {source}: update that tree where it was cloned, ' +
172
+ 'with git pull, and rebuild it — dist/ is build output, so a pull alone changes nothing.',
173
+ checkout: 'It is running out of a source tree at {source} that no installer recorded: update that ' +
174
+ 'tree where it was cloned, with git pull, and rebuild it — dist/ is build output, so a ' +
175
+ 'pull alone changes nothing.',
176
+ ephemeral: 'It is running from an npx cache that is discarded after the run and never updated in ' +
177
+ 'place, so there is nothing here to update: the next run serves the same cached ' +
178
+ '{installed} unless the host command line asks for {package}@latest.',
179
+ };
180
+ /**
181
+ * A refusal carrying the sentence the operator should read. Never a stack trace.
182
+ *
183
+ * The same contract `hostinstall.InstallError` has, and for the same reason: `--update` can
184
+ * fail for four reasons that are all somebody else's to fix, and a stack trace names none.
185
+ */
186
+ export class UpdateRefused extends Error {
187
+ }
188
+ /**
189
+ * The clock ran out. THE CONTRACT BETWEEN `fetchIndex` AND `update` IS THIS TYPE.
190
+ *
191
+ * On the reference side the distinction is `TimeoutError` vs any other `OSError`, and the
192
+ * ORDER of the two `except` clauses is load-bearing because `TimeoutError` IS an `OSError`.
193
+ * The Node hazard is the same one with different names, and it is MEASURED (Node v25.2.1):
194
+ * an `AbortSignal.timeout` abort arrives as a `DOMException` whose `name` is `'TimeoutError'`
195
+ * — and `DOMException` IS `instanceof Error`. So a `catch` that tested `instanceof Error`
196
+ * first would swallow every timeout into the unreachable arm, which names the wrong problem
197
+ * and sends the operator to check a network that is working. `test/selfupdate.test.mjs` pins
198
+ * both halves: that this class is an `Error`, and that the real abort reason is one too.
199
+ */
200
+ export class IndexTimeout extends Error {
201
+ }
202
+ export function installEnvironment(runningFile = fileURLToPath(import.meta.url)) {
203
+ let here = dirname(resolve(runningFile));
204
+ for (;;) {
205
+ if (basename(here) === 'node_modules') {
206
+ const root = dirname(here);
207
+ let owned = false;
208
+ try {
209
+ readFileSync(join(root, 'package.json'));
210
+ owned = true;
211
+ }
212
+ catch {
213
+ owned = false;
214
+ }
215
+ return { root, global: !owned };
216
+ }
217
+ const parent = dirname(here);
218
+ if (parent === here)
219
+ return { root: '', global: true };
220
+ here = parent;
221
+ }
222
+ }
223
+ /**
224
+ * `str.format` over the one substitution shape these sentences use — `{name}` and nothing else.
225
+ *
226
+ * A missing key THROWS, the way `str.format` raises `KeyError`, rather than leaving `{name}`
227
+ * in the text: an unsubstituted placeholder reaching the operator is a defect the reference's
228
+ * suite asserts against by name, and a silent passthrough is how one ships.
229
+ */
230
+ export function fill(template, values) {
231
+ return template.replace(/\{(\w+)\}/g, (_whole, key) => {
232
+ if (!(key in values))
233
+ throw new Error(`no value for {${key}} in a --update sentence`);
234
+ return String(values[key]);
235
+ });
236
+ }
237
+ /**
238
+ * `shlex.join`: `shlex.quote` each word and space them.
239
+ *
240
+ * Hand-rolled from CPython's own rule — `_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII)`
241
+ * — because the rendered command is printed to the operator by `UPDATING`, by `COMMAND_FAILED`
242
+ * and by the `local-file` route, and a prefix path with a space in it is the normal case on
243
+ * macOS and on Windows. Single quotes, with an embedded `'` closed and reopened the way
244
+ * `shlex.quote` does it, so the line can be pasted back into a shell unchanged.
245
+ */
246
+ export function shlexJoin(command) {
247
+ return command
248
+ .map((word) => {
249
+ if (word === '')
250
+ return "''";
251
+ if (/^[\w@%+=:,./-]+$/.test(word))
252
+ return word;
253
+ return `'${word.replace(/'/g, `'"'"'`)}'`;
254
+ })
255
+ .join(' ');
256
+ }
257
+ /**
258
+ * The version string out of the registry's JSON, or a named refusal. Pure — no network here.
259
+ *
260
+ * SPLIT FROM THE FETCH SO THE GARBAGE CASE IS TESTED THROUGH THE REAL PARSER, exactly as the
261
+ * reference splits it: a test that stubbed a parsed version would prove nothing about what
262
+ * happens when a captive-portal login page comes back with a 200, which is the realistic shape
263
+ * of "the index answered and it was not the index".
264
+ *
265
+ * THE JSON PATH IS THE DIVERGENCE. PyPI nests it at `info.version`; `registry.npmjs.org/<pkg>/
266
+ * latest` carries it at the top level. Both refusals below are the same two sentences.
267
+ */
268
+ export function latestFromIndexPayload(text) {
269
+ let payload;
270
+ try {
271
+ payload = JSON.parse(text);
272
+ }
273
+ catch {
274
+ throw new UpdateRefused(fill(NOT_A_VERSION, { program: PROGRAM, why: NOT_JSON }));
275
+ }
276
+ let version = '';
277
+ if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
278
+ const raw = payload['version'];
279
+ if (typeof raw === 'string')
280
+ version = raw.trim();
281
+ }
282
+ if (version === '') {
283
+ throw new UpdateRefused(fill(NOT_A_VERSION, { program: PROGRAM, why: NO_VERSION_FIELD }));
284
+ }
285
+ return version;
286
+ }
287
+ /** The User-Agent's version half, or nothing. A header, never an output — never a refusal. */
288
+ function packageVersion() {
289
+ try {
290
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
291
+ return typeof manifest.version === 'string' ? manifest.version : '0';
292
+ }
293
+ catch {
294
+ return '0';
295
+ }
296
+ }
297
+ /**
298
+ * The ONLY network call in this runtime. One request, one timeout, two failure words.
299
+ *
300
+ * THE PLATFORM'S `fetch`, NOT A DEPENDENCY. `package.json` declares exactly one runtime
301
+ * dependency and the pure-node `npx` install is a standing ruling, so the HTTP client is the
302
+ * one Node ships (>= 18, and `engines` already requires >= 20) and the deadline is
303
+ * `AbortSignal.timeout`, which is the same object the abort arrives as.
304
+ *
305
+ * THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Measured on Node
306
+ * v25.2.1 against a local socket that accepts and never answers: the rejection is a
307
+ * `DOMException` named `TimeoutError`. Everything else is a `TypeError: fetch failed` whose
308
+ * `cause` carries the sentence worth printing — `connect ECONNREFUSED 127.0.0.1:49999`,
309
+ * `getaddrinfo ENOTFOUND …` — so the cause's message is what `UNREACHABLE` interpolates and
310
+ * the bare "fetch failed" never reaches an operator.
311
+ *
312
+ * A non-2xx is an `Error` and not a timeout, the way the reference turns `HTTPError` into a
313
+ * plain `OSError`; and the body is decoded leniently, because a body that is not UTF-8 is not
314
+ * a version either and should be refused BY NAME in `latestFromIndexPayload`.
315
+ */
316
+ export async function fetchIndex(url = INDEX_URL, timeout = DEFAULT_TIMEOUT_SECONDS) {
317
+ const deadline = AbortSignal.timeout(Math.max(1, Math.round(timeout * 1000)));
318
+ try {
319
+ const response = await fetch(url, {
320
+ headers: { Accept: 'application/json', 'User-Agent': `${PROGRAM}/${packageVersion()}` },
321
+ signal: deadline,
322
+ });
323
+ if (!response.ok) {
324
+ throw new Error(`HTTP ${response.status} ${response.statusText}`.trimEnd());
325
+ }
326
+ const raw = await response.arrayBuffer();
327
+ return new TextDecoder('utf-8').decode(raw);
328
+ }
329
+ catch (e) {
330
+ if (e instanceof IndexTimeout)
331
+ throw e;
332
+ if (e instanceof Error && e.name === 'TimeoutError')
333
+ throw new IndexTimeout(e.message);
334
+ const cause = e.cause;
335
+ const detail = cause instanceof Error && cause.message !== '' ? cause.message : null;
336
+ throw new Error(detail ?? (e instanceof Error ? e.message : String(e)));
337
+ }
338
+ }
339
+ /**
340
+ * What a `registry` install upgrades with, ON THE TREE IT IS ACTUALLY IN.
341
+ *
342
+ * The reference's reason transfers exactly: "the server may be running from a venv whose `pip`
343
+ * is not the one first on `PATH`, and upgrading the wrong environment is a failure that
344
+ * reports success." Here the wrong environment is a different `node_modules`, and `--prefix` is
345
+ * npm's own way of naming the right one.
346
+ *
347
+ * `--global` FOR THE GLOBAL TREE AND NEVER `--prefix`. See `installEnvironment`: on a tree with
348
+ * no `package.json` beside `node_modules`, `npm install --prefix` prunes every sibling package.
349
+ * Both arms are the rows `README.md#updating` already documents.
350
+ *
351
+ * `@latest` IS NOT DECORATION. `npm install <pkg>` in a tree that already declares `<pkg>`
352
+ * satisfies the existing range rather than moving it, which is a command that exits 0 having
353
+ * changed nothing — the J46-4 defect, arrived at through npm's semantics rather than through
354
+ * this code's.
355
+ */
356
+ export function upgradeCommand(environment) {
357
+ if (environment.global)
358
+ return ['npm', 'install', '--global', `${PACKAGE}@latest`];
359
+ return ['npm', 'install', '--prefix', environment.root, `${PACKAGE}@latest`];
360
+ }
361
+ /**
362
+ * Run the installer, capture what it said, return both. The only side effect in this module.
363
+ *
364
+ * CAPTURED RATHER THAN INHERITED so the report has one shape whether or not anybody is
365
+ * watching, and so a test can inject a substitute and assert on the command WITHOUT a real
366
+ * install ever running.
367
+ *
368
+ * THE ONE PLACE THIS IS NOT THE REFERENCE'S BEHAVIOUR: `stderr=STDOUT` genuinely interleaves
369
+ * the two streams in the order they happened, and `spawnSync` has no fd-dup, so the two pipes
370
+ * are concatenated instead — stdout, then stderr. npm writes its progress to stderr and its
371
+ * result to stdout, so an operator reading a failure still gets both, in two blocks rather than
372
+ * one. The installer's own words are never compared across runtimes; the sentences around them
373
+ * are.
374
+ *
375
+ * `shell: true` ON WINDOWS ONLY, the idiom `hostinstall.installViaClaudeCli` established for
376
+ * the same reason: npm is a `npm.cmd` batch shim there and `spawnSync` returns ENOENT for a
377
+ * batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
378
+ */
379
+ export function runInstaller(command) {
380
+ const [program, ...args] = command;
381
+ const win = process.platform === 'win32';
382
+ const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
383
+ const done = spawnSync(program ?? '', quoted, { encoding: 'utf8', shell: win });
384
+ if (done.error) {
385
+ // A missing `npm` is not a stack trace: it is a command that failed, and `COMMAND_FAILED`
386
+ // is the sentence that says so and hands back what went wrong. 127 is the shell's own
387
+ // code for "command not found", so the number means something to the person reading it.
388
+ return [127, done.error.message];
389
+ }
390
+ return [done.status ?? 1, `${done.stdout ?? ''}${done.stderr ?? ''}`];
391
+ }
392
+ /**
393
+ * A dotted version as something orderable, deterministically, without claiming PEP 440.
394
+ *
395
+ * THE REFERENCE'S RULE, REPRODUCED EXACTLY, INCLUDING THE PART THAT IS WRONG. Split on `.`; a
396
+ * component that is all digits sorts as a NUMBER, anything else sorts as a STRING after every
397
+ * number in that position. So `0.9.0 < 0.10.0` — the thing a plain string compare gets wrong,
398
+ * and the reason this exists — and `0.31.0 < 0.31.0rc1`, which is WRONG BY SEMVER AND BY PEP
399
+ * 440 and is kept anyway.
400
+ *
401
+ * A CORRECT COMPARISON HERE WOULD BE A DIVERGENCE, NOT AN IMPROVEMENT. `_version_key`'s
402
+ * docstring in the reference gives the reason: bantamkit has never published a prerelease to
403
+ * either registry, and implementing PEP 440 there in order to reproduce it here would be a
404
+ * second, larger thing to keep byte-identical in service of a case neither index can currently
405
+ * return. What matters is that both runtimes are wrong in the SAME direction, which a
406
+ * conformance case can pin and a reader can check. If a prerelease is ever published, this is
407
+ * the function to fix — in both runtimes, in one job.
408
+ *
409
+ * `/^\d+$/` is ASCII-only, where CPython's `str.isdigit()` is not. That narrowing is the safe
410
+ * direction: the strings `str.isdigit()` accepts and `int()` then REFUSES (superscripts, for
411
+ * one) raise in the reference and sort as strings here, and no registry can answer with one.
412
+ */
413
+ function versionKey(version) {
414
+ return version.split('.').map((part) => (/^\d+$/.test(part) ? [0, Number(part), ''] : [1, 0, part]));
415
+ }
416
+ const PAD = [0, 0, ''];
417
+ function comparePart(left, right) {
418
+ if (left[0] !== right[0])
419
+ return left[0] < right[0] ? -1 : 1;
420
+ if (left[1] !== right[1])
421
+ return left[1] < right[1] ? -1 : 1;
422
+ if (left[2] === right[2])
423
+ return 0;
424
+ return left[2] < right[2] ? -1 : 1;
425
+ }
426
+ /**
427
+ * -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
428
+ *
429
+ * The shorter of the two is padded with numeric zeros, so `0.30` and `0.30.0` agree — which is
430
+ * what a person means by them and what both registries would print for one release.
431
+ */
432
+ export function compareVersions(installed, latest) {
433
+ const left = versionKey(installed);
434
+ const right = versionKey(latest);
435
+ const width = Math.max(left.length, right.length);
436
+ for (let i = 0; i < width; i += 1) {
437
+ const order = comparePart(left[i] ?? PAD, right[i] ?? PAD);
438
+ if (order !== 0)
439
+ return order;
440
+ }
441
+ return 0;
442
+ }
443
+ /**
444
+ * `10` rather than `10.0`, because the sentence is read by a person, not parsed.
445
+ *
446
+ * The reference spells this out so that Node's default rendering of a whole number is what
447
+ * both sides print, which is what keeps `TIMED_OUT` byte-identical instead of ruled. Here it
448
+ * is the identity function on a number, and it exists so the pairing is visible from both
449
+ * files rather than only from one.
450
+ */
451
+ export function seconds(timeout) {
452
+ return String(timeout);
453
+ }
454
+ /**
455
+ * The whole flag: ask the index, compare, and act — or refuse, by name.
456
+ *
457
+ * THE TWO SEAMS ARE `fetch` AND `installer`, and they are options with real defaults rather
458
+ * than module bindings a test rewrites. A test that reached the registry would fail on a plane
459
+ * and pass for the wrong reason off a cache; a test that ran a real installer would rewrite the
460
+ * developer's own `node_modules` mid-suite. Both substitutes are handed in here, so every arm
461
+ * below is reachable offline and the npm command is CAPTURED, never run.
462
+ *
463
+ * THE INDEX IS ASKED BEFORE THE SHAPE IS JUDGED, and that order is the reference's and is
464
+ * deliberate. The user asked the flag to go and check the latest version; an operator on a
465
+ * checkout five releases behind is owed that number even though this flag will not be the thing
466
+ * that installs it. The shape decides the ACTION, never whether the question is asked.
467
+ */
468
+ export async function update(installed, origin, options = {}) {
469
+ const ask = options.fetch ?? fetchIndex;
470
+ const installer = options.installer ?? runInstaller;
471
+ const url = options.url ?? INDEX_URL;
472
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
473
+ const environment = options.environment ?? installEnvironment();
474
+ let body;
475
+ try {
476
+ body = await ask(url, timeout);
477
+ }
478
+ catch (e) {
479
+ // ORDER IS LOAD-BEARING: `IndexTimeout` IS an `Error`, exactly as `TimeoutError` IS an
480
+ // `OSError` in the reference. Swap these two and every timeout reads as "could not be
481
+ // reached", which names the wrong problem and nobody notices.
482
+ if (e instanceof IndexTimeout) {
483
+ throw new UpdateRefused(fill(TIMED_OUT, { timeout: seconds(timeout) }));
484
+ }
485
+ if (e instanceof Error)
486
+ throw new UpdateRefused(fill(UNREACHABLE, { reason: e.message }));
487
+ throw e;
488
+ }
489
+ const latest = latestFromIndexPayload(body);
490
+ const header = fill(COMPARISON, { program: PROGRAM, installed, latest });
491
+ const order = compareVersions(installed, latest);
492
+ if (order === 0)
493
+ return `${header}\n${UP_TO_DATE}`;
494
+ if (order > 0)
495
+ return `${header}\n${AHEAD}`;
496
+ const command = upgradeCommand(environment);
497
+ const rendered = shlexJoin(command);
498
+ if (origin.shape !== 'registry') {
499
+ // An unknown shape word is a fact about THIS code being behind `INSTALL_SHAPES`, not about
500
+ // the operator's machine, and inventing a route for it would be the guess the whole
501
+ // install-shape surface refuses to make.
502
+ const route = ROUTES[origin.shape] ?? NO_RECORDED_ROUTE;
503
+ throw new UpdateRefused(fill(NO_ROUTE, {
504
+ program: PROGRAM,
505
+ installed,
506
+ latest,
507
+ shape: origin.shape,
508
+ route: fill(route, {
509
+ source: origin.source,
510
+ package: PACKAGE,
511
+ command: rendered,
512
+ installed,
513
+ latest,
514
+ shape: origin.shape,
515
+ }),
516
+ }));
517
+ }
518
+ const [code, output] = installer(command);
519
+ const printed = output.trim() === '' ? NO_OUTPUT : output.trim();
520
+ if (code !== 0) {
521
+ throw new UpdateRefused(fill(COMMAND_FAILED, {
522
+ code,
523
+ command: rendered,
524
+ program: PROGRAM,
525
+ installed,
526
+ printed: PRINTED,
527
+ output: printed,
528
+ }));
529
+ }
530
+ return [
531
+ header,
532
+ fill(UPDATING, { command: rendered }),
533
+ PRINTED,
534
+ printed,
535
+ fill(UPDATED, { program: PROGRAM, installed, latest }),
536
+ fill(RESTART, { program: PROGRAM, installed }),
537
+ ].join('\n');
538
+ }
539
+ /**
540
+ * `--update` on the CLI: ask the package index, act on the answer, print what happened, exit.
541
+ *
542
+ * THE SENTENCES ARE NOT HERE. This function owns three things and nothing else: where the
543
+ * shape comes from, which stream each outcome is written to, and the exit code.
544
+ *
545
+ * STDOUT + EXIT 0 IS ONLY FOR AN ANSWER THAT IS ALREADY TRUE — up to date, the index behind,
546
+ * or an update that actually ran. Everything else is `error: <sentence>` on stderr and exit 1,
547
+ * including the install shapes this flag will not touch: an operator who typed `--update`
548
+ * asked for an update, and a command that exits 0 having changed nothing is the J46-4 defect
549
+ * by name. Exit 1 rather than 2 because argparse already owns 2 for a usage error.
550
+ *
551
+ * THE SHAPE IS `currentInstall()`'s ANSWER AND NOT A SECOND DETECTOR. J46-12 (`c9372ca`)
552
+ * shipped it as a module-level function taking no arguments and touching no server state,
553
+ * explicitly so this flag could ask it before a store or a transport exists.
554
+ *
555
+ * `Undetermined` — a `git+https://…` origin, which is neither an index nor a path on this
556
+ * machine — is a refusal and not a fallback. Its own message already names the route, so it is
557
+ * quoted rather than paraphrased.
558
+ *
559
+ * THE WRITERS ARE ARGUMENTS so this arm is testable without a subprocess and without rewriting
560
+ * `process.stdout`. `cli.ts` hands in the real two. Both receive a UTF-8 string, which is LF
561
+ * on every platform — the same reason the reference goes through `sys.stdout.buffer`, where
562
+ * `print` would emit CRLF on Windows and a byte-comparing conformance runner would read that
563
+ * as a divergence belonging to the writer rather than to the product.
564
+ */
565
+ export async function runUpdate(out, err, installed, packageDirectory, options = {}) {
566
+ let shape;
567
+ let recorded;
568
+ try {
569
+ const install = currentInstall();
570
+ shape = install.shape;
571
+ recorded = install.source;
572
+ }
573
+ catch (e) {
574
+ if (!(e instanceof Undetermined))
575
+ throw e;
576
+ err(`error: ${fill(SHAPE_UNKNOWN, { reason: e.message })}\n`);
577
+ return 1;
578
+ }
579
+ // `source` is `null` for the shapes that HAVE no recorded origin rather than for one whose
580
+ // path could not be read — `registry`, where the route is this flag itself, and `checkout`,
581
+ // whose tree IS the thing to update. The running package directory is not a guess: it is
582
+ // where the code being executed lives, and it is the tree the route says to `git pull`.
583
+ const origin = { shape, source: recorded ?? packageDirectory };
584
+ try {
585
+ out(`${await update(installed, origin, options)}\n`);
586
+ }
587
+ catch (e) {
588
+ if (!(e instanceof UpdateRefused))
589
+ throw e;
590
+ err(`error: ${e.message}\n`);
591
+ return 1;
592
+ }
593
+ return 0;
594
+ }
@@ -35,9 +35,11 @@ export declare function clockIn(checkpoint: string): PyValue;
35
35
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
36
36
  *
37
37
  * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
38
- * never pick-a-unit. Mutations: set the unit's status, advance `plan.cursor` to the first
39
- * non-terminal unit in PLAN order (`depends_on` is ignored), shallow-merge `handoffPatch`
40
- * into `handoff`, push `historyEntry` onto the 5-entry ring.
38
+ * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
39
+ * of that role's models, spelled exactly; a wrong or missing model is refused here, before
40
+ * any mutation and before the accounting line. Mutations: set the unit's status, advance
41
+ * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
42
+ * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
41
43
  *
42
44
  * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
43
45
  * the full schema before the first byte leaves; a failure returns `refused to write: …` and