clearotron 0.2.3 → 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 +36 -37
- package/CONTRIBUTING.md +8 -4
- package/INSTALL.md +24 -4
- package/README.md +7 -6
- package/bin/example.mjs +6 -5
- package/bin/onboard.mjs +175 -9
- package/bin/start.mjs +66 -4
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/docs/GLOSSARY.md +85 -0
- package/docs/README.md +1 -0
- package/docs/architecture/01-product-overview.md +21 -9
- package/docs/architecture/05-config-governance.md +5 -0
- package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
- package/driver/CHANGELOG.md +34 -0
- package/driver/README.md +25 -6
- package/driver/connotation-search.mjs +1 -1
- package/driver/contract-audit.mjs +5 -1
- package/driver/contract-e3-baseline.json +11 -11
- package/driver/doubt-selection.mjs +1 -1
- package/driver/drainer-identity.mjs +1 -1
- package/driver/effort-model.mjs +2 -2
- package/driver/engine/probe.mjs +45 -5
- package/driver/gateway.mjs +2 -2
- package/driver/outbox-backoff.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +206 -101
- package/driver/plain-register.mjs +16 -2
- package/driver/portal-config-view.mjs +30 -1
- package/driver/portal-local-auth.mjs +5 -1
- package/driver/portal-service.mjs +53 -2
- package/driver/predelivery-lint.mjs +54 -25
- package/driver/publish/render.mjs +109 -14
- package/driver/search-policy.mjs +1 -1
- package/driver/stage-context.mjs +13 -0
- package/driver/stages.mjs +51 -4
- package/driver/suite-census.json +97 -31
- package/driver/systemd/clearotron-worker.service +3 -3
- package/driver/tokens.mjs +1 -1
- package/driver/unit-inventory.mjs +34 -4
- package/mcp-server/CHANGELOG.md +2 -0
- package/mcp-server/package.json +1 -1
- package/package.json +4 -10
- package/portal-ui/dist/assets/{index-CEYvXdqH.js → index-KFAHMgdT.js} +73 -34
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/added-reference-check.mjs +45 -89
- package/scripts/e2e.mjs +2 -2
- package/scripts/generated-files-are-current.mjs +125 -0
- 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/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 +9 -1
- package/scripts/test-full.mjs +226 -0
- package/shared/README.md +2 -1
- package/shared/browser-temp-root.mjs +142 -0
- package/shared/checkout-move.mjs +1 -1
- package/shared/client-door.mjs +4 -3
- package/shared/identifier-scan.mjs +1 -1
- package/shared/install-auth.mjs +35 -0
- package/shared/invocation.mjs +5 -1
- package/shared/listen.mjs +55 -3
- package/shared/names-in-force.mjs +4 -1
- 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/suite-census.mjs +31 -3
- package/shared/withheld-paths-access.mjs +36 -18
package/shared/client-door.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
import { join } from "node:path";
|
|
43
43
|
import { challengeVerdict, blockedByAccessChallenge, challengeNote } from "./mcp-challenge.mjs"; // — F57
|
|
44
|
+
import { whatHoldsPort } from "./os-advice.mjs";
|
|
44
45
|
|
|
45
46
|
/** The unit that runs the client door. Installed like any other; started only by this module. */
|
|
46
47
|
export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
|
|
@@ -162,7 +163,7 @@ export function clientDoorState({ env = {}, unitDir, exists, active = null, list
|
|
|
162
163
|
const standing = fenceOn && unitInstalled;
|
|
163
164
|
// ── CONFIGURED IS NOT RUNNING ──────────────────────────────────────────────
|
|
164
165
|
//
|
|
165
|
-
// Measured
|
|
166
|
+
// Measured in testing: a `connect` that died at `daemon-reload` had ALREADY written the denylist and
|
|
166
167
|
// installed both unit files, and `doctor` then said "the client door is on" while the unit was
|
|
167
168
|
// inactive and nothing listened on its port. The trigger was a shell without a session bus, which is
|
|
168
169
|
// not the product's fault — but any failure at that step leaves the same half-applied state
|
|
@@ -187,7 +188,7 @@ export function clientDoorState({ env = {}, unitDir, exists, active = null, list
|
|
|
187
188
|
* FIVE answers, and the two that matter are the two the caller used to get wrong in opposite directions.
|
|
188
189
|
*
|
|
189
190
|
* CONFIGURED IS NOT RUNNING. A `connect` that died at `daemon-reload` has already
|
|
190
|
-
* written the fence and placed both units, so every angle reads as set up;
|
|
191
|
+
* written the fence and placed both units, so every angle reads as set up; testing measured `doctor`
|
|
191
192
|
* saying "the client door is on" over an inactive unit with nothing on its port. That state is a
|
|
192
193
|
* problem and it is the reason this function exists.
|
|
193
194
|
*
|
|
@@ -278,7 +279,7 @@ export function describeDoorState(door, {
|
|
|
278
279
|
+ "That is very likely why it cannot start: the product's ports are fixed defaults, so another "
|
|
279
280
|
+ "install or a stray process holds it, and the unit fails over and over against a port it will "
|
|
280
281
|
+ `never get. Find the holder before re-applying anything — \`${startCmd}\`'s own refusal names `
|
|
281
|
-
+
|
|
282
|
+
+ `the port and the variable, and \`${whatHoldsPort("<the door's port>")}\` names the process.` };
|
|
282
283
|
}
|
|
283
284
|
if (looping) {
|
|
284
285
|
return { level: "problem",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// So the rules live here and nowhere else. Anything that wants to know whether a line is clean imports
|
|
14
14
|
// from this file.
|
|
15
15
|
//
|
|
16
|
-
// THE TABLE IS NOT HERE. shared/identifier-
|
|
16
|
+
// THE TABLE IS NOT HERE. shared/identifier-sentinels.mjs owns the names (sentinels, or sentinels ∪ the
|
|
17
17
|
// private roster). This file owns only the matching — it names no identity of its own beyond the
|
|
18
18
|
// platform tokens ALLOWED_CONTEXT has to spell out, which is why it declares itself in
|
|
19
19
|
// DECLARATION_SOURCES.
|
package/shared/install-auth.mjs
CHANGED
|
@@ -124,3 +124,38 @@ export function describeAuthGaps({ gaps, modes }, envFile) {
|
|
|
124
124
|
}
|
|
125
125
|
return out;
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
// ── WHAT PUTS SOMETHING IN FRONT OF A DOOR ───────────────────────────────────────────────────────────
|
|
129
|
+
//
|
|
130
|
+
// A different question from the one above, and kept apart from it deliberately: `authRequirements`
|
|
131
|
+
// answers "what will this unit refuse to start without", which includes values that say nothing about
|
|
132
|
+
// what is in front of it — a grants file, a local passphrase user, an allowed-domain list. This answers
|
|
133
|
+
// "does something OUTSIDE this deployment resolve to these port numbers", which is the only question a
|
|
134
|
+
// launcher may move a door on.
|
|
135
|
+
//
|
|
136
|
+
// Every name here is an alternative in some door's `oneOf`: a Cloudflare Access team, or that door's own
|
|
137
|
+
// OIDC issuer. The AUDIENCES are deliberately absent — they are `all` entries, not alternatives, and an
|
|
138
|
+
// audience set with neither a team nor an issuer refuses to start on every face, so it can never be the
|
|
139
|
+
// only evidence of a proxy.
|
|
140
|
+
//
|
|
141
|
+
// THE CLIENT DOOR HAS ITS OWN ISSUER SPELLING and it is not legacy: `mcp-server/http-server-client.mjs`
|
|
142
|
+
// reads `CLIENT_MCP_OIDC_ISSUER || TRADEMARK_MCP_OIDC_ISSUER`, and its fail-closed admits a start on the
|
|
143
|
+
// client spelling alone with no team set. A deployment fronting only its client door that way once read
|
|
144
|
+
// as unfronted here, which is the state that would have moved a door behind a proxy addressed to the old
|
|
145
|
+
// number — up, and unreachable.
|
|
146
|
+
//
|
|
147
|
+
// An arm holds this list to the doors themselves rather than to this comment: it reads the entrypoints
|
|
148
|
+
// `bin/start.mjs` spawns and asserts every team-or-issuer name they read appears below.
|
|
149
|
+
export const FRONTING_VARIABLES = Object.freeze([
|
|
150
|
+
"CF_ACCESS_TEAM",
|
|
151
|
+
"PORTAL_OIDC_ISSUER",
|
|
152
|
+
"TRADEMARK_MCP_OIDC_ISSUER",
|
|
153
|
+
"CLIENT_MCP_OIDC_ISSUER",
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The fronting values this environment has set — empty means nothing outside resolves to these doors.
|
|
158
|
+
*
|
|
159
|
+
* @returns {string[]} the names that are set, in the order above
|
|
160
|
+
*/
|
|
161
|
+
export const frontingVariablesSet = (env = {}) => FRONTING_VARIABLES.filter((k) => SET(env, k));
|
package/shared/invocation.mjs
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import { existsSync } from "node:fs";
|
|
40
40
|
import { basename, sep } from "node:path";
|
|
41
41
|
import { INSTALL_DIR, inspectShim, pathPosition, shimDir, shimPath } from "./verb-shim.mjs";
|
|
42
|
+
import { chdirPrefix } from "./os-advice.mjs";
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Filesystem reads, injectable so the arms can drive a machine that is not this one.
|
|
@@ -209,7 +210,10 @@ export function invocationForm(env = process.env, io = FS, installDir = INSTALL_
|
|
|
209
210
|
// to fix it — through the very shim it had just called broken. The advice for repairing a route
|
|
210
211
|
// cannot travel that route. Both remaining forms go around it.
|
|
211
212
|
return {
|
|
212
|
-
|
|
213
|
+
// THE SEPARATOR IS THE PART THAT FAILS OFF POSIX, not the path. `chdirPrefix` answers for the
|
|
214
|
+
// shell the reader is actually in; on Windows PowerShell 5.1 there is no `&&` at all, so this
|
|
215
|
+
// line was a parse error there before its backslashes mattered. Reported from a real run.
|
|
216
|
+
form: "in-place", prefix: `${chdirPrefix(standFrom(installDir))}npx `, shim: path, dir,
|
|
213
217
|
onPath: false, shadowedBy: null, shimKind: shim.kind, otherInstall: shim.installDir,
|
|
214
218
|
staleInterpreter: shim.interpreterMissing === true ? shim.interpreter : null,
|
|
215
219
|
};
|
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",
|
|
@@ -68,6 +70,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
68
70
|
"CLEAROTRON_GATHER_SESSION_KEY",
|
|
69
71
|
"CLEAROTRON_HARD_MS",
|
|
70
72
|
"CLEAROTRON_HTTP_TIMEOUT_MS",
|
|
73
|
+
"CLEAROTRON_IDENTIFIER_BLOCKLIST",
|
|
71
74
|
"CLEAROTRON_INSTRUCTIONS_DIR",
|
|
72
75
|
"CLEAROTRON_INVOKED_AS",
|
|
73
76
|
"CLEAROTRON_JX_",
|
|
@@ -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
|
+
}
|