@junghanacs/entwurf 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr-runtime — the Entwurf-owned runtime the Herdr plugin installs against (#116 M3-b1/b2).
|
|
3
|
+
*
|
|
4
|
+
* THIS FILE IS THE ONE OWNER, AND IT SHIPS. Herdr's `plugin uninstall` deletes its managed checkout
|
|
5
|
+
* and calls no cleanup hook, so anything that has to run AFTER that — retiring this runtime, reading
|
|
6
|
+
* its journal, undoing what was activated — cannot live in `plugins/`. It lives in `scripts/`, which
|
|
7
|
+
* `package.json.files` ships, and `plugins/herdr/lib/runtime-bootstrap.mjs` is a thin re-export of
|
|
8
|
+
* this module. One schema, one certified reader, one inverse: a second copy under `plugins/` would
|
|
9
|
+
* be the fork that lets an uninstall disagree with the install that preceded it.
|
|
10
|
+
*
|
|
11
|
+
* A Herdr user who has never heard of npm types one command. What has to exist afterwards is an
|
|
12
|
+
* Entwurf runtime at an address that does not move, because Pi records the owner ROOT it was wired
|
|
13
|
+
* with and a root that changes on every upgrade is a takeover, not an update. That address is
|
|
14
|
+
* `$XDG_DATA_HOME/entwurf/herdr-plugin/runtime/active`, and it is a REAL DIRECTORY — never a
|
|
15
|
+
* symlink whose canonicalisation would turn each version into a different owner root.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT DELIBERATELY DOES NOT DO: PUT ANYTHING ON PATH. An earlier cut exposed bare `entwurf` /
|
|
18
|
+
* `entwurf-bridge` through an owned bin directory. There is no reason a fresh
|
|
19
|
+
* `$XDG_DATA_HOME/…/bin` would be on a clean host's PATH, this module may not edit a shell profile,
|
|
20
|
+
* and it may not write into a standard bin directory it does not own — so that exposure was
|
|
21
|
+
* load-bearing on a condition nothing here can establish. It is retired. The scoped wiring one
|
|
22
|
+
* slice above records ABSOLUTE commands under the stable active root instead; this module's whole
|
|
23
|
+
* job is to make sure those commands exist and run.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE TRANSACTION LOOKS LIKE THIS. Herdr runs `[[build]]` in a TEMPORARY checkout and only
|
|
26
|
+
* afterwards re-reads the manifest, swaps the checkout and registers the plugin (measured
|
|
27
|
+
* 2026-09-16 against herdr 0.9.0 at `c77af189`, `src/cli/plugin.rs:193-230`). So our work happens
|
|
28
|
+
* BEFORE Herdr has committed anything, and Herdr's own rollback covers only its checkout. This
|
|
29
|
+
* module therefore owns exactly one all-or-nothing step — replacing the active runtime — and
|
|
30
|
+
* refuses to describe its success in Herdr's words. It writes `runtime-ready`. It never writes
|
|
31
|
+
* `herdr-installed`, because at the moment it finishes, nobody knows whether Herdr will commit.
|
|
32
|
+
*
|
|
33
|
+
* OWNERSHIP IS PROVEN, NOT ASSUMED. The journal is a CERTIFIED reader's output: exact keys, exact
|
|
34
|
+
* schema version, a phase from the closed set, and a `runtimeRoot` that is this host's stable
|
|
35
|
+
* address. A journal that fails any of those grants no authority to delete or replace anything, and
|
|
36
|
+
* neither does a missing one — a directory sitting at our address with no journal behind it belongs
|
|
37
|
+
* to somebody else until proven otherwise, and this module refuses rather than renaming it away.
|
|
38
|
+
*
|
|
39
|
+
* DISK FACTS COME FROM lstat, AND THEY COME FIRST. `existsSync` follows links, so a dangling symlink
|
|
40
|
+
* at our address reads as "absent" and a symlink to somebody else's tree reads as a directory we may
|
|
41
|
+
* rename. Every one of the three runtime paths is therefore classified as `absent` / `real-dir` /
|
|
42
|
+
* `symlink` / `other`, anything that is not absent-or-a-real-directory is a named refusal, and the
|
|
43
|
+
* whole assessment happens BEFORE the first `mkdir` — a module that creates its own root and then
|
|
44
|
+
* asks whether the root was its own has already answered the question with its own footprint.
|
|
45
|
+
*
|
|
46
|
+
* THE LAST GOOD RUNTIME IS NEVER THROWN AWAY. `active` beside `previous` does not mean `previous` is
|
|
47
|
+
* garbage: it means a swap did not finish, and which of the two is usable is a question only
|
|
48
|
+
* inspection answers. So `previous` is kept until `active` has been inspected as an installed
|
|
49
|
+
* runtime, and when `active` turns out to be corrupt the backup is RESTORED over it. Deleting the
|
|
50
|
+
* backup first and then failing to acquire a candidate is how a host ends a run with nothing usable
|
|
51
|
+
* at all — measured on the first cut of this module, which did exactly that.
|
|
52
|
+
*
|
|
53
|
+
* A TORN SWAP IS A STATE, NOT A MYSTERY. `active → previous`, then `staging → active` is two
|
|
54
|
+
* renames, and a process can die between them. So entry does not blindly clear leftovers: it reads
|
|
55
|
+
* the combination of `active`/`staging`/`previous` against a certified journal and names what it
|
|
56
|
+
* found. The one that matters most is `active` absent with a good `previous` — the crash window —
|
|
57
|
+
* where the previous runtime is RESTORED first, so a candidate that then fails still leaves the
|
|
58
|
+
* host with the runtime it had.
|
|
59
|
+
*
|
|
60
|
+
* WHY THE PACKAGE IS LOCKED TWICE, ON THE npm SOURCE. The exact `name@version` comes from the
|
|
61
|
+
* checkout's own `package.json`; a sibling `runtime-lock.json` repeats it and adds the integrity npm
|
|
62
|
+
* published. The two must agree, and the tarball's OWN sha512 must equal that integrity before
|
|
63
|
+
* anything is installed. The journal records the expected integrity and the observed digest as
|
|
64
|
+
* separate fields, because a digest computed from whatever arrived is a record of what happened —
|
|
65
|
+
* calling it a pin would claim a check that only the comparison performs.
|
|
66
|
+
*
|
|
67
|
+
* WHY THERE IS A SECOND SOURCE, AND WHAT IT MAY NOT BECOME (#116 M3-b3). Requiring a published npm
|
|
68
|
+
* version before a candidate can be installed makes every candidate a release. So the same lock
|
|
69
|
+
* file carries a CLOSED discriminant, and its other branch packs the exact commit Herdr itself
|
|
70
|
+
* checked out — `git+https://github.com/junghan0611/entwurf.git#<full sha>`, a literal remote and a
|
|
71
|
+
* commit read from the checkout, with no URL, ref, env var or caller parameter anywhere in the path.
|
|
72
|
+
* That branch is VERIFICATION-ONLY and it does not weaken the npm branch: there is no fallback
|
|
73
|
+
* between them, the npm integrity comparison is untouched, and a source switch is refused rather
|
|
74
|
+
* than inferred. What replaces the missing registry integrity is named where it happens — the
|
|
75
|
+
* commit pins the tree, the digest records the bytes, and the installed-runtime verifier decides.
|
|
76
|
+
*
|
|
77
|
+
* THE PACK FORM IS PART OF THE CONTRACT. Measured 2026-09-16 (npm 11.16.0): `npm pack <git spec>`
|
|
78
|
+
* runs `prepare` and NOT `prepack`, so the bridge is compiled and no global pnpm is needed, and the
|
|
79
|
+
* tarball is byte-identical across three independent sandboxes including a `--depth 1` clone. `npm
|
|
80
|
+
* pack <directory>` runs `prepack`, which calls `pnpm` and exits 127 on a clean host. Only the git
|
|
81
|
+
* spec is built here; the directory form is structurally absent, not merely avoided.
|
|
82
|
+
*
|
|
83
|
+
* WHY npm's CACHE IS OURS. The acquisition names `npm_config_cache` explicitly, under the user's
|
|
84
|
+
* XDG cache root, so a plugin install never writes into the operator's default npm cache. It also
|
|
85
|
+
* installs with `--ignore-scripts`: a package we are placing on behalf of someone who did not ask
|
|
86
|
+
* for npm must not run arbitrary install hooks in their HOME. The installed artifact therefore has
|
|
87
|
+
* to work with its own scripts never run, which is why completeness is verified rather than assumed.
|
|
88
|
+
*
|
|
89
|
+
* WHAT THIS SLICE STILL DOES NOT DO. It does not touch the manifest, does not read Herdr
|
|
90
|
+
* integration status, and does not activate or deactivate Pi or Claude Code — that is M3-b2. The
|
|
91
|
+
* inverse here removes only what a certified journal proves, takes the runtime LAST among the
|
|
92
|
+
* artifacts, and retires the journal after them because the ledger has to outlive what it
|
|
93
|
+
* authorised.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
import { spawnSync } from "node:child_process";
|
|
97
|
+
import { createHash } from "node:crypto";
|
|
98
|
+
import fs from "node:fs";
|
|
99
|
+
import path from "node:path";
|
|
100
|
+
import { fileURLToPath } from "node:url";
|
|
101
|
+
|
|
102
|
+
/** Journal format. Bump only with a reader that understands both. */
|
|
103
|
+
export const RUNTIME_SCHEMA_VERSION = 2;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The two ways a runtime may be ACQUIRED, and they are a closed set (#116 M3-b3).
|
|
107
|
+
*
|
|
108
|
+
* `npm` is the production authority and its semantics are untouched: the lock names an exact
|
|
109
|
+
* `name@version` plus the integrity npm published for it, and the tarball's own sha512 must equal
|
|
110
|
+
* that integrity before anything is installed.
|
|
111
|
+
*
|
|
112
|
+
* `herdr-checkout` is the VERIFICATION-ONLY candidate carrier. A published npm version cannot be a
|
|
113
|
+
* precondition for iterating on a candidate — that is a release step per candidate, which is the
|
|
114
|
+
* churn this carrier exists to remove. It packs the exact commit Herdr itself just checked out, so
|
|
115
|
+
* there is no URL, ref, env var or caller parameter anywhere in it: the identity is already on
|
|
116
|
+
* disk. No registry integrity exists for such an artifact, so the anchor is the COMMIT, and the
|
|
117
|
+
* safety is carried entirely by the final installed-runtime verifier and named refusals.
|
|
118
|
+
*/
|
|
119
|
+
export const ARTIFACT_KINDS = Object.freeze(["npm", "herdr-checkout"]);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The ONE repository a checkout-sourced runtime may come from, as a literal. Herdr's own remote is
|
|
123
|
+
* hardcoded `https://github.com/{owner}/{repo}.git` with no env override (measured 2026-09-16,
|
|
124
|
+
* herdr `src/cli/plugin.rs:763` @ c77af189), so binding this side to the same literal keeps the
|
|
125
|
+
* pair closed: nothing a caller says can point the acquisition at another tree.
|
|
126
|
+
*/
|
|
127
|
+
export const CHECKOUT_REPOSITORY = "junghan0611/entwurf";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The exact key set an `artifactIdentity` carries, per kind AND per stage. Both halves are
|
|
131
|
+
* load-bearing.
|
|
132
|
+
*
|
|
133
|
+
* PER KIND, because the two acquisitions are anchored by different facts and a union that merged
|
|
134
|
+
* them would have to accept a null integrity or a null commit — a shape in which "we have not
|
|
135
|
+
* observed this yet" and "this source has no such fact" are the same value.
|
|
136
|
+
*
|
|
137
|
+
* PER STAGE, because `installing` is written BEFORE an artifact exists. The requested shape has no
|
|
138
|
+
* digest KEY at all rather than a null one: an absent key cannot be mistaken for an observation,
|
|
139
|
+
* and each phase's certifier below names exactly which shape it will accept.
|
|
140
|
+
*
|
|
141
|
+
* `packageName`/`packageVersion` appear ONLY in the checkout-ready shape, and only as COMPLETENESS
|
|
142
|
+
* evidence read off the artifact npm actually produced — what must still be sitting on disk for the
|
|
143
|
+
* tree to be a usable runtime. They are never identity: two different commits can both call
|
|
144
|
+
* themselves `0.21.0`, so identity for this kind is the commit and nothing else.
|
|
145
|
+
*/
|
|
146
|
+
export const ARTIFACT_KEYS = Object.freeze({
|
|
147
|
+
npm: Object.freeze({
|
|
148
|
+
requested: Object.freeze(["kind", "name", "version", "expectedIntegrity"]),
|
|
149
|
+
ready: Object.freeze(["kind", "name", "version", "expectedIntegrity", "observedDigest"]),
|
|
150
|
+
}),
|
|
151
|
+
"herdr-checkout": Object.freeze({
|
|
152
|
+
requested: Object.freeze(["kind", "repository", "commit"]),
|
|
153
|
+
ready: Object.freeze(["kind", "repository", "commit", "packageName", "packageVersion", "observedDigest"]),
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The bins an installed runtime must carry for the scoped wiring above to have anything to name.
|
|
159
|
+
* Measured need, not the package's whole `bin` map: `entwurf` is the operator command,
|
|
160
|
+
* `entwurf-bridge` is what Pi and Claude wiring invoke, and `entwurf-statusline` is demanded by
|
|
161
|
+
* `doctor-meta-bridge` (measured 2026-09-16: `FAIL statusline bin not on PATH: entwurf-statusline`).
|
|
162
|
+
* The agy and Copilot helpers in the same `bin` map belong to harnesses outside this plugin's set P.
|
|
163
|
+
* These are VERIFIED to exist and be executable; none of them is placed on PATH.
|
|
164
|
+
*/
|
|
165
|
+
export const REQUIRED_BINS = Object.freeze(["entwurf", "entwurf-bridge", "entwurf-statusline"]);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The compiled entry an installed consumer must have. Measured in the 0.21.0 tarball; its presence
|
|
169
|
+
* is what separates "npm put a directory there" from "the prebuilt server is installed".
|
|
170
|
+
*/
|
|
171
|
+
export const COMPILED_ENTRY = path.join("mcp", "entwurf-bridge", "dist", "mcp", "entwurf-bridge", "src", "index.js");
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Phases a journal may record. `herdr-installed` is DELIBERATELY absent — see the header.
|
|
175
|
+
* `installing` is the incomplete-but-recoverable state entry reconciles.
|
|
176
|
+
*/
|
|
177
|
+
export const JOURNAL_PHASES = Object.freeze(["installing", "runtime-ready", "removing"]);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The exact key set a certified journal carries. Extra or missing keys grant no authority.
|
|
181
|
+
*
|
|
182
|
+
* There is ONE identity field, and every consumer — this journal, `previousRuntime`, the torn-swap
|
|
183
|
+
* carry, and the activation ledger one slice above — uses that same union through the same
|
|
184
|
+
* certifier. A second string beside it ("source", "commit") would be a second authority that could
|
|
185
|
+
* disagree with the first, on exactly the question the whole transaction is about.
|
|
186
|
+
*/
|
|
187
|
+
export const JOURNAL_KEYS = Object.freeze([
|
|
188
|
+
"schemaVersion",
|
|
189
|
+
"phase",
|
|
190
|
+
"runtimeRoot",
|
|
191
|
+
"artifactIdentity",
|
|
192
|
+
"previousRuntime",
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `previousRuntime` exists because writing the `installing` entry would otherwise OVERWRITE the only
|
|
197
|
+
* record of what was running. A host that dies mid-install would then hold a backup directory and no
|
|
198
|
+
* statement of what it contains — recoverable bytes with unrecoverable provenance. So the prior
|
|
199
|
+
* ready entry's identity is carried forward for exactly as long as a backup can exist, and a
|
|
200
|
+
* finished install sets it back to null because there is no longer a previous runtime to describe.
|
|
201
|
+
* It is a READY identity or null: a backup that was never observed is not a backup anyone can name.
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
/** How a path at one of our three addresses may look. Anything else is refused by name. */
|
|
205
|
+
export const PATH_KINDS = Object.freeze(["absent", "real-dir", "symlink", "other"]);
|
|
206
|
+
|
|
207
|
+
/** The lock file that sits beside the manifest. */
|
|
208
|
+
export const RUNTIME_LOCK_BASENAME = "runtime-lock.json";
|
|
209
|
+
|
|
210
|
+
/** A named refusal. Never a warning, never a silent default (AGENTS.md Hard Rule 15). */
|
|
211
|
+
export class RuntimeBootstrapError extends Error {
|
|
212
|
+
constructor(code, detail) {
|
|
213
|
+
super(`${code}: ${detail}`);
|
|
214
|
+
this.name = "RuntimeBootstrapError";
|
|
215
|
+
this.code = code;
|
|
216
|
+
this.detail = detail;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Every path this module may touch, derived from XDG only. No defaults invented elsewhere. */
|
|
221
|
+
export function resolveRuntimeLayout(env) {
|
|
222
|
+
const home = env.HOME;
|
|
223
|
+
const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local", "share") : null);
|
|
224
|
+
const cacheHome = env.XDG_CACHE_HOME || (home ? path.join(home, ".cache") : null);
|
|
225
|
+
if (!dataHome || !cacheHome) {
|
|
226
|
+
throw new RuntimeBootstrapError(
|
|
227
|
+
"runtime-xdg-root-unresolvable",
|
|
228
|
+
"neither XDG_DATA_HOME/XDG_CACHE_HOME nor HOME is set, so there is no user root to own",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const pluginRoot = path.join(dataHome, "entwurf", "herdr-plugin");
|
|
232
|
+
const runtimeRoot = path.join(pluginRoot, "runtime");
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
pluginRoot,
|
|
235
|
+
runtimeRoot,
|
|
236
|
+
activeDir: path.join(runtimeRoot, "active"),
|
|
237
|
+
stagingDir: path.join(runtimeRoot, "staging"),
|
|
238
|
+
previousDir: path.join(runtimeRoot, "previous"),
|
|
239
|
+
journalPath: path.join(pluginRoot, "journal.json"),
|
|
240
|
+
cacheDir: path.join(cacheHome, "entwurf", "herdr-plugin", "npm"),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The package root this module ships in: `<root>/scripts/` is where it lives. */
|
|
245
|
+
export function defaultCheckoutRoot() {
|
|
246
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `<root>/plugins/herdr` — where the manifest and the lock file live in a CHECKOUT. */
|
|
250
|
+
export function defaultPluginDir() {
|
|
251
|
+
return path.join(defaultCheckoutRoot(), "plugins", "herdr");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The ONE production package spec: the exact `name@version` of the checkout that carries this
|
|
256
|
+
* plugin. Nothing here consults the environment or a flag.
|
|
257
|
+
*/
|
|
258
|
+
export function readCheckoutPackageSpec(checkoutRoot) {
|
|
259
|
+
const manifestPath = path.join(checkoutRoot, "package.json");
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
263
|
+
} catch (err) {
|
|
264
|
+
throw new RuntimeBootstrapError("runtime-package-manifest-unreadable", `${manifestPath}: ${err.message}`);
|
|
265
|
+
}
|
|
266
|
+
const { name, version } = parsed;
|
|
267
|
+
if (typeof name !== "string" || name.length === 0 || typeof version !== "string" || version.length === 0) {
|
|
268
|
+
throw new RuntimeBootstrapError(
|
|
269
|
+
"runtime-package-manifest-unreadable",
|
|
270
|
+
`${manifestPath} carries no exact name/version pair`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return Object.freeze({ name, version });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The plugin-owned lock, and it is a DISCRIMINATED UNION whose discriminant is committed.
|
|
278
|
+
*
|
|
279
|
+
* Which source a host acquires from may not be decided by an environment variable, a caller
|
|
280
|
+
* argument or a fallback — all three would let the acquisition authority be chosen at runtime by
|
|
281
|
+
* whoever is running, and a fallback in particular would turn "the registry is unreachable" into
|
|
282
|
+
* "install something else instead" (AGENTS.md Hard Rule 5). So the selector is a field in a file
|
|
283
|
+
* that travels in the checkout, and an unknown or absent `source` is a named refusal rather than a
|
|
284
|
+
* default.
|
|
285
|
+
*
|
|
286
|
+
* `comment` is the one tolerated extra key: a lock is read by people too.
|
|
287
|
+
*/
|
|
288
|
+
export function readRuntimeLock(pluginDir) {
|
|
289
|
+
const lockPath = path.join(pluginDir, RUNTIME_LOCK_BASENAME);
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
293
|
+
} catch (err) {
|
|
294
|
+
throw new RuntimeBootstrapError("runtime-lock-unreadable", `${lockPath}: ${err.message}`);
|
|
295
|
+
}
|
|
296
|
+
// Shape before fields. `JSON.parse` happily returns null, an array or a scalar, and reaching for
|
|
297
|
+
// `.schemaVersion` on any of those leaks a native TypeError with no code — an unnamed crash where
|
|
298
|
+
// the contract promises a named refusal, on the one file that says which package we install.
|
|
299
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
300
|
+
throw new RuntimeBootstrapError(
|
|
301
|
+
"runtime-lock-unreadable",
|
|
302
|
+
`${lockPath} parsed to ${Array.isArray(parsed) ? "an array" : JSON.stringify(parsed)}, not an object`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION) {
|
|
306
|
+
throw new RuntimeBootstrapError(
|
|
307
|
+
"runtime-lock-unreadable",
|
|
308
|
+
`${lockPath} carries schemaVersion ${JSON.stringify(parsed.schemaVersion)}, not ${RUNTIME_SCHEMA_VERSION}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (!ARTIFACT_KINDS.includes(parsed.source)) {
|
|
312
|
+
throw new RuntimeBootstrapError(
|
|
313
|
+
"runtime-lock-source-unknown",
|
|
314
|
+
`${lockPath} names source ${JSON.stringify(parsed.source)}; the closed set is ${JSON.stringify(ARTIFACT_KINDS)}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const expected =
|
|
318
|
+
parsed.source === "npm"
|
|
319
|
+
? ["comment", "integrity", "name", "schemaVersion", "source", "version"]
|
|
320
|
+
: ["comment", "repository", "schemaVersion", "source"];
|
|
321
|
+
const keys = Object.keys(parsed)
|
|
322
|
+
.filter((k) => k !== "comment")
|
|
323
|
+
.sort();
|
|
324
|
+
if (keys.join(",") !== expected.filter((k) => k !== "comment").join(",")) {
|
|
325
|
+
throw new RuntimeBootstrapError(
|
|
326
|
+
"runtime-lock-unreadable",
|
|
327
|
+
`${lockPath} is a ${parsed.source} lock with key set ${keys.join(",")}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (parsed.source === "npm") {
|
|
331
|
+
if (
|
|
332
|
+
typeof parsed.name !== "string" ||
|
|
333
|
+
typeof parsed.version !== "string" ||
|
|
334
|
+
typeof parsed.integrity !== "string" ||
|
|
335
|
+
!parsed.integrity.startsWith("sha512-")
|
|
336
|
+
) {
|
|
337
|
+
throw new RuntimeBootstrapError(
|
|
338
|
+
"runtime-lock-unreadable",
|
|
339
|
+
`${lockPath} is not an npm lock with an sha512 integrity`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return Object.freeze({ source: "npm", name: parsed.name, version: parsed.version, integrity: parsed.integrity });
|
|
343
|
+
}
|
|
344
|
+
if (parsed.repository !== CHECKOUT_REPOSITORY) {
|
|
345
|
+
throw new RuntimeBootstrapError(
|
|
346
|
+
"runtime-checkout-repository-foreign",
|
|
347
|
+
`${lockPath} names repository ${JSON.stringify(parsed.repository)}; this plugin installs ${CHECKOUT_REPOSITORY} and nothing else`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return Object.freeze({ source: "herdr-checkout", repository: parsed.repository });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The npm lock and the checkout must name the SAME package, exactly. */
|
|
354
|
+
export function certifyLockCoherence(lock, checkoutSpec) {
|
|
355
|
+
if (lock.name !== checkoutSpec.name || lock.version !== checkoutSpec.version) {
|
|
356
|
+
throw new RuntimeBootstrapError(
|
|
357
|
+
"runtime-lock-incoherent",
|
|
358
|
+
`the lock names ${lock.name}@${lock.version} but this checkout is ${checkoutSpec.name}@${checkoutSpec.version}`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return Object.freeze({ name: lock.name, version: lock.version, integrity: lock.integrity });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A commit is forty lowercase hex characters. Anything else is not an anchor. */
|
|
365
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The canonical full SHA of the checkout this build is running inside.
|
|
369
|
+
*
|
|
370
|
+
* `HEAD^{commit}` is asked for deliberately: it resolves a tag or an annotated object down to the
|
|
371
|
+
* commit, and `--verify` refuses an ambiguous or missing revision instead of echoing the argument
|
|
372
|
+
* back. A shallow clone answers this exactly as a full one does (measured 2026-09-16 on a real
|
|
373
|
+
* `--depth 1` checkout), which matters because Herdr's managed checkout IS shallow.
|
|
374
|
+
*/
|
|
375
|
+
export function resolveCheckoutCommit(checkoutRoot, { gitBin = "git", spawn = spawnSync } = {}) {
|
|
376
|
+
const run = spawn(gitBin, ["-C", checkoutRoot, "rev-parse", "--verify", "HEAD^{commit}"], { encoding: "utf8" });
|
|
377
|
+
if (run.error || run.status !== 0) {
|
|
378
|
+
throw new RuntimeBootstrapError(
|
|
379
|
+
"runtime-checkout-commit-unresolvable",
|
|
380
|
+
`${gitBin} -C ${checkoutRoot} rev-parse: ${run.error ? run.error.message : `exit ${run.status}: ${(run.stderr || "").trim().slice(0, 200)}`}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const commit = (run.stdout || "").trim();
|
|
384
|
+
if (!COMMIT_SHA.test(commit)) {
|
|
385
|
+
throw new RuntimeBootstrapError(
|
|
386
|
+
"runtime-checkout-commit-unresolvable",
|
|
387
|
+
`${checkoutRoot} resolved HEAD to ${JSON.stringify(commit)}, which is not a full 40-hex commit`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return commit;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The identity a bootstrap is being ASKED for, derived from the committed lock and — for the
|
|
395
|
+
* checkout source — from the checkout itself. Nothing consults the environment or a flag.
|
|
396
|
+
*/
|
|
397
|
+
export function requestedArtifactIdentity({ lock, checkoutRoot, resolveCommit = resolveCheckoutCommit }) {
|
|
398
|
+
if (lock.source === "npm") {
|
|
399
|
+
const locked = certifyLockCoherence(lock, readCheckoutPackageSpec(checkoutRoot));
|
|
400
|
+
return certifyArtifactIdentity("requested artifact", {
|
|
401
|
+
kind: "npm",
|
|
402
|
+
name: locked.name,
|
|
403
|
+
version: locked.version,
|
|
404
|
+
expectedIntegrity: locked.integrity,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return certifyArtifactIdentity("requested artifact", {
|
|
408
|
+
kind: "herdr-checkout",
|
|
409
|
+
repository: lock.repository,
|
|
410
|
+
commit: resolveCommit(checkoutRoot),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The fixed product remote. There is no other spelling, and no input reaches it. */
|
|
415
|
+
export function buildCheckoutRemote(repository) {
|
|
416
|
+
return `git+https://github.com/${repository}.git`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* `npm pack` argv for a checkout-sourced artifact, and the FORM is the contract.
|
|
421
|
+
*
|
|
422
|
+
* Measured 2026-09-16 on npm 11.16.0: packing a git spec runs `prepare` and NOT `prepack`, so the
|
|
423
|
+
* compiled bridge is built and no global pnpm is needed. Packing the DIRECTORY instead runs
|
|
424
|
+
* `prepack`, which calls `pnpm` and exits 127 on a clean host. Those are two different transactions,
|
|
425
|
+
* and only this one may be the product's — a directory pack is structurally absent from this module.
|
|
426
|
+
*
|
|
427
|
+
* `--ignore-scripts` is deliberately NOT here. It belongs to the local-tarball install below; on a
|
|
428
|
+
* pack it would skip `prepare` and produce a tarball with no compiled entry, which the installed
|
|
429
|
+
* verifier then refuses by name (measured: `runtime-compiled-dist-missing`).
|
|
430
|
+
*/
|
|
431
|
+
export function buildCheckoutPackArgv(identity, packDestination) {
|
|
432
|
+
return Object.freeze([
|
|
433
|
+
"pack",
|
|
434
|
+
`${buildCheckoutRemote(identity.repository)}#${identity.commit}`,
|
|
435
|
+
"--json",
|
|
436
|
+
"--pack-destination",
|
|
437
|
+
packDestination,
|
|
438
|
+
"--no-audit",
|
|
439
|
+
"--no-fund",
|
|
440
|
+
]);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** `npm pack` argv for the exact spec, landing the artifact in OUR cache root. */
|
|
444
|
+
export function buildPackArgv(spec, packDestination) {
|
|
445
|
+
return Object.freeze([
|
|
446
|
+
"pack",
|
|
447
|
+
`${spec.name}@${spec.version}`,
|
|
448
|
+
"--json",
|
|
449
|
+
"--pack-destination",
|
|
450
|
+
packDestination,
|
|
451
|
+
"--no-audit",
|
|
452
|
+
"--no-fund",
|
|
453
|
+
]);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** `npm install` argv for a LOCAL tarball. `--ignore-scripts` is the load-bearing flag. */
|
|
457
|
+
export function buildInstallArgv(tarballPath, prefix) {
|
|
458
|
+
return Object.freeze([
|
|
459
|
+
"install",
|
|
460
|
+
tarballPath,
|
|
461
|
+
"--prefix",
|
|
462
|
+
prefix,
|
|
463
|
+
"--ignore-scripts",
|
|
464
|
+
"--no-audit",
|
|
465
|
+
"--no-fund",
|
|
466
|
+
"--no-save",
|
|
467
|
+
]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The environment npm runs under: the operator's default cache is never the one we fill. */
|
|
471
|
+
export function npmEnvironment(env, cacheDir) {
|
|
472
|
+
return Object.freeze({
|
|
473
|
+
...env,
|
|
474
|
+
npm_config_cache: cacheDir,
|
|
475
|
+
npm_config_update_notifier: "false",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function removeTree(target) {
|
|
480
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Journals are replaced, never patched in place: write beside, then rename. */
|
|
484
|
+
export function writeJournal(layout, entry) {
|
|
485
|
+
if (!JOURNAL_PHASES.includes(entry.phase)) {
|
|
486
|
+
throw new RuntimeBootstrapError("runtime-journal-phase-unknown", `phase ${JSON.stringify(entry.phase)}`);
|
|
487
|
+
}
|
|
488
|
+
// Certified on the way OUT as well as in. A journal only the writer can read is a journal that
|
|
489
|
+
// strands the next process — including the teardown, which is the one that has no checkout left
|
|
490
|
+
// to fall back on.
|
|
491
|
+
certifyArtifactIdentity(
|
|
492
|
+
"artifactIdentity",
|
|
493
|
+
entry.artifactIdentity,
|
|
494
|
+
entry.phase === "installing" ? "requested" : entry.phase === "runtime-ready" ? "ready" : "either",
|
|
495
|
+
);
|
|
496
|
+
if (entry.previousRuntime !== null) certifyArtifactIdentity("previousRuntime", entry.previousRuntime, "ready");
|
|
497
|
+
fs.mkdirSync(layout.pluginRoot, { recursive: true });
|
|
498
|
+
const body = `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, ...entry }, null, 2)}\n`;
|
|
499
|
+
const tmp = `${layout.journalPath}.tmp`;
|
|
500
|
+
fs.writeFileSync(tmp, body);
|
|
501
|
+
fs.renameSync(tmp, layout.journalPath);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const SHA512 = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
|
|
505
|
+
const SHA256 = /^sha256-[0-9a-f]{64}$/;
|
|
506
|
+
|
|
507
|
+
function isExactIdentity(value) {
|
|
508
|
+
return typeof value === "string" && value.trim().length > 0 && value === value.trim();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* THE one certifier for an `artifactIdentity`, wherever it appears — journal entry, carried
|
|
513
|
+
* `previousRuntime`, or activation ledger. Exact keys for the kind AND the stage; a shape that
|
|
514
|
+
* belongs to the other kind, or to the other stage of its own kind, is a named refusal.
|
|
515
|
+
*
|
|
516
|
+
* @param stage `"requested"` | `"ready"` | `"either"` — which shapes the CALLER's position accepts.
|
|
517
|
+
* @returns the frozen identity, so a certified value cannot be mutated after it was certified.
|
|
518
|
+
*/
|
|
519
|
+
export function certifyArtifactIdentity(where, value, stage = "either") {
|
|
520
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
521
|
+
throw new RuntimeBootstrapError(
|
|
522
|
+
"runtime-artifact-identity-uncertified",
|
|
523
|
+
`${where} is ${Array.isArray(value) ? "an array" : JSON.stringify(value)}, not an identity object`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
if (!ARTIFACT_KINDS.includes(value.kind)) {
|
|
527
|
+
throw new RuntimeBootstrapError(
|
|
528
|
+
"runtime-artifact-identity-uncertified",
|
|
529
|
+
`${where}.kind ${JSON.stringify(value.kind)} is outside ${JSON.stringify(ARTIFACT_KINDS)}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const shapes = stage === "either" ? ["requested", "ready"] : [stage];
|
|
533
|
+
const keys = Object.keys(value).sort().join(",");
|
|
534
|
+
const matched = shapes.find((s) => keys === [...ARTIFACT_KEYS[value.kind][s]].sort().join(","));
|
|
535
|
+
if (matched === undefined) {
|
|
536
|
+
throw new RuntimeBootstrapError(
|
|
537
|
+
"runtime-artifact-identity-uncertified",
|
|
538
|
+
`${where} has key set ${keys}, which is not ${value.kind}'s ${shapes.join(" or ")} shape`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (value.kind === "npm") {
|
|
542
|
+
if (!isExactIdentity(value.name) || !isExactIdentity(value.version)) {
|
|
543
|
+
throw new RuntimeBootstrapError(
|
|
544
|
+
"runtime-artifact-identity-uncertified",
|
|
545
|
+
`${where} carries no exact package identity`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
if (!SHA512.test(value.expectedIntegrity)) {
|
|
549
|
+
throw new RuntimeBootstrapError(
|
|
550
|
+
"runtime-artifact-identity-uncertified",
|
|
551
|
+
`${where}.expectedIntegrity ${JSON.stringify(value.expectedIntegrity)}`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
// The repo literal travels WITH the identity, and is re-checked wherever it is read: a journal
|
|
556
|
+
// or ledger that names another repository describes an artifact this plugin may not have put
|
|
557
|
+
// there, and inheriting it would make a foreign tree our own by transcription.
|
|
558
|
+
if (value.repository !== CHECKOUT_REPOSITORY) {
|
|
559
|
+
throw new RuntimeBootstrapError(
|
|
560
|
+
"runtime-checkout-repository-foreign",
|
|
561
|
+
`${where}.repository ${JSON.stringify(value.repository)} is not ${CHECKOUT_REPOSITORY}`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (!COMMIT_SHA.test(value.commit)) {
|
|
565
|
+
throw new RuntimeBootstrapError(
|
|
566
|
+
"runtime-artifact-identity-uncertified",
|
|
567
|
+
`${where}.commit ${JSON.stringify(value.commit)} is not a full 40-hex commit`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (matched === "ready" && (!isExactIdentity(value.packageName) || !isExactIdentity(value.packageVersion))) {
|
|
571
|
+
throw new RuntimeBootstrapError(
|
|
572
|
+
"runtime-artifact-identity-uncertified",
|
|
573
|
+
`${where} carries no exact package completeness pair`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (matched === "ready" && !SHA256.test(value.observedDigest)) {
|
|
578
|
+
throw new RuntimeBootstrapError(
|
|
579
|
+
"runtime-artifact-identity-uncertified",
|
|
580
|
+
`${where}.observedDigest ${JSON.stringify(value.observedDigest)}`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return Object.freeze({ ...value });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Which stage a certified identity is at. Derived from its own key set, never stored twice. */
|
|
587
|
+
export function artifactStage(identity) {
|
|
588
|
+
const keys = Object.keys(identity).sort().join(",");
|
|
589
|
+
return keys === [...ARTIFACT_KEYS[identity.kind].ready].sort().join(",") ? "ready" : "requested";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The ONE place that answers "what name@version must be sitting on disk for this identity". For npm
|
|
594
|
+
* that is the identity itself; for a checkout it is the completeness pair observed off the artifact.
|
|
595
|
+
* Every disk verification — bootstrap idempotence, recovery, activation — asks here, so there is no
|
|
596
|
+
* second opinion to drift from.
|
|
597
|
+
*/
|
|
598
|
+
export function artifactCompleteness(identity) {
|
|
599
|
+
if (artifactStage(identity) !== "ready") {
|
|
600
|
+
throw new RuntimeBootstrapError(
|
|
601
|
+
"runtime-artifact-identity-uncertified",
|
|
602
|
+
`a requested ${identity.kind} identity has not observed a package yet, so it cannot say what is on disk`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return identity.kind === "npm"
|
|
606
|
+
? Object.freeze({ name: identity.name, version: identity.version })
|
|
607
|
+
: Object.freeze({ name: identity.packageName, version: identity.packageVersion });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* What the staging tree must hold, given what was asked for and what the acquisition observed.
|
|
612
|
+
*
|
|
613
|
+
* For npm the request itself is the answer — the registry spec IS the name@version. For a checkout
|
|
614
|
+
* the artifact says what it is, and this pair is exactly what the next line checks against the
|
|
615
|
+
* installed tree: an artifact whose metadata and whose installed tree disagree is refused there, by
|
|
616
|
+
* `verifyInstalledRuntime`, and not smoothed over here.
|
|
617
|
+
*/
|
|
618
|
+
export function completenessOf(requested, acquired) {
|
|
619
|
+
if (requested.kind === "npm") return Object.freeze({ name: requested.name, version: requested.version });
|
|
620
|
+
if (!isExactIdentity(acquired?.packageName) || !isExactIdentity(acquired?.packageVersion)) {
|
|
621
|
+
throw new RuntimeBootstrapError(
|
|
622
|
+
"runtime-artifact-identity-uncertified",
|
|
623
|
+
`a ${requested.kind} acquisition must report the packed package's exact name and version`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return Object.freeze({ name: acquired.packageName, version: acquired.packageVersion });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The READY identity of what just landed: the request, plus exactly what was observed about it. */
|
|
630
|
+
export function readyIdentity(requested, acquired) {
|
|
631
|
+
const observed =
|
|
632
|
+
requested.kind === "npm"
|
|
633
|
+
? { ...requested, observedDigest: acquired.observedDigest }
|
|
634
|
+
: {
|
|
635
|
+
...requested,
|
|
636
|
+
packageName: acquired.packageName,
|
|
637
|
+
packageVersion: acquired.packageVersion,
|
|
638
|
+
observedDigest: acquired.observedDigest,
|
|
639
|
+
};
|
|
640
|
+
return certifyArtifactIdentity("observed artifact", observed, "ready");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Is a READY identity the artifact a REQUESTED identity is asking for?
|
|
645
|
+
*
|
|
646
|
+
* For a checkout the answer is the commit and ONLY the commit. A version string is not an identity
|
|
647
|
+
* here: two commits can both call themselves `0.21.0`, so believing a version would let a stale
|
|
648
|
+
* runtime satisfy a request for a new candidate — the exact silence this comparison exists to make
|
|
649
|
+
* impossible.
|
|
650
|
+
*/
|
|
651
|
+
export function sameArtifactRequest(ready, requested) {
|
|
652
|
+
if (ready.kind !== requested.kind) return false;
|
|
653
|
+
if (ready.kind === "npm") {
|
|
654
|
+
return (
|
|
655
|
+
ready.name === requested.name &&
|
|
656
|
+
ready.version === requested.version &&
|
|
657
|
+
ready.expectedIntegrity === requested.expectedIntegrity
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
return ready.repository === requested.repository && ready.commit === requested.commit;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The CERTIFIED journal, or null when there is none. Nothing else in this module may read the
|
|
665
|
+
* journal: an entry that cannot pass here — including a bare `null`, an array, a scalar, or a shape
|
|
666
|
+
* that contradicts the writer state its own phase implies — grants no authority to delete, rename or
|
|
667
|
+
* replace anything.
|
|
668
|
+
*/
|
|
669
|
+
export function readCertifiedJournal(layout) {
|
|
670
|
+
let raw;
|
|
671
|
+
try {
|
|
672
|
+
raw = fs.readFileSync(layout.journalPath, "utf8");
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (err.code === "ENOENT") return null;
|
|
675
|
+
throw new RuntimeBootstrapError("runtime-journal-uncertified", `${layout.journalPath}: ${err.message}`);
|
|
676
|
+
}
|
|
677
|
+
let parsed;
|
|
678
|
+
try {
|
|
679
|
+
parsed = JSON.parse(raw);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
throw new RuntimeBootstrapError("runtime-journal-uncertified", `${layout.journalPath}: ${err.message}`);
|
|
682
|
+
}
|
|
683
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
684
|
+
throw new RuntimeBootstrapError(
|
|
685
|
+
"runtime-journal-uncertified",
|
|
686
|
+
`${layout.journalPath} parsed to ${Array.isArray(parsed) ? "an array" : JSON.stringify(parsed)}, not an object`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const keys = Object.keys(parsed).sort().join(",");
|
|
690
|
+
if (keys !== [...JOURNAL_KEYS].sort().join(",")) {
|
|
691
|
+
throw new RuntimeBootstrapError("runtime-journal-uncertified", `unexpected key set: ${keys}`);
|
|
692
|
+
}
|
|
693
|
+
if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION) {
|
|
694
|
+
throw new RuntimeBootstrapError("runtime-journal-uncertified", `schemaVersion ${parsed.schemaVersion}`);
|
|
695
|
+
}
|
|
696
|
+
if (!JOURNAL_PHASES.includes(parsed.phase)) {
|
|
697
|
+
throw new RuntimeBootstrapError("runtime-journal-uncertified", `phase ${JSON.stringify(parsed.phase)}`);
|
|
698
|
+
}
|
|
699
|
+
// The identity shape a phase may carry IS the writer state that phase describes, and each phase
|
|
700
|
+
// names its own: `installing` has asked for an artifact and not seen one, `runtime-ready` has
|
|
701
|
+
// observed exactly one, and `removing` inherits whichever entry it left. That is why the stage is
|
|
702
|
+
// passed in here rather than accepted loosely — a ready-shaped `installing` entry would claim an
|
|
703
|
+
// observation that never happened, and a requested-shaped ready entry would name a runtime whose
|
|
704
|
+
// bytes nobody looked at.
|
|
705
|
+
certifyArtifactIdentity(
|
|
706
|
+
"artifactIdentity",
|
|
707
|
+
parsed.artifactIdentity,
|
|
708
|
+
parsed.phase === "installing" ? "requested" : parsed.phase === "runtime-ready" ? "ready" : "either",
|
|
709
|
+
);
|
|
710
|
+
if (parsed.previousRuntime !== null) certifyArtifactIdentity("previousRuntime", parsed.previousRuntime, "ready");
|
|
711
|
+
if (parsed.phase === "runtime-ready" && parsed.previousRuntime !== null) {
|
|
712
|
+
throw new RuntimeBootstrapError(
|
|
713
|
+
"runtime-journal-uncertified",
|
|
714
|
+
"a ready entry describes a finished swap, so there is no previous runtime left to name",
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (parsed.runtimeRoot !== layout.activeDir) {
|
|
718
|
+
throw new RuntimeBootstrapError(
|
|
719
|
+
"runtime-journal-foreign-root",
|
|
720
|
+
`the journal names ${parsed.runtimeRoot}, which is not this host's stable ${layout.activeDir}`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
return Object.freeze(parsed);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* The identity of a FINISHED runtime, for carrying across an install that may not finish. Only a
|
|
728
|
+
* `runtime-ready` entry has one: an install that never observed an artifact has nothing to hand to
|
|
729
|
+
* the next transaction, and inventing a shape for it would be a provenance claim nobody made.
|
|
730
|
+
*/
|
|
731
|
+
export function provenanceOf(journal) {
|
|
732
|
+
if (journal === null || journal.phase !== "runtime-ready") return null;
|
|
733
|
+
return journal.artifactIdentity;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* What a path at one of our addresses IS. `existsSync` follows links, so it answers "absent" for a
|
|
738
|
+
* dangling symlink and "there" for a link into somebody else's tree — neither is a fact this module
|
|
739
|
+
* can act on.
|
|
740
|
+
*/
|
|
741
|
+
export function classifyPath(target) {
|
|
742
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
743
|
+
if (stat === undefined) return "absent";
|
|
744
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
745
|
+
if (stat.isDirectory()) return "real-dir";
|
|
746
|
+
return "other";
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* What the three runtime directories mean, given a certified journal. Reading only — this names a
|
|
751
|
+
* state, it does not act on one. All eight presence combinations have a name.
|
|
752
|
+
*/
|
|
753
|
+
export function assessRuntimeState(layout, certified) {
|
|
754
|
+
// The root itself is classified too: a symlinked `runtime/` makes `mkdir -p` fail with a bare
|
|
755
|
+
// ENOENT, and an unnamed errno is not a refusal anyone can act on.
|
|
756
|
+
const kinds = {
|
|
757
|
+
runtimeRoot: classifyPath(layout.runtimeRoot),
|
|
758
|
+
active: classifyPath(layout.activeDir),
|
|
759
|
+
staging: classifyPath(layout.stagingDir),
|
|
760
|
+
previous: classifyPath(layout.previousDir),
|
|
761
|
+
// The cache is classified with the rest, because it is the fourth thing this module deletes
|
|
762
|
+
// and a transaction that reclaims it has to have proven it was ours BEFORE the first removal.
|
|
763
|
+
cache: classifyPath(layout.cacheDir),
|
|
764
|
+
};
|
|
765
|
+
const facts = {
|
|
766
|
+
kinds: Object.freeze(kinds),
|
|
767
|
+
active: kinds.active === "real-dir",
|
|
768
|
+
staging: kinds.staging === "real-dir",
|
|
769
|
+
previous: kinds.previous === "real-dir",
|
|
770
|
+
};
|
|
771
|
+
for (const [name, kind] of Object.entries(kinds)) {
|
|
772
|
+
if (kind !== "absent" && kind !== "real-dir") {
|
|
773
|
+
return Object.freeze({ state: "path-kind-refused", refused: `${name}=${kind}`, ...facts });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
const { active, staging, previous } = facts;
|
|
777
|
+
if (certified === null) {
|
|
778
|
+
if (active || staging || previous) return Object.freeze({ state: "unowned-residue", ...facts });
|
|
779
|
+
// CACHE-ONLY residue gets its own name. A cache directory at our address with no journal behind
|
|
780
|
+
// it is somebody else's — or the remains of a generation this host cut — and adopting it would
|
|
781
|
+
// mean this module's first act on a strange host is deleting a tree it cannot prove it wrote.
|
|
782
|
+
if (kinds.cache !== "absent") return Object.freeze({ state: "unowned-cache-residue", ...facts });
|
|
783
|
+
return Object.freeze({ state: "clean", ...facts });
|
|
784
|
+
}
|
|
785
|
+
if (!active && !staging && !previous) return Object.freeze({ state: "owned-empty", ...facts });
|
|
786
|
+
if (!active && !staging && previous) return Object.freeze({ state: "torn-swap", ...facts });
|
|
787
|
+
if (!active && staging && !previous) return Object.freeze({ state: "abandoned-candidate", ...facts });
|
|
788
|
+
if (!active && staging && previous) return Object.freeze({ state: "torn-swap-with-candidate", ...facts });
|
|
789
|
+
if (active && !staging && !previous) return Object.freeze({ state: "settled", ...facts });
|
|
790
|
+
if (active && !staging && previous) return Object.freeze({ state: "backup-pending", ...facts });
|
|
791
|
+
if (active && staging && !previous) return Object.freeze({ state: "stale-candidate", ...facts });
|
|
792
|
+
return Object.freeze({ state: "backup-pending-with-candidate", ...facts });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* INSPECT a tree as an installed package consumer, without demanding a particular version: the
|
|
797
|
+
* package is there under the name we expect, the compiled entry is present, every required bin is
|
|
798
|
+
* present and executable, and the shipped operator command runs. This is the question a recovery has
|
|
799
|
+
* to answer about a directory whose version it does not yet know — "is what is sitting at the active
|
|
800
|
+
* address a usable runtime at all?" — and it is why a backup is never deleted on the strength of a
|
|
801
|
+
* sibling's mere existence.
|
|
802
|
+
*
|
|
803
|
+
* @returns `{ok: true, version}` or `{ok: false, code, detail}` — a verdict, not a throw.
|
|
804
|
+
*/
|
|
805
|
+
export function inspectInstalledRuntime(prefix, packageName) {
|
|
806
|
+
const installedRoot = path.join(prefix, "node_modules", packageName);
|
|
807
|
+
let installed;
|
|
808
|
+
try {
|
|
809
|
+
installed = JSON.parse(fs.readFileSync(path.join(installedRoot, "package.json"), "utf8"));
|
|
810
|
+
} catch (err) {
|
|
811
|
+
return {
|
|
812
|
+
ok: false,
|
|
813
|
+
code: "runtime-package-spec-mismatch",
|
|
814
|
+
detail: `${installedRoot}/package.json: ${err.message}`,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (installed.name !== packageName || !isExactIdentity(installed.version)) {
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
code: "runtime-package-spec-mismatch",
|
|
821
|
+
detail: `${installedRoot} holds ${JSON.stringify(installed.name)}@${JSON.stringify(installed.version)}`,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
if (!fs.existsSync(path.join(installedRoot, COMPILED_ENTRY))) {
|
|
825
|
+
return { ok: false, code: "runtime-compiled-dist-missing", detail: path.join(installedRoot, COMPILED_ENTRY) };
|
|
826
|
+
}
|
|
827
|
+
for (const name of REQUIRED_BINS) {
|
|
828
|
+
const bin = path.join(prefix, "node_modules", ".bin", name);
|
|
829
|
+
try {
|
|
830
|
+
fs.accessSync(bin, fs.constants.X_OK);
|
|
831
|
+
} catch (err) {
|
|
832
|
+
return { ok: false, code: "runtime-required-bin-missing", detail: `${bin}: ${err.message}` };
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const entwurf = path.join(prefix, "node_modules", ".bin", "entwurf");
|
|
836
|
+
const run = spawnSync(entwurf, ["check-bridge"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
837
|
+
if (run.status !== 0) {
|
|
838
|
+
return {
|
|
839
|
+
ok: false,
|
|
840
|
+
code: "runtime-check-bridge-failed",
|
|
841
|
+
detail: `${entwurf} check-bridge exited ${run.status}: ${(run.stderr || run.stdout || "").trim().slice(0, 400)}`,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
return { ok: true, version: installed.version };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Verify a tree as the EXACT runtime asked for: everything `inspectInstalledRuntime` checks, plus
|
|
849
|
+
* the version. Refusals are named and thrown, because this is the gate a candidate must pass before
|
|
850
|
+
* it is allowed to become the active runtime.
|
|
851
|
+
*/
|
|
852
|
+
export function verifyInstalledRuntime(prefix, spec) {
|
|
853
|
+
const seen = inspectInstalledRuntime(prefix, spec.name);
|
|
854
|
+
if (!seen.ok) throw new RuntimeBootstrapError(seen.code, seen.detail);
|
|
855
|
+
if (seen.version !== spec.version) {
|
|
856
|
+
throw new RuntimeBootstrapError(
|
|
857
|
+
"runtime-package-spec-mismatch",
|
|
858
|
+
`asked for ${spec.name}@${spec.version}, the tree holds ${spec.name}@${seen.version}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return Object.freeze({ name: spec.name, version: seen.version });
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** npm's own integrity format, computed from the bytes that actually arrived. */
|
|
865
|
+
export function integrityOfFile(file) {
|
|
866
|
+
return `sha512-${createHash("sha512").update(fs.readFileSync(file)).digest("base64")}`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A second, shorter record of the same bytes — what we observed, never a claim of a check. */
|
|
870
|
+
export function digestFile(file) {
|
|
871
|
+
return `sha256-${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** `npm pack --json`'s one row, or a named refusal. The metadata is the ARTIFACT's own claim. */
|
|
875
|
+
function readPackedArtifact(packed, cacheDir, failureCode) {
|
|
876
|
+
let row;
|
|
877
|
+
try {
|
|
878
|
+
row = JSON.parse(packed.stdout)[0];
|
|
879
|
+
} catch (err) {
|
|
880
|
+
throw new RuntimeBootstrapError(failureCode, `npm pack --json was unreadable: ${err.message}`);
|
|
881
|
+
}
|
|
882
|
+
if (
|
|
883
|
+
row === undefined ||
|
|
884
|
+
typeof row.filename !== "string" ||
|
|
885
|
+
!isExactIdentity(row.name) ||
|
|
886
|
+
!isExactIdentity(row.version)
|
|
887
|
+
) {
|
|
888
|
+
throw new RuntimeBootstrapError(
|
|
889
|
+
failureCode,
|
|
890
|
+
`npm pack --json carried no name/version/filename: ${packed.stdout.slice(0, 200)}`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
return Object.freeze({
|
|
894
|
+
tarball: path.join(cacheDir, path.basename(row.filename)),
|
|
895
|
+
name: row.name,
|
|
896
|
+
version: row.version,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Install a local tarball into the staging prefix. `--ignore-scripts` is this call's flag. */
|
|
901
|
+
function installLocalTarball(tarball, prefix, npmEnv, npmBin, failureCode) {
|
|
902
|
+
const installed = spawnSync(npmBin, buildInstallArgv(tarball, prefix), { encoding: "utf8", env: npmEnv });
|
|
903
|
+
if (installed.status !== 0) {
|
|
904
|
+
throw new RuntimeBootstrapError(
|
|
905
|
+
failureCode,
|
|
906
|
+
`npm install exited ${installed.status}: ${(installed.stderr || "").trim().slice(0, 400)}`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** npm acquisition: pack the exact spec into our cache, verify its bytes against the published integrity, install. */
|
|
912
|
+
export function npmAcquire({ identity, prefix, cacheDir, env, npmBin = "npm" }) {
|
|
913
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
914
|
+
const npmEnv = npmEnvironment(env, cacheDir);
|
|
915
|
+
const spec = { name: identity.name, version: identity.version, integrity: identity.expectedIntegrity };
|
|
916
|
+
const packed = spawnSync(npmBin, buildPackArgv(spec, cacheDir), { encoding: "utf8", env: npmEnv });
|
|
917
|
+
if (packed.status !== 0) {
|
|
918
|
+
throw new RuntimeBootstrapError(
|
|
919
|
+
"runtime-acquire-failed",
|
|
920
|
+
`npm pack exited ${packed.status}: ${(packed.stderr || "").trim().slice(0, 400)}`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
const artifact = readPackedArtifact(packed, cacheDir, "runtime-acquire-failed");
|
|
924
|
+
const observedIntegrity = integrityOfFile(artifact.tarball);
|
|
925
|
+
if (observedIntegrity !== spec.integrity) {
|
|
926
|
+
throw new RuntimeBootstrapError(
|
|
927
|
+
"runtime-artifact-integrity-mismatch",
|
|
928
|
+
`the lock expects ${spec.integrity} for ${spec.name}@${spec.version}, the tarball hashes to ${observedIntegrity}`,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
installLocalTarball(artifact.tarball, prefix, npmEnv, npmBin, "runtime-acquire-failed");
|
|
932
|
+
return Object.freeze({ observedDigest: digestFile(artifact.tarball) });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Checkout acquisition: pack the EXACT commit from the fixed remote, then install that tarball.
|
|
937
|
+
*
|
|
938
|
+
* There is no integrity to compare against, and this function does not pretend otherwise — a
|
|
939
|
+
* registry publishes an integrity, a commit does not. What stands in its place is spelled out
|
|
940
|
+
* rather than implied: the commit pins WHICH source tree was packed, the digest RECORDS the bytes
|
|
941
|
+
* that arrived, and the caller's `verifyInstalledRuntime` decides whether what landed is a runtime
|
|
942
|
+
* at all. A remote that cannot serve the commit, or a `prepare` that cannot build it, is
|
|
943
|
+
* `runtime-checkout-source-unavailable` — never a fallback to some other source.
|
|
944
|
+
*/
|
|
945
|
+
export function checkoutAcquire({ identity, prefix, cacheDir, env, npmBin = "npm" }) {
|
|
946
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
947
|
+
const npmEnv = npmEnvironment(env, cacheDir);
|
|
948
|
+
const packed = spawnSync(npmBin, buildCheckoutPackArgv(identity, cacheDir), { encoding: "utf8", env: npmEnv });
|
|
949
|
+
if (packed.status !== 0) {
|
|
950
|
+
throw new RuntimeBootstrapError(
|
|
951
|
+
"runtime-checkout-source-unavailable",
|
|
952
|
+
`npm pack ${buildCheckoutRemote(identity.repository)}#${identity.commit} exited ${packed.status}: ${(packed.stderr || "").trim().slice(0, 400)}`,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
const artifact = readPackedArtifact(packed, cacheDir, "runtime-checkout-source-unavailable");
|
|
956
|
+
installLocalTarball(artifact.tarball, prefix, npmEnv, npmBin, "runtime-checkout-source-unavailable");
|
|
957
|
+
// COMPLETENESS, not identity: what the artifact says it is, to be checked against the tree.
|
|
958
|
+
return Object.freeze({
|
|
959
|
+
observedDigest: digestFile(artifact.tarball),
|
|
960
|
+
packageName: artifact.name,
|
|
961
|
+
packageVersion: artifact.version,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The dispatcher, on the identity's own discriminant. No environment, no fallback, no third branch. */
|
|
966
|
+
export function acquireArtifact(args) {
|
|
967
|
+
return args.identity.kind === "npm" ? npmAcquire(args) : checkoutAcquire(args);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The package name the tree at the ACTIVE address was installed as, according to the journal.
|
|
972
|
+
*
|
|
973
|
+
* An `installing` entry describes what we are reaching for, not what is standing there, so the
|
|
974
|
+
* answer comes from the carried ready identity in that case. It is deliberately NOT taken from the
|
|
975
|
+
* request: on a source switch the request may carry no package name at all, and answering with the
|
|
976
|
+
* candidate's name would inspect the running runtime as if it were already the new one.
|
|
977
|
+
*/
|
|
978
|
+
function activePackageName(certified) {
|
|
979
|
+
const ready =
|
|
980
|
+
artifactStage(certified.artifactIdentity) === "ready" ? certified.artifactIdentity : certified.previousRuntime;
|
|
981
|
+
if (ready === null) {
|
|
982
|
+
throw new RuntimeBootstrapError(
|
|
983
|
+
"runtime-owner-state-missing",
|
|
984
|
+
`${certified.runtimeRoot} has a backup beside it but the journal carries no ready identity naming what that backup contains`,
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
return artifactCompleteness(ready).name;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Put the exact runtime at the stable address, or leave the last good one exactly as it was.
|
|
992
|
+
*
|
|
993
|
+
* @returns frozen `{phase, changed, recovered, journal}`
|
|
994
|
+
*/
|
|
995
|
+
export function bootstrapRuntime({
|
|
996
|
+
env,
|
|
997
|
+
lock,
|
|
998
|
+
checkoutRoot = defaultCheckoutRoot(),
|
|
999
|
+
acquire = acquireArtifact,
|
|
1000
|
+
resolveCommit = resolveCheckoutCommit,
|
|
1001
|
+
}) {
|
|
1002
|
+
const requested = requestedArtifactIdentity({ lock, checkoutRoot, resolveCommit });
|
|
1003
|
+
const layout = resolveRuntimeLayout(env);
|
|
1004
|
+
|
|
1005
|
+
// PRE-MUTATION, and that word is literal: not one directory is created before ownership and the
|
|
1006
|
+
// disk state have been decided. A module that mkdirs its own root first has answered "is this
|
|
1007
|
+
// ours?" with its own footprint.
|
|
1008
|
+
const certified = readCertifiedJournal(layout);
|
|
1009
|
+
const assessed = assessRuntimeState(layout, certified);
|
|
1010
|
+
if (assessed.state === "path-kind-refused") {
|
|
1011
|
+
throw new RuntimeBootstrapError(
|
|
1012
|
+
"runtime-path-kind-refused",
|
|
1013
|
+
`${layout.runtimeRoot} holds ${assessed.refused}; only an absent path or a real directory may stand at these addresses`,
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
if (assessed.state === "unowned-residue") {
|
|
1017
|
+
throw new RuntimeBootstrapError(
|
|
1018
|
+
"runtime-owner-state-missing",
|
|
1019
|
+
`${layout.runtimeRoot} holds ${JSON.stringify(assessed.kinds)} with no certified journal behind it; refusing to move what we cannot prove is ours`,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
if (assessed.state === "unowned-cache-residue") {
|
|
1023
|
+
throw new RuntimeBootstrapError(
|
|
1024
|
+
"runtime-cache-unowned-residue",
|
|
1025
|
+
`${layout.cacheDir} exists with no certified journal behind it; this transaction reclaims only a cache its own journal proves it wrote`,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
fs.mkdirSync(layout.runtimeRoot, { recursive: true });
|
|
1030
|
+
|
|
1031
|
+
// Recovery. The backup is the last good runtime until something proves otherwise, so it is never
|
|
1032
|
+
// dropped on the strength of a sibling merely existing.
|
|
1033
|
+
let recovered = null;
|
|
1034
|
+
let restored = false;
|
|
1035
|
+
switch (assessed.state) {
|
|
1036
|
+
case "torn-swap":
|
|
1037
|
+
case "torn-swap-with-candidate":
|
|
1038
|
+
removeTree(layout.stagingDir);
|
|
1039
|
+
fs.renameSync(layout.previousDir, layout.activeDir);
|
|
1040
|
+
recovered = assessed.state;
|
|
1041
|
+
restored = true;
|
|
1042
|
+
break;
|
|
1043
|
+
case "abandoned-candidate":
|
|
1044
|
+
case "stale-candidate":
|
|
1045
|
+
removeTree(layout.stagingDir);
|
|
1046
|
+
recovered = assessed.state;
|
|
1047
|
+
break;
|
|
1048
|
+
case "backup-pending":
|
|
1049
|
+
case "backup-pending-with-candidate": {
|
|
1050
|
+
removeTree(layout.stagingDir);
|
|
1051
|
+
// "Is the active tree a usable runtime at all?" — asked with the name the PREVIOUS ready
|
|
1052
|
+
// identity claims, because that is what the backup beside it was installed as. On a source
|
|
1053
|
+
// switch the requested identity may not even carry a package name.
|
|
1054
|
+
if (inspectInstalledRuntime(layout.activeDir, activePackageName(certified)).ok) {
|
|
1055
|
+
removeTree(layout.previousDir);
|
|
1056
|
+
recovered = "stale-backup";
|
|
1057
|
+
} else {
|
|
1058
|
+
removeTree(layout.activeDir);
|
|
1059
|
+
fs.renameSync(layout.previousDir, layout.activeDir);
|
|
1060
|
+
recovered = "corrupt-active-restored";
|
|
1061
|
+
restored = true;
|
|
1062
|
+
}
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
default:
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Reconcile journal against disk: only a `runtime-ready` journal for THIS exact spec, whose
|
|
1070
|
+
// active tree is still a REAL DIRECTORY that verifies, may be believed. A recovery just moved a
|
|
1071
|
+
// tree the journal does not describe, so a restore never takes this path.
|
|
1072
|
+
// IDENTITY, not version. `sameArtifactRequest` is the only comparison allowed here: for a
|
|
1073
|
+
// checkout-sourced runtime the anchor is the commit, so a journal that says `0.21.0` while the
|
|
1074
|
+
// request names a different commit is NOT this runtime, and skipping the install on the strength
|
|
1075
|
+
// of a matching version is how a host would keep serving the previous candidate while every
|
|
1076
|
+
// receipt above it claimed the new one.
|
|
1077
|
+
if (
|
|
1078
|
+
!restored &&
|
|
1079
|
+
certified !== null &&
|
|
1080
|
+
certified.phase === "runtime-ready" &&
|
|
1081
|
+
sameArtifactRequest(certified.artifactIdentity, requested) &&
|
|
1082
|
+
classifyPath(layout.activeDir) === "real-dir"
|
|
1083
|
+
) {
|
|
1084
|
+
try {
|
|
1085
|
+
verifyInstalledRuntime(layout.activeDir, artifactCompleteness(certified.artifactIdentity));
|
|
1086
|
+
return Object.freeze({ phase: "runtime-ready", changed: false, recovered, journal: certified });
|
|
1087
|
+
} catch {
|
|
1088
|
+
// fall through: the journal claimed a runtime the disk does not have.
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Carry the last good provenance forward. Without this, writing `installing` would erase the only
|
|
1093
|
+
// statement of what the backup about to be created actually contains.
|
|
1094
|
+
const carried =
|
|
1095
|
+
certified === null
|
|
1096
|
+
? null
|
|
1097
|
+
: certified.phase === "runtime-ready"
|
|
1098
|
+
? provenanceOf(certified)
|
|
1099
|
+
: certified.previousRuntime;
|
|
1100
|
+
writeJournal(layout, {
|
|
1101
|
+
phase: "installing",
|
|
1102
|
+
runtimeRoot: layout.activeDir,
|
|
1103
|
+
artifactIdentity: requested,
|
|
1104
|
+
previousRuntime: carried,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// From here the journal above is this transaction's proof of ownership over the staging tree AND
|
|
1108
|
+
// the cache — which is why the cleanup below may reclaim both, and why it could not before the
|
|
1109
|
+
// write. What it may NOT touch is the journal itself, the backup, or the active runtime: those
|
|
1110
|
+
// are the retry authority and the last good runtime, and a failed candidate is not a reason to
|
|
1111
|
+
// have less than we started with.
|
|
1112
|
+
let acquired;
|
|
1113
|
+
try {
|
|
1114
|
+
fs.mkdirSync(layout.stagingDir, { recursive: true });
|
|
1115
|
+
acquired = acquire({ identity: requested, prefix: layout.stagingDir, cacheDir: layout.cacheDir, env });
|
|
1116
|
+
verifyInstalledRuntime(layout.stagingDir, completenessOf(requested, acquired));
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
removeTree(layout.stagingDir);
|
|
1119
|
+
// A half-fetched tarball left in the cache is the thing a retry would trip over: npm's own
|
|
1120
|
+
// cache entry may be incomplete, and OUR cache is the only place this transaction wrote.
|
|
1121
|
+
removeTree(layout.cacheDir);
|
|
1122
|
+
throw err;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let backedUp = false;
|
|
1126
|
+
try {
|
|
1127
|
+
if (classifyPath(layout.activeDir) === "real-dir") {
|
|
1128
|
+
fs.renameSync(layout.activeDir, layout.previousDir);
|
|
1129
|
+
backedUp = true;
|
|
1130
|
+
}
|
|
1131
|
+
fs.renameSync(layout.stagingDir, layout.activeDir);
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
removeTree(layout.stagingDir);
|
|
1134
|
+
if (backedUp) {
|
|
1135
|
+
removeTree(layout.activeDir);
|
|
1136
|
+
fs.renameSync(layout.previousDir, layout.activeDir);
|
|
1137
|
+
}
|
|
1138
|
+
throw new RuntimeBootstrapError("runtime-swap-failed", err.message);
|
|
1139
|
+
}
|
|
1140
|
+
removeTree(layout.previousDir);
|
|
1141
|
+
|
|
1142
|
+
const journal = {
|
|
1143
|
+
phase: "runtime-ready",
|
|
1144
|
+
runtimeRoot: layout.activeDir,
|
|
1145
|
+
artifactIdentity: readyIdentity(requested, acquired),
|
|
1146
|
+
previousRuntime: null,
|
|
1147
|
+
};
|
|
1148
|
+
writeJournal(layout, journal);
|
|
1149
|
+
return Object.freeze({ phase: "runtime-ready", changed: true, recovered, journal });
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The inverse, provable-only. Every deletion authority is established BEFORE the first deletion, so
|
|
1154
|
+
* a refusal leaves the host — including the journal's bytes — exactly as it found it. The runtime
|
|
1155
|
+
* goes LAST among the artifacts, and the journal after them, because the ledger has to outlive what
|
|
1156
|
+
* it authorised. No public verb reaches this yet — that orchestration is M3-b2.
|
|
1157
|
+
*/
|
|
1158
|
+
export function removeOwnedRuntime({ env }) {
|
|
1159
|
+
const layout = resolveRuntimeLayout(env);
|
|
1160
|
+
const certified = readCertifiedJournal(layout);
|
|
1161
|
+
const assessed = assessRuntimeState(layout, certified);
|
|
1162
|
+
if (assessed.state === "path-kind-refused") {
|
|
1163
|
+
throw new RuntimeBootstrapError(
|
|
1164
|
+
"runtime-inverse-foreign-refused",
|
|
1165
|
+
`${layout.runtimeRoot} holds ${assessed.refused}; only an absent path or a real directory may stand at these addresses`,
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
if (certified === null) {
|
|
1169
|
+
if (assessed.state === "unowned-residue") {
|
|
1170
|
+
throw new RuntimeBootstrapError(
|
|
1171
|
+
"runtime-inverse-foreign-refused",
|
|
1172
|
+
`${layout.runtimeRoot} holds ${JSON.stringify(assessed)} with no certified journal behind it`,
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
// A CACHE with no journal behind it is refused here for the same reason the forward
|
|
1176
|
+
// transaction refuses it: the inverse's whole plan begins with that directory, and the two
|
|
1177
|
+
// sides of one ownership fact may not read it differently. An inverse that treated it as
|
|
1178
|
+
// "nothing of ours, report success" would be the half that quietly deletes it next time the
|
|
1179
|
+
// forward path is taught to be less careful.
|
|
1180
|
+
if (assessed.state === "unowned-cache-residue") {
|
|
1181
|
+
throw new RuntimeBootstrapError(
|
|
1182
|
+
"runtime-inverse-foreign-refused",
|
|
1183
|
+
`${layout.cacheDir} exists with no certified journal behind it; this inverse removes only what a journal proves it wrote`,
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
return Object.freeze({ removed: Object.freeze([]), reason: "runtime-journal-absent" });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// PREFLIGHT: everything we are about to delete, decided before anything is deleted.
|
|
1190
|
+
const plan = [];
|
|
1191
|
+
if (fs.existsSync(layout.cacheDir)) plan.push({ what: "cache", target: layout.cacheDir });
|
|
1192
|
+
plan.push({ what: "runtime", target: layout.runtimeRoot });
|
|
1193
|
+
|
|
1194
|
+
writeJournal(layout, { ...certified, phase: "removing" });
|
|
1195
|
+
const removed = [];
|
|
1196
|
+
for (const step of plan) {
|
|
1197
|
+
removeTree(step.target);
|
|
1198
|
+
removed.push(step.what);
|
|
1199
|
+
}
|
|
1200
|
+
fs.rmSync(layout.journalPath, { force: true });
|
|
1201
|
+
removed.push("journal");
|
|
1202
|
+
return Object.freeze({ removed: Object.freeze(removed), reason: null });
|
|
1203
|
+
}
|