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.
Files changed (76) hide show
  1. package/.env.example +36 -37
  2. package/CONTRIBUTING.md +8 -4
  3. package/INSTALL.md +24 -4
  4. package/README.md +7 -6
  5. package/bin/example.mjs +6 -5
  6. package/bin/onboard.mjs +175 -9
  7. package/bin/start.mjs +66 -4
  8. package/build-info.json +2 -2
  9. package/demo/README.md +1 -1
  10. package/docs/GLOSSARY.md +85 -0
  11. package/docs/README.md +1 -0
  12. package/docs/architecture/01-product-overview.md +21 -9
  13. package/docs/architecture/05-config-governance.md +5 -0
  14. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  15. package/driver/CHANGELOG.md +34 -0
  16. package/driver/README.md +25 -6
  17. package/driver/connotation-search.mjs +1 -1
  18. package/driver/contract-audit.mjs +5 -1
  19. package/driver/contract-e3-baseline.json +11 -11
  20. package/driver/doubt-selection.mjs +1 -1
  21. package/driver/drainer-identity.mjs +1 -1
  22. package/driver/effort-model.mjs +2 -2
  23. package/driver/engine/probe.mjs +45 -5
  24. package/driver/gateway.mjs +2 -2
  25. package/driver/outbox-backoff.mjs +1 -1
  26. package/driver/package.json +1 -1
  27. package/driver/pipeline.mjs +206 -101
  28. package/driver/plain-register.mjs +16 -2
  29. package/driver/portal-config-view.mjs +30 -1
  30. package/driver/portal-local-auth.mjs +5 -1
  31. package/driver/portal-service.mjs +53 -2
  32. package/driver/predelivery-lint.mjs +54 -25
  33. package/driver/publish/render.mjs +109 -14
  34. package/driver/search-policy.mjs +1 -1
  35. package/driver/stage-context.mjs +13 -0
  36. package/driver/stages.mjs +51 -4
  37. package/driver/suite-census.json +97 -31
  38. package/driver/systemd/clearotron-worker.service +3 -3
  39. package/driver/tokens.mjs +1 -1
  40. package/driver/unit-inventory.mjs +34 -4
  41. package/mcp-server/CHANGELOG.md +2 -0
  42. package/mcp-server/package.json +1 -1
  43. package/package.json +4 -10
  44. package/portal-ui/dist/assets/{index-CEYvXdqH.js → index-KFAHMgdT.js} +73 -34
  45. package/portal-ui/dist/index.html +1 -1
  46. package/portal-ui/package.json +1 -1
  47. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  48. package/providers/oauth-mcp-bridge/package.json +1 -1
  49. package/scripts/added-reference-check.mjs +45 -89
  50. package/scripts/e2e.mjs +2 -2
  51. package/scripts/generated-files-are-current.mjs +125 -0
  52. package/scripts/mint-names-in-force.mjs +4 -2
  53. package/scripts/mint-public-residue.mjs +74 -0
  54. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  55. package/scripts/mint-suite-census.mjs +5 -2
  56. package/scripts/render-check.mjs +13 -2
  57. package/scripts/report-frame-check.mjs +8 -1
  58. package/scripts/report-screenshot.mjs +6 -3
  59. package/scripts/revisit-render-check.mjs +6 -3
  60. package/scripts/score.mjs +1 -1
  61. package/scripts/strip-tracker-citations.mjs +9 -1
  62. package/scripts/test-full.mjs +226 -0
  63. package/shared/README.md +2 -1
  64. package/shared/browser-temp-root.mjs +142 -0
  65. package/shared/checkout-move.mjs +1 -1
  66. package/shared/client-door.mjs +4 -3
  67. package/shared/identifier-scan.mjs +1 -1
  68. package/shared/install-auth.mjs +35 -0
  69. package/shared/invocation.mjs +5 -1
  70. package/shared/listen.mjs +55 -3
  71. package/shared/names-in-force.mjs +4 -1
  72. package/shared/os-advice.mjs +91 -0
  73. package/shared/reap-on-exit.mjs +42 -0
  74. package/shared/reference-guard-classes.mjs +351 -0
  75. package/shared/suite-census.mjs +31 -3
  76. package/shared/withheld-paths-access.mjs +36 -18
@@ -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 by role-e2e: a `connect` that died at `daemon-reload` had ALREADY written the denylist and
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; role-e2e measured `doctor`
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
- + "the port and the variable, and `ss -ltnp` names the process." };
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-blocklist.mjs owns the names (sentinels, or sentinels ∪ the
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.
@@ -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));
@@ -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
- form: "in-place", prefix: `cd ${standFrom(installDir)} && npx `, shim: path, dir,
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 `ss -ltnp`, which is the instruction that works.
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
- + ` See what holds it: ss -ltnp 'sport = :${port}' (or: lsof -i :${port})\n`
164
- + ` Then stop that process${move ? `, or ${move}` : ""}.\n`
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
- // driver/test/retired-env-spellings.test.mjs regenerates this and refuses a stale copy.
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");
@@ -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
+ }