bantamkit-mcp 0.35.6 → 0.35.8
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/README.md +11 -7
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/cli.js +28 -0
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +14 -1
- package/dist/eventlog.js +23 -3
- package/dist/hookadapter.js +260 -45
- package/dist/hostinstall.js +250 -79
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +18 -27
- package/dist/mcp/status.js +11 -4
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +1 -1
package/dist/selfupdate.d.ts
CHANGED
|
@@ -7,13 +7,32 @@ export { compareVersions, PACKAGE, runInstaller, shlexJoin };
|
|
|
7
7
|
*/
|
|
8
8
|
export declare const PROGRAM = "bantamkit-mcp";
|
|
9
9
|
/**
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* The command that asks the index what `latest` is — npm itself, never a URL of this module's
|
|
11
|
+
* own. Divergent by construction (the reference asks pip).
|
|
12
|
+
*
|
|
13
|
+
* WHY npm AND NOT `fetch` (J65-19, #135). The version check and the `npm install` that follows
|
|
14
|
+
* it must agree about WHICH registry they mean, and only npm knows: `npm_config_registry`, the
|
|
15
|
+
* project and user `.npmrc`, `proxy`/`https-proxy` and the `HTTPS_PROXY` environment, auth
|
|
16
|
+
* tokens for a private mirror, `strict-ssl`, `cafile`. Measured 2026-10-13 (#135): with
|
|
17
|
+
* `npm_config_registry=http://127.0.0.1:4873/` and `~/.npmrc registry=` set, the old `fetch`
|
|
18
|
+
* still asked `https://registry.npmjs.org/bantamkit-mcp/latest`. Node's own `fetch` cannot
|
|
19
|
+
* even honour `HTTPS_PROXY` without a dependency this toolbox refuses to add, so reading the
|
|
20
|
+
* registry out of npm's config and fetching it here would have fixed half the report.
|
|
21
|
+
*
|
|
22
|
+
* `@latest` resolved by npm, `--json` so the answer is the manifest of that dist-tag with
|
|
23
|
+
* `version` at the top level — the same document shape the old URL answered, measured against
|
|
24
|
+
* a loopback registry in `test/selfupdate.test.mjs`, so `latestFromIndexPayload` is unchanged.
|
|
25
|
+
* `--fetch-retries=0` because a registry that refuses the connection takes npm 70 seconds
|
|
26
|
+
* under its default retry schedule (measured, npm 11.6.2), which is seven times this flag's
|
|
27
|
+
* deadline: the operator would read "did not answer within 10 seconds" where the truth is
|
|
28
|
+
* `ECONNREFUSED`, and that truth is what `UNREACHABLE` is for.
|
|
29
|
+
*
|
|
30
|
+
* `--prefix` / `--global` MIRROR `upgradeCommand` EXACTLY, because npm reads the project
|
|
31
|
+
* `.npmrc` relative to the prefix it is given: a `view` asked from the operator's cwd and an
|
|
32
|
+
* `install` pointed at the kept tree could read two different `registry=` lines, which is the
|
|
33
|
+
* disagreement #135 is about.
|
|
15
34
|
*/
|
|
16
|
-
export declare
|
|
35
|
+
export declare function indexCommand(environment: Environment): string[];
|
|
17
36
|
/**
|
|
18
37
|
* Explicit, and short enough that a person who typed the flag does not think it hung.
|
|
19
38
|
* AS-7(3): the network is not on any other path here, so nothing else inherits this.
|
|
@@ -43,6 +62,11 @@ export declare const PRINTED = "the command printed:";
|
|
|
43
62
|
export declare const UPDATED = "updated {program} from {installed} to {latest}.";
|
|
44
63
|
/** AS-7(1), measured. This is the line that makes the success true rather than plausible. */
|
|
45
64
|
export declare const RESTART: string;
|
|
65
|
+
/**
|
|
66
|
+
* `{article}` is `a` or `an` by the shape word's first letter (`article`): "a ephemeral
|
|
67
|
+
* install" shipped in every release up to 0.35.7 (#177), because the template carried the
|
|
68
|
+
* article and the shape was substituted after it.
|
|
69
|
+
*/
|
|
46
70
|
export declare const NO_ROUTE: string;
|
|
47
71
|
export declare const TIMED_OUT: string;
|
|
48
72
|
export declare const UNREACHABLE: string;
|
|
@@ -52,6 +76,18 @@ export declare const NOT_A_VERSION: string;
|
|
|
52
76
|
* and a refusal that swallowed it would send the operator to re-run the command by hand.
|
|
53
77
|
*/
|
|
54
78
|
export declare const COMMAND_FAILED: string;
|
|
79
|
+
/**
|
|
80
|
+
* The installer exited 0 and the install does not report the version it was told to install
|
|
81
|
+
* (J65-19, #128). The same shape as `COMMAND_FAILED` — the command on line 1, the installer's
|
|
82
|
+
* own words last — because it is the same kind of failure read off the disk instead of off
|
|
83
|
+
* the exit code. Measured 2026-10-13 (#128): with the index stubbed to 9.9.9 and an `npm`
|
|
84
|
+
* that recorded its argv and changed nothing, 0.35.7 printed `updated bantamkit-mcp from 0.35.7
|
|
85
|
+
* to 9.9.9.` and exited 0 with 0.35.7 still on disk. `{found}` is the version the manifest
|
|
86
|
+
* reports now, or `NO_INSTALLED_VERSION` when there is no readable manifest at all.
|
|
87
|
+
*/
|
|
88
|
+
export declare const NOT_INSTALLED: string;
|
|
89
|
+
/** What `{found}` reads when no manifest could be read back after the installer ran. */
|
|
90
|
+
export declare const NO_INSTALLED_VERSION = "no readable version";
|
|
55
91
|
/**
|
|
56
92
|
* The install shape itself could not be derived — `identity.Undetermined`, which is what an
|
|
57
93
|
* origin that is neither an index nor a path on this machine raises (`git+https://…`). That
|
|
@@ -70,7 +106,13 @@ export declare const NO_VERSION_FIELD = "the response carries no version string"
|
|
|
70
106
|
* The fallthrough for a shape word `ROUTES` has never heard of — `INSTALL_SHAPES` growing a
|
|
71
107
|
* sixth word. Not divergent: it names no package manager, so it is the reference's sentence.
|
|
72
108
|
*/
|
|
73
|
-
export declare const NO_RECORDED_ROUTE = "There is no recorded update route for
|
|
109
|
+
export declare const NO_RECORDED_ROUTE = "There is no recorded update route for {article} {shape} install, so --update will not guess one.";
|
|
110
|
+
/**
|
|
111
|
+
* `a` or `an` for the word that follows, by its first letter — the rule English applies to
|
|
112
|
+
* `ephemeral` and the reference's `_article` applies byte for byte. A letter test and not a
|
|
113
|
+
* table, because `INSTALL_SHAPES` can grow a word this file has never seen.
|
|
114
|
+
*/
|
|
115
|
+
export declare function article(word: string): string;
|
|
74
116
|
/**
|
|
75
117
|
* Per shape, what the real update route is — the answer to "then how DO I update this?".
|
|
76
118
|
* DIVERGENT BY CONSTRUCTION and registered in `docs/porting.md` by J46-31: the reference's
|
|
@@ -167,8 +209,28 @@ export interface Origin {
|
|
|
167
209
|
export interface Environment {
|
|
168
210
|
readonly root: string;
|
|
169
211
|
readonly global: boolean;
|
|
212
|
+
/**
|
|
213
|
+
* Whether the prefix tree's own `package.json` pins this package EXACTLY (`"0.35.7"`, not
|
|
214
|
+
* `"^0.35.7"`). npm rewrites an exact pin to a caret range on `npm install <pkg>@<v>` unless
|
|
215
|
+
* told `--save-exact` (observed in #128), so `upgradeCommand` carries the flag when the pin
|
|
216
|
+
* was exact and the operator's record keeps its shape. Absent or `false` otherwise.
|
|
217
|
+
*/
|
|
218
|
+
readonly exact?: boolean;
|
|
170
219
|
}
|
|
171
220
|
export declare function installEnvironment(runningFile?: string): Environment;
|
|
221
|
+
/**
|
|
222
|
+
* The version the tree `--update` just pointed npm at reports NOW, read off its manifest, or
|
|
223
|
+
* `null` when there is no readable one. The default `installedVersion` seam (J65-19, #128):
|
|
224
|
+
* what the success line is allowed to say is what this returns, never what the index said.
|
|
225
|
+
*/
|
|
226
|
+
export declare function readInstalledVersion(environment: Environment): string | null;
|
|
227
|
+
/**
|
|
228
|
+
* The package root above a running `dist/` — the directory whose `package.json` is the one this
|
|
229
|
+
* build reads its version from — or `packageDirectory` itself when no manifest sits above it.
|
|
230
|
+
* The `checkout` route names "a source tree", and `<tree>/dist` is that tree's build output, not
|
|
231
|
+
* the tree (#177): `cli.ts` hands in `dirname(import.meta.url)`, which is `dist/`.
|
|
232
|
+
*/
|
|
233
|
+
export declare function packageRoot(packageDirectory: string): string;
|
|
172
234
|
/**
|
|
173
235
|
* `str.format` over the one substitution shape these sentences use — `{name}` and nothing else.
|
|
174
236
|
*
|
|
@@ -190,25 +252,29 @@ export declare function fill(template: string, values: Readonly<Record<string, s
|
|
|
190
252
|
*/
|
|
191
253
|
export declare function latestFromIndexPayload(text: string): string;
|
|
192
254
|
/**
|
|
193
|
-
* The ONLY network
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* `
|
|
203
|
-
* `
|
|
204
|
-
*
|
|
205
|
-
* the
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
255
|
+
* The ONLY path to the network in this runtime, and it goes through npm. One command, one
|
|
256
|
+
* deadline, two failure words.
|
|
257
|
+
*
|
|
258
|
+
* NOT THE PLATFORM'S `fetch` ANY MORE (J65-19, #135): see `indexCommand` for why the question
|
|
259
|
+
* is asked of npm. No new dependency either — `spawnCommand` is the spawner `--install` and the
|
|
260
|
+
* installer below already use. The deadline is `spawnSync`'s own `timeout`: npm is killed when
|
|
261
|
+
* it passes and the result carries `ETIMEDOUT`, which is the one fact that becomes
|
|
262
|
+
* `IndexTimeout`. Measured in `test/selfupdate.test.mjs` with a command that sleeps.
|
|
263
|
+
*
|
|
264
|
+
* THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Everything that is
|
|
265
|
+
* not the deadline is a plain `Error` whose message is the sentence worth printing: with
|
|
266
|
+
* `--json`, npm writes its failure to STDOUT as `{"error": {"code", "summary", "detail"}}`
|
|
267
|
+
* (measured, npm 11.6.2: `E404` for a package the mirror does not carry, `ECONNREFUSED` with
|
|
268
|
+
* `request to http://127.0.0.1:1/bantamkit-mcp failed, reason: connect ECONNREFUSED` for a
|
|
269
|
+
* dead one), so `summary` is taken first — it names the URL npm actually asked, which is the
|
|
270
|
+
* configured registry and the thing an operator behind a mirror needs to see — then `code`,
|
|
271
|
+
* then the first line npm wrote to stderr, then the bare exit status. A missing `npm` is the
|
|
272
|
+
* spawn error's own message (`spawnSync npm ENOENT`).
|
|
273
|
+
*
|
|
274
|
+
* Nothing here parses a version: a body that is not the manifest is not a version either and
|
|
275
|
+
* is refused BY NAME in `latestFromIndexPayload`, through the real parser.
|
|
210
276
|
*/
|
|
211
|
-
export declare function fetchIndex(
|
|
277
|
+
export declare function fetchIndex(command: readonly string[], timeout?: number): Promise<string>;
|
|
212
278
|
/**
|
|
213
279
|
* What a `registry` install upgrades with, ON THE TREE IT IS ACTUALLY IN.
|
|
214
280
|
*
|
|
@@ -221,12 +287,20 @@ export declare function fetchIndex(url?: string, timeout?: number): Promise<stri
|
|
|
221
287
|
* no `package.json` beside `node_modules`, `npm install --prefix` prunes every sibling package.
|
|
222
288
|
* Both arms are the rows `README.md#updating` already documents.
|
|
223
289
|
*
|
|
224
|
-
* `@latest`
|
|
225
|
-
* satisfies the existing range rather than moving it
|
|
226
|
-
* changed nothing
|
|
227
|
-
*
|
|
290
|
+
* `@<latest>` AND NOT `@latest` (J65-19, #128). A bare `npm install <pkg>` in a tree that
|
|
291
|
+
* already declares `<pkg>` satisfies the existing range rather than moving it — a command that
|
|
292
|
+
* exits 0 having changed nothing, the J46-4 defect through npm's semantics. `@latest` fixed that
|
|
293
|
+
* and left a second hole: npm resolves the dist-tag AGAIN, at install time, against whatever
|
|
294
|
+
* registry it is configured for, so a mirror behind the index, or a tag moved between the two
|
|
295
|
+
* calls, installs something other than the version line 1 of the report compared — and the
|
|
296
|
+
* success line was printed from the compared number regardless. The spec now names the version
|
|
297
|
+
* this flag compared, and `update` reads the manifest back before it says `updated`.
|
|
298
|
+
*
|
|
299
|
+
* `--save-exact` WHEN THE PIN WAS EXACT: npm rewrites `"0.35.7"` to `"^0.35.7"` on this
|
|
300
|
+
* command unless told otherwise (observed in #128), and an operator who pinned exactly did so
|
|
301
|
+
* on purpose. The kept install `--install` makes carries npm's own caret, so it keeps it.
|
|
228
302
|
*/
|
|
229
|
-
export declare function upgradeCommand(environment: Environment): string[];
|
|
303
|
+
export declare function upgradeCommand(environment: Environment, latest: string): string[];
|
|
230
304
|
/**
|
|
231
305
|
* `10` rather than `10.0`, because the sentence is read by a person, not parsed.
|
|
232
306
|
*
|
|
@@ -237,9 +311,15 @@ export declare function upgradeCommand(environment: Environment): string[];
|
|
|
237
311
|
*/
|
|
238
312
|
export declare function seconds(timeout: number): string;
|
|
239
313
|
export interface UpdateOptions {
|
|
240
|
-
|
|
314
|
+
/** The index request: handed the command `indexCommand` built and the timeout, answers the body. */
|
|
315
|
+
readonly fetch?: (command: string[], timeout: number) => Promise<string> | string;
|
|
241
316
|
readonly installer?: (command: string[]) => [number, string];
|
|
242
|
-
|
|
317
|
+
/**
|
|
318
|
+
* What the tree reports AFTER the installer exited 0 — `readInstalledVersion` by default. A
|
|
319
|
+
* seam so a test can hand in the manifest npm would have written without an npm, and the
|
|
320
|
+
* `--update` conformance arms can drive the verify failure on both runtimes.
|
|
321
|
+
*/
|
|
322
|
+
readonly installedVersion?: (environment: Environment) => string | null;
|
|
243
323
|
readonly timeout?: number;
|
|
244
324
|
readonly environment?: Environment;
|
|
245
325
|
/**
|
package/dist/selfupdate.js
CHANGED
|
@@ -11,13 +11,20 @@
|
|
|
11
11
|
* EXACTLY THREE THINGS DIFFER FROM THE REFERENCE, and each is a genuinely different object
|
|
12
12
|
* rather than a different spelling of one object:
|
|
13
13
|
*
|
|
14
|
-
* 1. THE
|
|
15
|
-
*
|
|
16
|
-
* latest`
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* 1. THE QUESTION ASKED OF THE INDEX, AND THE JSON PATH. Until J65-19 (#135) each runtime
|
|
15
|
+
* fetched one hard-coded URL itself — `pypi.org/pypi/bantamkit/json` with the version at
|
|
16
|
+
* `info.version`, `registry.npmjs.org/bantamkit-mcp/latest` with it at the top level —
|
|
17
|
+
* so `npm_config_registry`, `.npmrc` and a mandatory proxy were honoured by the
|
|
18
|
+
* `npm install` this flag then ran and ignored by the version check in front of it.
|
|
19
|
+
* Now each side asks ITS OWN PACKAGE MANAGER what the index holds, under whatever
|
|
20
|
+
* registry, proxy and auth config that manager already reads: `npm view bantamkit-mcp@latest
|
|
21
|
+
* --json` here (the manifest of the `latest` dist-tag, `version` at the top level;
|
|
22
|
+
* measured against a loopback registry, `test/selfupdate.test.mjs`), and a `pip install
|
|
23
|
+
* --dry-run --report -` there (the version at `install[0].metadata.version`). See
|
|
24
|
+
* `indexCommand` and `fetchIndex`.
|
|
25
|
+
* 2. THE COMMAND. `pip install --upgrade bantamkit==<latest>` there; `npm install
|
|
26
|
+
* bantamkit-mcp@<latest>` here, and WHICH `npm install` is a measured question — see
|
|
27
|
+
* `upgradeCommand`. Both name the version they compared (J65-19, #128), never `latest`.
|
|
21
28
|
* 3. THE FOUR `ROUTES` SENTENCES, which name those commands and the things they update.
|
|
22
29
|
* `ephemeral`'s is not a translation of the reference's and must not be made into one:
|
|
23
30
|
* see the note on that row.
|
|
@@ -44,7 +51,7 @@ import { readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'n
|
|
|
44
51
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
45
52
|
import { fileURLToPath } from 'node:url';
|
|
46
53
|
import { currentInstall, Undetermined } from './mcp/identity.js';
|
|
47
|
-
import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
|
|
54
|
+
import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin, spawnCommand, } from './npminstall.js';
|
|
48
55
|
import { KEY as RECORD_KEY, loadRecord, recordPath, SOURCE_RECORD } from './updatecheck.js';
|
|
49
56
|
// `PACKAGE`, `shlexJoin` and `runInstaller` moved to `npminstall.ts` in job51 so `--install` can
|
|
50
57
|
// share the npm spawner without importing this module (see that file's header). Re-exported so
|
|
@@ -59,13 +66,38 @@ export { compareVersions, PACKAGE, runInstaller, shlexJoin };
|
|
|
59
66
|
*/
|
|
60
67
|
export const PROGRAM = 'bantamkit-mcp';
|
|
61
68
|
/**
|
|
62
|
-
* The
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
69
|
+
* The command that asks the index what `latest` is — npm itself, never a URL of this module's
|
|
70
|
+
* own. Divergent by construction (the reference asks pip).
|
|
71
|
+
*
|
|
72
|
+
* WHY npm AND NOT `fetch` (J65-19, #135). The version check and the `npm install` that follows
|
|
73
|
+
* it must agree about WHICH registry they mean, and only npm knows: `npm_config_registry`, the
|
|
74
|
+
* project and user `.npmrc`, `proxy`/`https-proxy` and the `HTTPS_PROXY` environment, auth
|
|
75
|
+
* tokens for a private mirror, `strict-ssl`, `cafile`. Measured 2026-10-13 (#135): with
|
|
76
|
+
* `npm_config_registry=http://127.0.0.1:4873/` and `~/.npmrc registry=` set, the old `fetch`
|
|
77
|
+
* still asked `https://registry.npmjs.org/bantamkit-mcp/latest`. Node's own `fetch` cannot
|
|
78
|
+
* even honour `HTTPS_PROXY` without a dependency this toolbox refuses to add, so reading the
|
|
79
|
+
* registry out of npm's config and fetching it here would have fixed half the report.
|
|
80
|
+
*
|
|
81
|
+
* `@latest` resolved by npm, `--json` so the answer is the manifest of that dist-tag with
|
|
82
|
+
* `version` at the top level — the same document shape the old URL answered, measured against
|
|
83
|
+
* a loopback registry in `test/selfupdate.test.mjs`, so `latestFromIndexPayload` is unchanged.
|
|
84
|
+
* `--fetch-retries=0` because a registry that refuses the connection takes npm 70 seconds
|
|
85
|
+
* under its default retry schedule (measured, npm 11.6.2), which is seven times this flag's
|
|
86
|
+
* deadline: the operator would read "did not answer within 10 seconds" where the truth is
|
|
87
|
+
* `ECONNREFUSED`, and that truth is what `UNREACHABLE` is for.
|
|
88
|
+
*
|
|
89
|
+
* `--prefix` / `--global` MIRROR `upgradeCommand` EXACTLY, because npm reads the project
|
|
90
|
+
* `.npmrc` relative to the prefix it is given: a `view` asked from the operator's cwd and an
|
|
91
|
+
* `install` pointed at the kept tree could read two different `registry=` lines, which is the
|
|
92
|
+
* disagreement #135 is about.
|
|
67
93
|
*/
|
|
68
|
-
export
|
|
94
|
+
export function indexCommand(environment) {
|
|
95
|
+
return ['npm', 'view', ...npmLocation(environment), `${PACKAGE}@latest`, '--json', '--fetch-retries=0'];
|
|
96
|
+
}
|
|
97
|
+
/** The two words that point npm at the tree `--update` is about: `--global`, or `--prefix <root>`. */
|
|
98
|
+
function npmLocation(environment) {
|
|
99
|
+
return environment.global ? ['--global'] : ['--prefix', environment.root];
|
|
100
|
+
}
|
|
69
101
|
/**
|
|
70
102
|
* Explicit, and short enough that a person who typed the flag does not think it hung.
|
|
71
103
|
* AS-7(3): the network is not on any other path here, so nothing else inherits this.
|
|
@@ -106,8 +138,13 @@ export const RESTART = 'restart the server: a running {program} keeps serving th
|
|
|
106
138
|
// `--update` asked for an update, and a command that exits 0 having changed nothing is the
|
|
107
139
|
// J46-4 defect. Exit 1 and not 2, because argparse already owns 2 for a usage error and
|
|
108
140
|
// `--update` is not one.
|
|
109
|
-
|
|
110
|
-
|
|
141
|
+
/**
|
|
142
|
+
* `{article}` is `a` or `an` by the shape word's first letter (`article`): "a ephemeral
|
|
143
|
+
* install" shipped in every release up to 0.35.7 (#177), because the template carried the
|
|
144
|
+
* article and the shape was substituted after it.
|
|
145
|
+
*/
|
|
146
|
+
export const NO_ROUTE = '{program} {installed} is installed and the package index has {latest}, but this is ' +
|
|
147
|
+
'{article} {shape} install, which --update will not touch. {route}';
|
|
111
148
|
export const TIMED_OUT = 'the package index did not answer within {timeout} seconds; --update needs the network, ' +
|
|
112
149
|
'and nothing was changed.';
|
|
113
150
|
export const UNREACHABLE = 'the package index could not be reached: {reason}; --update needs the network, and ' +
|
|
@@ -122,6 +159,21 @@ export const COMMAND_FAILED = 'the update command exited {code}: {command}\n' +
|
|
|
122
159
|
'{program} {installed} is still installed; nothing was changed.\n' +
|
|
123
160
|
'{printed}\n' +
|
|
124
161
|
'{output}';
|
|
162
|
+
/**
|
|
163
|
+
* The installer exited 0 and the install does not report the version it was told to install
|
|
164
|
+
* (J65-19, #128). The same shape as `COMMAND_FAILED` — the command on line 1, the installer's
|
|
165
|
+
* own words last — because it is the same kind of failure read off the disk instead of off
|
|
166
|
+
* the exit code. Measured 2026-10-13 (#128): with the index stubbed to 9.9.9 and an `npm`
|
|
167
|
+
* that recorded its argv and changed nothing, 0.35.7 printed `updated bantamkit-mcp from 0.35.7
|
|
168
|
+
* to 9.9.9.` and exited 0 with 0.35.7 still on disk. `{found}` is the version the manifest
|
|
169
|
+
* reports now, or `NO_INSTALLED_VERSION` when there is no readable manifest at all.
|
|
170
|
+
*/
|
|
171
|
+
export const NOT_INSTALLED = 'the update command exited 0 but did not install {latest}: {command}\n' +
|
|
172
|
+
'{program} now reports {found}, not {latest}; the tree the command names did not take the update.\n' +
|
|
173
|
+
'{printed}\n' +
|
|
174
|
+
'{output}';
|
|
175
|
+
/** What `{found}` reads when no manifest could be read back after the installer ran. */
|
|
176
|
+
export const NO_INSTALLED_VERSION = 'no readable version';
|
|
125
177
|
/**
|
|
126
178
|
* The install shape itself could not be derived — `identity.Undetermined`, which is what an
|
|
127
179
|
* origin that is neither an index nor a path on this machine raises (`git+https://…`). That
|
|
@@ -141,7 +193,15 @@ export const NO_VERSION_FIELD = 'the response carries no version string';
|
|
|
141
193
|
* The fallthrough for a shape word `ROUTES` has never heard of — `INSTALL_SHAPES` growing a
|
|
142
194
|
* sixth word. Not divergent: it names no package manager, so it is the reference's sentence.
|
|
143
195
|
*/
|
|
144
|
-
export const NO_RECORDED_ROUTE = 'There is no recorded update route for
|
|
196
|
+
export const NO_RECORDED_ROUTE = 'There is no recorded update route for {article} {shape} install, so --update will not guess one.';
|
|
197
|
+
/**
|
|
198
|
+
* `a` or `an` for the word that follows, by its first letter — the rule English applies to
|
|
199
|
+
* `ephemeral` and the reference's `_article` applies byte for byte. A letter test and not a
|
|
200
|
+
* table, because `INSTALL_SHAPES` can grow a word this file has never seen.
|
|
201
|
+
*/
|
|
202
|
+
export function article(word) {
|
|
203
|
+
return /^[aeiou]/i.test(word) ? 'an' : 'a';
|
|
204
|
+
}
|
|
145
205
|
/**
|
|
146
206
|
* Per shape, what the real update route is — the answer to "then how DO I update this?".
|
|
147
207
|
* DIVERGENT BY CONSTRUCTION and registered in `docs/porting.md` by J46-31: the reference's
|
|
@@ -213,18 +273,72 @@ export function installEnvironment(runningFile = fileURLToPath(import.meta.url))
|
|
|
213
273
|
if (basename(here) === 'node_modules') {
|
|
214
274
|
const root = dirname(here);
|
|
215
275
|
let owned = false;
|
|
276
|
+
let exact = false;
|
|
216
277
|
try {
|
|
217
|
-
readFileSync(join(root, 'package.json'));
|
|
278
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
218
279
|
owned = true;
|
|
280
|
+
exact = pinsExactly(manifest);
|
|
219
281
|
}
|
|
220
282
|
catch {
|
|
221
283
|
owned = false;
|
|
222
284
|
}
|
|
223
|
-
return { root, global: !owned };
|
|
285
|
+
return { root, global: !owned, exact };
|
|
286
|
+
}
|
|
287
|
+
const parent = dirname(here);
|
|
288
|
+
if (parent === here)
|
|
289
|
+
return { root: '', global: true, exact: false };
|
|
290
|
+
here = parent;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Does this manifest pin `PACKAGE` to one version — a spec starting with a digit — in any dependency table? */
|
|
294
|
+
function pinsExactly(manifest) {
|
|
295
|
+
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest))
|
|
296
|
+
return false;
|
|
297
|
+
for (const table of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
298
|
+
const deps = manifest[table];
|
|
299
|
+
if (typeof deps !== 'object' || deps === null)
|
|
300
|
+
continue;
|
|
301
|
+
const spec = deps[PACKAGE];
|
|
302
|
+
if (typeof spec === 'string' && /^\d/.test(spec.trim()))
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The version the tree `--update` just pointed npm at reports NOW, read off its manifest, or
|
|
309
|
+
* `null` when there is no readable one. The default `installedVersion` seam (J65-19, #128):
|
|
310
|
+
* what the success line is allowed to say is what this returns, never what the index said.
|
|
311
|
+
*/
|
|
312
|
+
export function readInstalledVersion(environment) {
|
|
313
|
+
if (environment.root === '')
|
|
314
|
+
return null;
|
|
315
|
+
try {
|
|
316
|
+
const manifest = JSON.parse(readFileSync(join(environment.root, 'node_modules', PACKAGE, 'package.json'), 'utf8'));
|
|
317
|
+
return typeof manifest.version === 'string' && manifest.version.trim() !== '' ? manifest.version.trim() : null;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The package root above a running `dist/` — the directory whose `package.json` is the one this
|
|
325
|
+
* build reads its version from — or `packageDirectory` itself when no manifest sits above it.
|
|
326
|
+
* The `checkout` route names "a source tree", and `<tree>/dist` is that tree's build output, not
|
|
327
|
+
* the tree (#177): `cli.ts` hands in `dirname(import.meta.url)`, which is `dist/`.
|
|
328
|
+
*/
|
|
329
|
+
export function packageRoot(packageDirectory) {
|
|
330
|
+
let here = resolve(packageDirectory);
|
|
331
|
+
for (;;) {
|
|
332
|
+
try {
|
|
333
|
+
readFileSync(join(here, 'package.json'));
|
|
334
|
+
return here;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* not here; look one level up */
|
|
224
338
|
}
|
|
225
339
|
const parent = dirname(here);
|
|
226
340
|
if (parent === here)
|
|
227
|
-
return
|
|
341
|
+
return packageDirectory;
|
|
228
342
|
here = parent;
|
|
229
343
|
}
|
|
230
344
|
}
|
|
@@ -272,57 +386,57 @@ export function latestFromIndexPayload(text) {
|
|
|
272
386
|
}
|
|
273
387
|
return version;
|
|
274
388
|
}
|
|
275
|
-
/** The User-Agent's version half, or nothing. A header, never an output — never a refusal. */
|
|
276
|
-
function packageVersion() {
|
|
277
|
-
try {
|
|
278
|
-
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
279
|
-
return typeof manifest.version === 'string' ? manifest.version : '0';
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
return '0';
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
389
|
/**
|
|
286
|
-
* The ONLY network
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* `
|
|
296
|
-
* `
|
|
297
|
-
*
|
|
298
|
-
* the
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
390
|
+
* The ONLY path to the network in this runtime, and it goes through npm. One command, one
|
|
391
|
+
* deadline, two failure words.
|
|
392
|
+
*
|
|
393
|
+
* NOT THE PLATFORM'S `fetch` ANY MORE (J65-19, #135): see `indexCommand` for why the question
|
|
394
|
+
* is asked of npm. No new dependency either — `spawnCommand` is the spawner `--install` and the
|
|
395
|
+
* installer below already use. The deadline is `spawnSync`'s own `timeout`: npm is killed when
|
|
396
|
+
* it passes and the result carries `ETIMEDOUT`, which is the one fact that becomes
|
|
397
|
+
* `IndexTimeout`. Measured in `test/selfupdate.test.mjs` with a command that sleeps.
|
|
398
|
+
*
|
|
399
|
+
* THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Everything that is
|
|
400
|
+
* not the deadline is a plain `Error` whose message is the sentence worth printing: with
|
|
401
|
+
* `--json`, npm writes its failure to STDOUT as `{"error": {"code", "summary", "detail"}}`
|
|
402
|
+
* (measured, npm 11.6.2: `E404` for a package the mirror does not carry, `ECONNREFUSED` with
|
|
403
|
+
* `request to http://127.0.0.1:1/bantamkit-mcp failed, reason: connect ECONNREFUSED` for a
|
|
404
|
+
* dead one), so `summary` is taken first — it names the URL npm actually asked, which is the
|
|
405
|
+
* configured registry and the thing an operator behind a mirror needs to see — then `code`,
|
|
406
|
+
* then the first line npm wrote to stderr, then the bare exit status. A missing `npm` is the
|
|
407
|
+
* spawn error's own message (`spawnSync npm ENOENT`).
|
|
408
|
+
*
|
|
409
|
+
* Nothing here parses a version: a body that is not the manifest is not a version either and
|
|
410
|
+
* is refused BY NAME in `latestFromIndexPayload`, through the real parser.
|
|
303
411
|
*/
|
|
304
|
-
export async function fetchIndex(
|
|
305
|
-
const
|
|
412
|
+
export async function fetchIndex(command, timeout = DEFAULT_TIMEOUT_SECONDS) {
|
|
413
|
+
const done = spawnCommand(command, Math.max(1, Math.round(timeout * 1000)));
|
|
414
|
+
if (done.error !== null) {
|
|
415
|
+
if (done.error.code === 'ETIMEDOUT')
|
|
416
|
+
throw new IndexTimeout(done.error.message);
|
|
417
|
+
throw new Error(done.error.message);
|
|
418
|
+
}
|
|
419
|
+
if (done.status !== 0)
|
|
420
|
+
throw new Error(npmFailureReason(done));
|
|
421
|
+
return done.stdout;
|
|
422
|
+
}
|
|
423
|
+
/** The one line of an npm failure worth printing, in the order `fetchIndex`'s docstring gives. */
|
|
424
|
+
function npmFailureReason(done) {
|
|
306
425
|
try {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
426
|
+
const parsed = JSON.parse(done.stdout);
|
|
427
|
+
const error = parsed?.error;
|
|
428
|
+
if (typeof error === 'object' && error !== null) {
|
|
429
|
+
if (typeof error.summary === 'string' && error.summary.trim() !== '')
|
|
430
|
+
return error.summary.trim();
|
|
431
|
+
if (typeof error.code === 'string' && error.code.trim() !== '')
|
|
432
|
+
return error.code.trim();
|
|
313
433
|
}
|
|
314
|
-
const raw = await response.arrayBuffer();
|
|
315
|
-
return new TextDecoder('utf-8').decode(raw);
|
|
316
434
|
}
|
|
317
|
-
catch
|
|
318
|
-
|
|
319
|
-
throw e;
|
|
320
|
-
if (e instanceof Error && e.name === 'TimeoutError')
|
|
321
|
-
throw new IndexTimeout(e.message);
|
|
322
|
-
const cause = e.cause;
|
|
323
|
-
const detail = cause instanceof Error && cause.message !== '' ? cause.message : null;
|
|
324
|
-
throw new Error(detail ?? (e instanceof Error ? e.message : String(e)));
|
|
435
|
+
catch {
|
|
436
|
+
/* not npm's JSON error shape; fall through to stderr */
|
|
325
437
|
}
|
|
438
|
+
const line = done.stderr.split('\n').map((l) => l.trim()).find((l) => l !== '');
|
|
439
|
+
return line ?? `exit ${done.status ?? 'unknown'}`;
|
|
326
440
|
}
|
|
327
441
|
/**
|
|
328
442
|
* What a `registry` install upgrades with, ON THE TREE IT IS ACTUALLY IN.
|
|
@@ -336,15 +450,22 @@ export async function fetchIndex(url = INDEX_URL, timeout = DEFAULT_TIMEOUT_SECO
|
|
|
336
450
|
* no `package.json` beside `node_modules`, `npm install --prefix` prunes every sibling package.
|
|
337
451
|
* Both arms are the rows `README.md#updating` already documents.
|
|
338
452
|
*
|
|
339
|
-
* `@latest`
|
|
340
|
-
* satisfies the existing range rather than moving it
|
|
341
|
-
* changed nothing
|
|
342
|
-
*
|
|
453
|
+
* `@<latest>` AND NOT `@latest` (J65-19, #128). A bare `npm install <pkg>` in a tree that
|
|
454
|
+
* already declares `<pkg>` satisfies the existing range rather than moving it — a command that
|
|
455
|
+
* exits 0 having changed nothing, the J46-4 defect through npm's semantics. `@latest` fixed that
|
|
456
|
+
* and left a second hole: npm resolves the dist-tag AGAIN, at install time, against whatever
|
|
457
|
+
* registry it is configured for, so a mirror behind the index, or a tag moved between the two
|
|
458
|
+
* calls, installs something other than the version line 1 of the report compared — and the
|
|
459
|
+
* success line was printed from the compared number regardless. The spec now names the version
|
|
460
|
+
* this flag compared, and `update` reads the manifest back before it says `updated`.
|
|
461
|
+
*
|
|
462
|
+
* `--save-exact` WHEN THE PIN WAS EXACT: npm rewrites `"0.35.7"` to `"^0.35.7"` on this
|
|
463
|
+
* command unless told otherwise (observed in #128), and an operator who pinned exactly did so
|
|
464
|
+
* on purpose. The kept install `--install` makes carries npm's own caret, so it keeps it.
|
|
343
465
|
*/
|
|
344
|
-
export function upgradeCommand(environment) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return ['npm', 'install', '--prefix', environment.root, `${PACKAGE}@latest`];
|
|
466
|
+
export function upgradeCommand(environment, latest) {
|
|
467
|
+
const exact = environment.exact === true && !environment.global ? ['--save-exact'] : [];
|
|
468
|
+
return ['npm', 'install', ...npmLocation(environment), ...exact, `${PACKAGE}@${latest}`];
|
|
348
469
|
}
|
|
349
470
|
/**
|
|
350
471
|
* `10` rather than `10.0`, because the sentence is read by a person, not parsed.
|
|
@@ -476,12 +597,12 @@ export function recordUpdate(latest, now) {
|
|
|
476
597
|
export async function update(installed, origin, options = {}) {
|
|
477
598
|
const ask = options.fetch ?? fetchIndex;
|
|
478
599
|
const installer = options.installer ?? runInstaller;
|
|
479
|
-
const
|
|
600
|
+
const installedVersion = options.installedVersion ?? readInstalledVersion;
|
|
480
601
|
const timeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
|
|
481
602
|
const environment = options.environment ?? installEnvironment();
|
|
482
603
|
let body;
|
|
483
604
|
try {
|
|
484
|
-
body = await ask(
|
|
605
|
+
body = await ask(indexCommand(environment), timeout);
|
|
485
606
|
}
|
|
486
607
|
catch (e) {
|
|
487
608
|
// ORDER IS LOAD-BEARING: `IndexTimeout` IS an `Error`, exactly as `TimeoutError` IS an
|
|
@@ -508,7 +629,7 @@ export async function update(installed, origin, options = {}) {
|
|
|
508
629
|
return `${header}\n${UP_TO_DATE}`;
|
|
509
630
|
if (order > 0)
|
|
510
631
|
return `${header}\n${AHEAD}`;
|
|
511
|
-
const command = upgradeCommand(environment);
|
|
632
|
+
const command = upgradeCommand(environment, latest);
|
|
512
633
|
const rendered = shlexJoin(command);
|
|
513
634
|
if (origin.shape !== 'registry') {
|
|
514
635
|
// An unknown shape word is a fact about THIS code being behind `INSTALL_SHAPES`, not about
|
|
@@ -519,6 +640,7 @@ export async function update(installed, origin, options = {}) {
|
|
|
519
640
|
program: PROGRAM,
|
|
520
641
|
installed,
|
|
521
642
|
latest,
|
|
643
|
+
article: article(origin.shape),
|
|
522
644
|
shape: origin.shape,
|
|
523
645
|
route: fill(route, {
|
|
524
646
|
source: origin.source,
|
|
@@ -526,6 +648,7 @@ export async function update(installed, origin, options = {}) {
|
|
|
526
648
|
command: rendered,
|
|
527
649
|
installed,
|
|
528
650
|
latest,
|
|
651
|
+
article: article(origin.shape),
|
|
529
652
|
shape: origin.shape,
|
|
530
653
|
}),
|
|
531
654
|
}));
|
|
@@ -542,6 +665,23 @@ export async function update(installed, origin, options = {}) {
|
|
|
542
665
|
output: printed,
|
|
543
666
|
}));
|
|
544
667
|
}
|
|
668
|
+
// THE SUCCESS LINE IS READ OFF THE DISK, NOT OFF THE INDEX (J65-19, #128). An installer that
|
|
669
|
+
// exited 0 has said nothing about which version it left behind: npm resolves its own spec,
|
|
670
|
+
// against its own registry, and `npm install` in a tree that already satisfies the range is
|
|
671
|
+
// a no-op that exits 0. So the tree's manifest is read back and compared with the version
|
|
672
|
+
// this flag compared, by the same comparator — `0.30` and `0.30.0` are one release — and a
|
|
673
|
+
// mismatch, or no readable manifest at all, is a refusal carrying the installer's words.
|
|
674
|
+
const found = installedVersion(environment);
|
|
675
|
+
if (found === null || compareVersions(found, latest) !== 0) {
|
|
676
|
+
throw new UpdateRefused(fill(NOT_INSTALLED, {
|
|
677
|
+
latest,
|
|
678
|
+
command: rendered,
|
|
679
|
+
program: PROGRAM,
|
|
680
|
+
found: found ?? NO_INSTALLED_VERSION,
|
|
681
|
+
printed: PRINTED,
|
|
682
|
+
output: printed,
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
545
685
|
return [
|
|
546
686
|
header,
|
|
547
687
|
fill(UPDATING, { command: rendered }),
|
|
@@ -593,9 +733,11 @@ export async function runUpdate(out, err, installed, packageDirectory, options =
|
|
|
593
733
|
}
|
|
594
734
|
// `source` is `null` for the shapes that HAVE no recorded origin rather than for one whose
|
|
595
735
|
// path could not be read — `registry`, where the route is this flag itself, and `checkout`,
|
|
596
|
-
// whose tree IS the thing to update. The running package
|
|
597
|
-
//
|
|
598
|
-
|
|
736
|
+
// whose tree IS the thing to update. The running package's ROOT is not a guess: it is the
|
|
737
|
+
// directory whose manifest this build reads its version from, and it is the tree the route
|
|
738
|
+
// says to `git pull`. `packageDirectory` itself is `dist/` — the build output the route says
|
|
739
|
+
// a pull alone does not change — which is why it is walked up from (#177).
|
|
740
|
+
let origin = { shape, source: recorded ?? packageRoot(packageDirectory) };
|
|
599
741
|
let current = installed;
|
|
600
742
|
let chosen = options;
|
|
601
743
|
// J51-5: an `npx` cache WITH a kept install updates the kept install — its version is the one
|