clearotron 0.2.2 → 0.2.4
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/.env.example +64 -37
- package/.nvmrc +1 -1
- package/CONTRIBUTING.md +8 -4
- package/INSTALL.md +36 -14
- package/README.md +18 -22
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +10 -5
- package/bin/onboard.mjs +370 -28
- package/bin/start.mjs +220 -12
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/demo/full-country-search/run/_driver/framework.json +1 -1
- package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
- package/demo/knockout-search/run/_driver/framework.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
- package/docs/E2E.md +5 -5
- package/docs/GLOSSARY.md +85 -0
- package/docs/PORTAL.md +2 -2
- package/docs/README.md +6 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/01-product-overview.md +21 -9
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +19 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
- package/driver/CHANGELOG.md +90 -0
- package/driver/README.md +25 -6
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/connotation-search.mjs +1 -1
- package/driver/contract-audit.mjs +5 -1
- package/driver/contract-e3-baseline.json +11 -11
- package/driver/dev-portal.mjs +2 -2
- package/driver/doubt-selection.mjs +1 -1
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +1 -1
- package/driver/effort-model.mjs +2 -2
- package/driver/engine/cli-version.mjs +103 -0
- package/driver/engine/probe.mjs +45 -5
- package/driver/enqueue-schema.mjs +8 -8
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +52 -2
- package/driver/flag-snapshot.mjs +20 -0
- package/driver/framework.mjs +1 -1
- package/driver/gateway.mjs +20 -3
- package/driver/outbox-backoff.mjs +1 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +211 -106
- package/driver/plain-register.mjs +134 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +70 -2
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-local-auth.mjs +5 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +79 -9
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +151 -2
- package/driver/profile-page.html +10 -2
- package/driver/profile-service.mjs +4 -4
- package/driver/profiles/README.md +3 -3
- package/driver/profiles.mjs +144 -12
- package/driver/publish/index.mjs +2 -2
- package/driver/publish/knockout.mjs +7 -1
- package/driver/publish/render-knockout.mjs +453 -118
- package/driver/publish/render.mjs +111 -16
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +3 -3
- package/driver/skills/blind-frame/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +84 -0
- package/driver/skills/matter-frame/SKILL.md +6 -6
- package/driver/skills/matter-frame/watchlist-reference.md +2 -2
- package/driver/skills/prelim-common-law/SKILL.md +1 -1
- package/driver/skills/prelim-search/SKILL.md +1 -1
- package/driver/skills/prelim-search/report-prose.md +82 -0
- package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
- package/driver/skills/prelim-search/synthesis-rules.md +1 -1
- package/driver/skills/prelim-variants/SKILL.md +3 -3
- package/driver/stage-context.mjs +13 -0
- package/driver/stages.mjs +54 -7
- package/driver/suite-census.json +220 -58
- package/driver/systemd/clearotron-worker.service +3 -3
- package/driver/tokens.mjs +1 -1
- package/driver/unit-inventory.mjs +214 -27
- package/driver/verify.mjs +1 -1
- package/driver/whatif-memo-run.mjs +26 -4
- package/driver/whatif-memo.mjs +19 -1
- package/examples/README.md +2 -2
- package/examples/grants.example.json +6 -7
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/ops/EXAMPLES.md +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +31 -19
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/added-reference-check.mjs +45 -89
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/e2e.mjs +2 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/generated-files-are-current.mjs +125 -0
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/mint-names-in-force.mjs +4 -2
- package/scripts/mint-public-residue.mjs +74 -0
- package/scripts/mint-reference-strip-backlog.mjs +12 -1
- package/scripts/mint-suite-census.mjs +5 -2
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/render-check.mjs +13 -2
- package/scripts/report-frame-check.mjs +8 -1
- package/scripts/report-screenshot.mjs +6 -3
- package/scripts/revisit-render-check.mjs +6 -3
- package/scripts/score.mjs +1 -1
- package/scripts/strip-tracker-citations.mjs +38 -7
- package/scripts/test-full.mjs +226 -0
- package/scripts/test-run.mjs +16 -0
- package/shared/README.md +2 -1
- package/shared/anon-overlay.mjs +3 -3
- package/shared/browser-temp-root.mjs +142 -0
- package/shared/checkout-move.mjs +1 -1
- package/shared/client-door.mjs +4 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/identifier-scan.mjs +1 -1
- package/shared/install-auth.mjs +35 -0
- package/shared/invocation.mjs +33 -1
- package/shared/listen.mjs +55 -3
- package/shared/names-in-force.mjs +6 -1
- package/shared/node-floor.mjs +97 -0
- package/shared/os-advice.mjs +91 -0
- package/shared/reap-on-exit.mjs +42 -0
- package/shared/reference-guard-classes.mjs +351 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- package/shared/suite-census.mjs +31 -3
- package/shared/withheld-paths-access.mjs +36 -18
- package/skills/clearotron-ops/SKILL.md +1 -1
- package/driver/recipes/README.md +0 -49
- package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
- package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
- package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
- package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
- package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
- package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
- package/scripts/ai-page-render-check.mjs +0 -532
- package/scripts/clearances-render-check.mjs +0 -480
- package/scripts/composer-render-check.mjs +0 -1027
- package/scripts/home-render-check.mjs +0 -526
- package/scripts/portal-lifecycle-check.mjs +0 -639
package/shared/listen.mjs
CHANGED
|
@@ -65,9 +65,11 @@
|
|
|
65
65
|
// WHAT THIS DELIBERATELY DOES NOT DO: look up who holds the port. Node cannot see another user's
|
|
66
66
|
// process without privilege, and a probe that usually cannot look would print "no other instance" when
|
|
67
67
|
// it means "could not check" — which is the same class of lie this whole issue is about. The EADDRINUSE
|
|
68
|
-
// message already tells the operator
|
|
68
|
+
// message already tells the operator how to find the holder, in the shell they are actually in.
|
|
69
69
|
|
|
70
70
|
/** The environment variable a shared box sets once to ban silent defaults for every service on it. */
|
|
71
|
+
import { whatHoldsPort, stopThatProcess } from "./os-advice.mjs";
|
|
72
|
+
|
|
71
73
|
export const REQUIRE_EXPLICIT_PORTS = "CLEAROTRON_REQUIRE_EXPLICIT_PORTS";
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -124,6 +126,40 @@ export function explicitPortRequiredMessage({ what, port, portVar }) {
|
|
|
124
126
|
* response-body and bundle assertion in the suite, so naming it is free and correct.
|
|
125
127
|
* `portFlag` an optional CLI equivalent, for the entry points that take one.
|
|
126
128
|
*/
|
|
129
|
+
/**
|
|
130
|
+
* The first free port at or after `from`, or `null` when nothing in range is free — owner ruling,
|
|
131
|
+
* 2026-09-09.
|
|
132
|
+
*
|
|
133
|
+
* WHAT THIS IS FOR AND WHAT IT IS NOT. A collision on a DEFAULT port is this process discovering it
|
|
134
|
+
* guessed somebody else's address: nobody stated that number and nothing outside is addressed to it,
|
|
135
|
+
* so stepping to the next free one loses nothing. A collision on a port the reader SET is an address
|
|
136
|
+
* conflict they can reason about, and moving it silently would take the product away from where they
|
|
137
|
+
* pointed it. The caller decides which case it is; this only walks.
|
|
138
|
+
*
|
|
139
|
+
* AND IT IS ONLY EVER SAFE WHERE NOTHING FRONTS THESE DOORS. A proxy, an Access team or an OIDC issuer
|
|
140
|
+
* means something outside this process is addressed to these numbers, and a door that moved would be
|
|
141
|
+
* up and unreachable — the worst of the three outcomes, because it looks like success. That is the
|
|
142
|
+
* caller's question too, for the same reason: only it knows which deployment shape it is.
|
|
143
|
+
*
|
|
144
|
+
* `isFree` IS INJECTED and may be sync or async. A walker testable only by occupying real ports would
|
|
145
|
+
* be tested against whichever ports happened to be free on the machine that ran it, which is not a
|
|
146
|
+
* test of the rule.
|
|
147
|
+
*
|
|
148
|
+
* NULL RATHER THAN A GUESS when the range is exhausted. The caller then refuses on the port that was
|
|
149
|
+
* actually asked for, and the reader is told the truth — that address is taken — instead of being sent
|
|
150
|
+
* to one this could not prove was free either.
|
|
151
|
+
*/
|
|
152
|
+
export async function nextFreePort(from, isFree, { limit = 64, claimed = new Set() } = {}) {
|
|
153
|
+
for (let p = from + 1; p <= 65535 && p < from + limit; p += 1) {
|
|
154
|
+
// CLAIMED PORTS ARE NOT FREE, even though nothing is listening on them yet. The doors are chosen
|
|
155
|
+
// one after another and bound later, so two of them landing on one number is the same collision
|
|
156
|
+
// deferred — and the second to bind would be the one that failed.
|
|
157
|
+
if (claimed.has(p)) continue;
|
|
158
|
+
if (await isFree(p)) return p;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
127
163
|
export function listenErrorMessage(err, { what, host, port, portVar, portFlag = null, portSource = null,
|
|
128
164
|
portFile = null }) {
|
|
129
165
|
const at = `${host}:${port}`;
|
|
@@ -160,8 +196,24 @@ export function listenErrorMessage(err, { what, host, port, portVar, portFlag =
|
|
|
160
196
|
+ `address — and had it been down just now, this process would have taken it silently. `
|
|
161
197
|
+ `Set ${portVar ?? "the port variable"} for this instance.\n`
|
|
162
198
|
: "")
|
|
163
|
-
|
|
164
|
-
|
|
199
|
+
// ── THE HOLDER IS NOT ALWAYS SOMETHING TO KILL, AND THIS USED TO ASSUME IT WAS ─────────────
|
|
200
|
+
//
|
|
201
|
+
// "Then stop that process" is right for a stray second copy and wrong for the other common
|
|
202
|
+
// holder: an editor's port forward. A reader working over a remote session has the port
|
|
203
|
+
// forwarded to their laptop by the editor itself, so the thing holding it is the tool they are
|
|
204
|
+
// reading this message in — and stopping it drops the session that printed the advice.
|
|
205
|
+
//
|
|
206
|
+
// So the holder is named as a question rather than a verdict, moving THIS instance comes
|
|
207
|
+
// first among the remedies, and stopping the holder is offered second and only as the
|
|
208
|
+
// reader's own call. Nothing here claims to know which it is: that cannot be seen from
|
|
209
|
+
// inside this process, and the previous wording claimed it by implication.
|
|
210
|
+
+ ` See what holds it: ${whatHoldsPort(port)}\n`
|
|
211
|
+
+ " A forwarded port counts: an editor forwarding this port to your machine holds it exactly "
|
|
212
|
+
+ "as a second copy would, and stopping that would end the session you are reading this in.\n"
|
|
213
|
+
+ (move
|
|
214
|
+
? ` Move this instance instead — ${move} — or, once you know what the holder is and that you `
|
|
215
|
+
+ `do not need it, ${stopThatProcess()}.\n`
|
|
216
|
+
: ` Once you know what the holder is and that you do not need it, ${stopThatProcess()}.\n`)
|
|
165
217
|
+ whichFile
|
|
166
218
|
+ ` Refusing to start — it will NOT quietly move to another port, because whatever is in front `
|
|
167
219
|
+ `of it is still addressed to ${at}.`;
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
//
|
|
9
9
|
// node scripts/mint-names-in-force.mjs
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// CI refuses a stale copy, from the guards job: scripts/generated-files-are-current.mjs runs every
|
|
12
|
+
// minter in scripts/ with --check. (This line used to name a test file that does not exist in this
|
|
13
|
+
// tree, so the only thing that ever caught this drifting was a private control refusing to start.)
|
|
12
14
|
|
|
13
15
|
export const NAMES_IN_FORCE = Object.freeze([
|
|
14
16
|
"CLEAROTRON_ACCESS_DOMAIN",
|
|
@@ -43,6 +45,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
43
45
|
"CLEAROTRON_DEFAULT_AGENT",
|
|
44
46
|
"CLEAROTRON_DELIVERY",
|
|
45
47
|
"CLEAROTRON_DEMO",
|
|
48
|
+
"CLEAROTRON_DEMO_PROFILES",
|
|
46
49
|
"CLEAROTRON_DISPATCH_RECORD",
|
|
47
50
|
"CLEAROTRON_DOCTOR_ASSUME_PINNED",
|
|
48
51
|
"CLEAROTRON_DUMP_JSON",
|
|
@@ -67,6 +70,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
67
70
|
"CLEAROTRON_GATHER_SESSION_KEY",
|
|
68
71
|
"CLEAROTRON_HARD_MS",
|
|
69
72
|
"CLEAROTRON_HTTP_TIMEOUT_MS",
|
|
73
|
+
"CLEAROTRON_IDENTIFIER_BLOCKLIST",
|
|
70
74
|
"CLEAROTRON_INSTRUCTIONS_DIR",
|
|
71
75
|
"CLEAROTRON_INVOKED_AS",
|
|
72
76
|
"CLEAROTRON_JX_",
|
|
@@ -162,6 +166,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
162
166
|
"CLEAROTRON_SUITE_TELEMETRY_DIR",
|
|
163
167
|
"CLEAROTRON_SYNTHESIS_MODEL",
|
|
164
168
|
"CLEAROTRON_TAINT_CHAIN",
|
|
169
|
+
"CLEAROTRON_TEST_FIXTURE_PROFILES",
|
|
165
170
|
"CLEAROTRON_TURN_CAP",
|
|
166
171
|
"CLEAROTRON_TURN_LOCK_POLL_MS",
|
|
167
172
|
"CLEAROTRON_UNREACHABLE_SENIOR",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// THE MINIMUM NODE VERSION, READ FROM THE ONE PLACE THAT DECLARES IT.
|
|
5
|
+
//
|
|
6
|
+
// `package.json` says `engines: { node: ">=22.13.0" }`. npm reads that field, and so does everything
|
|
7
|
+
// here. Nothing restates the number.
|
|
8
|
+
//
|
|
9
|
+
// WHY THIS FILE EXISTS RATHER THAN A CONSTANT. There was a constant: `NODE_FLOOR = 22` in bin/onboard.mjs,
|
|
10
|
+
// compared as `Number(process.versions.node.split(".")[0]) >= NODE_FLOOR`. A major-only comparison
|
|
11
|
+
// STRUCTURALLY CANNOT SEE A MINOR FLOOR, so 22.9.0 passed a check written for a 22.13.0 floor — the check said
|
|
12
|
+
// `node 22.9.0` and a tick while npm, reading the same requirement from the same repository, printed
|
|
13
|
+
// EBADENGINE for it. One requirement, two spellings, already disagreeing.
|
|
14
|
+
//
|
|
15
|
+
// The cost was a first-run failure nobody could diagnose: an engine door exited 1 on a machine below the
|
|
16
|
+
// floor, and every check the product offers said the runtime was fine.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
/** The declared range, verbatim, e.g. `">=22.13.0"`. Throws if the field is gone — an absent floor is a
|
|
25
|
+
* packaging fault, not a licence to run on anything. */
|
|
26
|
+
export function declaredRange(root = join(HERE, "..")) {
|
|
27
|
+
const range = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))?.engines?.node;
|
|
28
|
+
if (!range) throw new Error("package.json declares no engines.node — the runtime floor has no source");
|
|
29
|
+
return String(range);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `">=22.13.0"` → [22, 13, 0]. `">=22"` and `">=22.13"` are accepted too, with the absent parts read as
|
|
34
|
+
* zero, because that is what they mean and not a guess — and because the field is edited by whoever
|
|
35
|
+
* changes the floor, who should not have to know which spelling this reader was written against. A
|
|
36
|
+
* coordination failure between two people editing one number is the defect this whole file exists for.
|
|
37
|
+
*
|
|
38
|
+
* ONLY the `>=` family. A caret or tilde range, or an `||` union, THROWS rather than being interpreted:
|
|
39
|
+
* a floor read wrongly is worse than one not read at all, because it would silently pass every version
|
|
40
|
+
* and take every check built on it with it.
|
|
41
|
+
*/
|
|
42
|
+
export function floorOf(range) {
|
|
43
|
+
const m = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/.exec(String(range).trim());
|
|
44
|
+
if (!m) throw new Error(`engines.node is ${range}, which this reader does not understand — expected ">=x", ">=x.y" or ">=x.y.z"`);
|
|
45
|
+
return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `"22.16.0"` → [22, 16, 0], ignoring any pre-release or build suffix. NULL when it cannot be read.
|
|
50
|
+
*
|
|
51
|
+
* Null rather than zeroes, and the difference is the whole of it: `[0, 0, 0]` compares as below every
|
|
52
|
+
* floor, so a version string this reader did not understand would REFUSE — and this file would become
|
|
53
|
+
* the reason an install fails on a Node that is probably fine. A parser gap must not become an outage.
|
|
54
|
+
* Found in review, 2026-09-08.
|
|
55
|
+
*/
|
|
56
|
+
export const partsOf = (v) => {
|
|
57
|
+
// THE `v` IS OPTIONAL AND MUST BE, because both spellings are in reach: `process.versions.node` has
|
|
58
|
+
// no prefix and `process.version` does. Without it a caller handed the prefixed form gets null,
|
|
59
|
+
// null passes, and an out-of-date runtime is waved through by the guard written to stop it — the
|
|
60
|
+
// permissive half of the same asymmetry, which is the direction that fails silently.
|
|
61
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim());
|
|
62
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Is `current` at or above `floor`? ALL THREE PARTS, in order — the defect this replaces compared one. */
|
|
66
|
+
export function meetsFloor(current, floor) {
|
|
67
|
+
const c = partsOf(current);
|
|
68
|
+
// AN UNREADABLE RUNNING VERSION PASSES, and that asymmetry is deliberate. The DECLARED range is our
|
|
69
|
+
// own manifest and throws loudly when it cannot be read, because that is a packaging fault we own.
|
|
70
|
+
// The running version is whatever the runtime reports, and refusing one we failed to parse would
|
|
71
|
+
// stop a working install over our own gap. Refuse only what we can show is below the floor.
|
|
72
|
+
if (!c) return true;
|
|
73
|
+
for (let i = 0; i < 3; i += 1) {
|
|
74
|
+
if (c[i] > floor[i]) return true;
|
|
75
|
+
if (c[i] < floor[i]) return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The whole answer, for a caller that wants to refuse.
|
|
82
|
+
*
|
|
83
|
+
* `current` and `root` are injected so a check can drive every side of this without a second Node
|
|
84
|
+
* installation — a floor guard that can only be exercised by running on an old runtime is one nobody
|
|
85
|
+
* ever sees fail.
|
|
86
|
+
*/
|
|
87
|
+
export function nodeFloorVerdict({ current = process.versions.node, root } = {}) {
|
|
88
|
+
const range = declaredRange(root);
|
|
89
|
+
const floor = floorOf(range);
|
|
90
|
+
return { ok: meetsFloor(current, floor), current, required: floor.join("."), range };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a person is told, in one sentence, naming both versions. No stack, no advice they cannot act on. */
|
|
94
|
+
export function nodeFloorRefusal(v) {
|
|
95
|
+
return `This needs Node ${v.required} or newer, and this is Node ${v.current}. `
|
|
96
|
+
+ "Upgrade Node and run the command again — nothing else here will work until you do.";
|
|
97
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// THE COMMANDS WE TELL A READER TO TYPE, IN THE SHELL THEY ARE ACTUALLY IN.
|
|
5
|
+
//
|
|
6
|
+
// Three surfaces print a command for the reader to run: the port-in-use refusal, the client door's
|
|
7
|
+
// version of the same, and the demo's "remove it later" line. All three printed POSIX commands
|
|
8
|
+
// unconditionally — `ss`, `lsof`, `rm -rf` — and the demo is supported on native Windows, where none
|
|
9
|
+
// of them exist. A reader who follows the instruction gets "not recognized as a cmdlet", which reads
|
|
10
|
+
// as a broken product rather than as advice written for somebody else's machine.
|
|
11
|
+
//
|
|
12
|
+
// Reported from the owner's own Windows run, 2026-09-09.
|
|
13
|
+
//
|
|
14
|
+
// ONE PLACE, BECAUSE THREE COPIES OF A RULE DRIFT. The alternative was a ternary at each call site,
|
|
15
|
+
// which is how the first one ends up saying `Remove-Item` while the second still says `rm -rf`.
|
|
16
|
+
//
|
|
17
|
+
// `platform` IS INJECTABLE ON EVERY FUNCTION, and that is the whole testability story: the population
|
|
18
|
+
// this protects is the one that cannot run this suite to find out. An arm on a Linux runner has to be
|
|
19
|
+
// able to ask what a Windows reader would be shown, or the Windows text is asserted by nobody.
|
|
20
|
+
//
|
|
21
|
+
// WHAT THIS DOES NOT DO: it does not detect a shell. A reader in Git Bash on Windows has POSIX
|
|
22
|
+
// commands and will be shown PowerShell ones. That is the right way round — `process.platform` is a
|
|
23
|
+
// fact and the shell is a guess, and being shown a command for the OS you are on is recoverable in a
|
|
24
|
+
// way that being shown one for an OS you are not on is not.
|
|
25
|
+
|
|
26
|
+
/** Is this the platform whose shell has none of the POSIX tools these messages used to name? */
|
|
27
|
+
const isWindows = (platform) => platform === "win32";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How to see which process holds a TCP port.
|
|
31
|
+
*
|
|
32
|
+
* The POSIX form keeps both spellings it always had: `ss` is on every modern Linux and `lsof` is the
|
|
33
|
+
* one a macOS reader has. The Windows form names the cmdlet AND how to turn its answer into a process,
|
|
34
|
+
* because `Get-NetTCPConnection` reports an owning PID and stops there — a reader given only that has
|
|
35
|
+
* been handed a number and no way to act on it.
|
|
36
|
+
*/
|
|
37
|
+
export function whatHoldsPort(port, { platform = process.platform } = {}) {
|
|
38
|
+
return isWindows(platform)
|
|
39
|
+
? `Get-NetTCPConnection -LocalPort ${port} | Select-Object -ExpandProperty OwningProcess | `
|
|
40
|
+
+ "ForEach-Object { Get-Process -Id $_ }"
|
|
41
|
+
: `ss -ltnp 'sport = :${port}' (or: lsof -i :${port})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How to stop the process that turned up, once the reader has found it. */
|
|
45
|
+
export const stopThatProcess = ({ platform = process.platform } = {}) =>
|
|
46
|
+
(isWindows(platform) ? "Stop-Process -Id <the PID above>" : "kill <the pid above>");
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How to remove one directory and everything under it.
|
|
50
|
+
*
|
|
51
|
+
* `-Recurse -Force` is the pair, and both are needed: without `-Recurse` PowerShell prompts for a
|
|
52
|
+
* non-empty directory, and a reader who is told to remove a directory and then asked to confirm each
|
|
53
|
+
* child has been given a command that does not do what the sentence said it would.
|
|
54
|
+
*/
|
|
55
|
+
export const removeDirectory = (dir, { platform = process.platform } = {}) =>
|
|
56
|
+
(isWindows(platform) ? `Remove-Item -Recurse -Force "${dir}"` : `rm -rf ${dir}`);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set one environment variable for the length of one command.
|
|
60
|
+
*
|
|
61
|
+
* `VAR=value cmd` is a POSIX shell construct with no PowerShell equivalent — there it is a statement of
|
|
62
|
+
* its own, so the pair has to be separated rather than juxtaposed. A reader who pastes the POSIX form
|
|
63
|
+
* into PowerShell is told the variable name is not a recognised cmdlet, which names the wrong half of
|
|
64
|
+
* the line as the problem.
|
|
65
|
+
*/
|
|
66
|
+
export const envPrefix = (name, value, { platform = process.platform } = {}) =>
|
|
67
|
+
(isWindows(platform) ? `$env:${name}="${value}"; ` : `${name}=${value} `);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Move to a directory, then run what follows.
|
|
71
|
+
*
|
|
72
|
+
* `&&` IS THE PART THAT FAILS, not the path. Windows PowerShell 5.1 — still the default shell on a
|
|
73
|
+
* stock machine — has no `&&` operator at all, so `cd X && npx …` is a parse error there before the
|
|
74
|
+
* backslashes matter. `;` runs in both 5.1 and 7, and quoting the directory covers the spaces a
|
|
75
|
+
* Windows home path routinely carries.
|
|
76
|
+
*
|
|
77
|
+
* The POSIX form keeps `&&` deliberately: it is not a separator there, it is a guard, and running the
|
|
78
|
+
* command from the wrong directory because the `cd` failed is worse than not running it.
|
|
79
|
+
*/
|
|
80
|
+
export const chdirPrefix = (dir, { platform = process.platform } = {}) =>
|
|
81
|
+
(isWindows(platform) ? `cd "${dir}"; ` : `cd ${dir} && `);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What keeps a background instance alive, named for the thing that actually does it.
|
|
85
|
+
*
|
|
86
|
+
* `null` where this platform has no such mechanism in the product, so a caller prints nothing rather
|
|
87
|
+
* than naming a service manager the reader does not have. Windows was told the background form was
|
|
88
|
+
* "managed by systemd", which is not on the machine and cannot be installed onto it.
|
|
89
|
+
*/
|
|
90
|
+
export const backgroundManager = ({ platform = process.platform } = {}) =>
|
|
91
|
+
(isWindows(platform) ? null : "systemd");
|
package/shared/reap-on-exit.mjs
CHANGED
|
@@ -36,8 +36,27 @@
|
|
|
36
36
|
//
|
|
37
37
|
// IDEMPOTENT, and unregistering is the caller's normal path: a child that exits on its own drops out,
|
|
38
38
|
// so the reaper never signals a pid that has been recycled onto somebody else's process.
|
|
39
|
+
//
|
|
40
|
+
// ── WHY DIRECTORY REMOVAL LIVES IN THIS FILE AND NOT BESIDE IT ────────────────────────────────────
|
|
41
|
+
//
|
|
42
|
+
// A browser run's temp root can only be removed AFTER its process group is dead. Chrome's renderer,
|
|
43
|
+
// GPU and zygote children keep writing the profile while they live, so a removal that runs first
|
|
44
|
+
// races them and throws ENOTEMPTY — a race three of the call sites below already carry a comment
|
|
45
|
+
// about, on their own success paths.
|
|
46
|
+
//
|
|
47
|
+
// Two separate exit handlers cannot express that ordering: Node runs `exit` listeners in
|
|
48
|
+
// registration order, so the guarantee would depend on which module a caller happened to import
|
|
49
|
+
// first. One handler doing both, in the order written here, is the whole reason `removeOnExit` is
|
|
50
|
+
// not its own module.
|
|
51
|
+
//
|
|
52
|
+
// The removal is best-effort for the same reason the kill is: a handler that throws on the way out
|
|
53
|
+
// of a crashing script replaces one problem with a worse one. `maxRetries` covers ENOTEMPTY, which
|
|
54
|
+
// is the error the surviving-children race actually produces.
|
|
55
|
+
|
|
56
|
+
import { rmSync } from "node:fs";
|
|
39
57
|
|
|
40
58
|
const groups = new Set();
|
|
59
|
+
const dirs = new Set();
|
|
41
60
|
let installed = false;
|
|
42
61
|
|
|
43
62
|
function reapAll() {
|
|
@@ -49,6 +68,11 @@ function reapAll() {
|
|
|
49
68
|
try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } }
|
|
50
69
|
}
|
|
51
70
|
groups.clear();
|
|
71
|
+
// ONLY NOW, with the groups signalled, is the temp root removable. See the note above.
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* a stray temp root is not worth failing an exit over */ }
|
|
74
|
+
}
|
|
75
|
+
dirs.clear();
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
function install() {
|
|
@@ -77,7 +101,25 @@ export function reapOnExit(child) {
|
|
|
77
101
|
return stop;
|
|
78
102
|
}
|
|
79
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Remove `dir` when THIS process exits, by any route it can observe, and only after every watched
|
|
106
|
+
* process group has been signalled.
|
|
107
|
+
*
|
|
108
|
+
* @returns {() => void} stop watching, for a caller that removes the directory itself.
|
|
109
|
+
*/
|
|
110
|
+
export function removeOnExit(dir) {
|
|
111
|
+
if (typeof dir !== "string" || dir === "") return () => {};
|
|
112
|
+
dirs.add(dir);
|
|
113
|
+
install();
|
|
114
|
+
return () => dirs.delete(dir);
|
|
115
|
+
}
|
|
116
|
+
|
|
80
117
|
/** The pids currently watched. For arms — a reaper nobody can inspect is a reaper nobody can test. */
|
|
81
118
|
export function watchedGroups() {
|
|
82
119
|
return [...groups];
|
|
83
120
|
}
|
|
121
|
+
|
|
122
|
+
/** The directories currently watched, for the same reason. */
|
|
123
|
+
export function watchedDirs() {
|
|
124
|
+
return [...dirs];
|
|
125
|
+
}
|