@a11ign/screenreader-fleet 0.1.0 → 0.3.0

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 (129) hide show
  1. package/dist/capture-client.d.mts +0 -1
  2. package/dist/capture-client.mjs +144 -306
  3. package/dist/check-worker-code.d.mts +0 -1
  4. package/dist/check-worker-code.mjs +42 -141
  5. package/dist/cli-flags.d.mts +0 -1
  6. package/dist/cli-flags.mjs +33 -179
  7. package/dist/code-drift.d.mts +0 -1
  8. package/dist/command-line-census.d.mts +0 -1
  9. package/dist/compare-workers.d.mts +0 -1
  10. package/dist/compare-workers.mjs +383 -255
  11. package/dist/control-plane-isolation.d.mts +0 -1
  12. package/dist/deploy-worker.d.mts +0 -1
  13. package/dist/deploy-worker.mjs +101 -242
  14. package/dist/doctor.d.mts +0 -1
  15. package/dist/doctor.mjs +361 -809
  16. package/dist/fleet-consistency.d.mts +0 -1
  17. package/dist/fleet-consistency.mjs +155 -379
  18. package/dist/fleet-env.d.mts +0 -1
  19. package/dist/fleet-env.mjs +148 -438
  20. package/dist/fleet-scripts.d.mts +0 -1
  21. package/dist/git-safe-env.d.mts +0 -1
  22. package/dist/guest-run.d.mts +0 -1
  23. package/dist/host-address.d.mts +0 -1
  24. package/dist/host-address.mjs +19 -90
  25. package/dist/host-capacity.d.mts +0 -1
  26. package/dist/host-capacity.mjs +22 -136
  27. package/dist/host-metrics.d.mts +0 -1
  28. package/dist/index.d.ts +0 -1
  29. package/dist/index.mjs +231 -0
  30. package/dist/local-vm.d.ts +0 -1
  31. package/dist/measure-guard.d.mts +0 -1
  32. package/dist/normalise-fleet.d.mts +0 -1
  33. package/dist/npm-cli-executable.d.mts +0 -1
  34. package/dist/probe-outcome.d.mts +0 -1
  35. package/dist/probe-outcome.mjs +50 -96
  36. package/dist/protocol-guard.d.mts +0 -1
  37. package/dist/source-walk.d.mts +0 -1
  38. package/dist/src_fleet-scripts_mjs.mjs +16 -0
  39. package/dist/src_git-safe-env_mjs.mjs +9 -0
  40. package/dist/src_utm-deprecated_mjs.mjs +4 -0
  41. package/dist/transient-fault.d.mts +0 -1
  42. package/dist/transient-fault.mjs +21 -81
  43. package/dist/utm-deprecated.d.mts +0 -1
  44. package/dist/worker-code-check.d.mts +0 -1
  45. package/dist/worker-code-check.mjs +127 -76
  46. package/dist/worker-health.d.mts +0 -1
  47. package/dist/worker-health.mjs +16 -61
  48. package/dist/worker-http.d.mts +0 -1
  49. package/dist/worker-http.mjs +42 -234
  50. package/dist/worker-stats.d.mts +0 -1
  51. package/package.json +12 -5
  52. package/dist/capture-client.d.mts.map +0 -1
  53. package/dist/capture-client.mjs.map +0 -1
  54. package/dist/check-worker-code.d.mts.map +0 -1
  55. package/dist/check-worker-code.mjs.map +0 -1
  56. package/dist/cli-flags.d.mts.map +0 -1
  57. package/dist/cli-flags.mjs.map +0 -1
  58. package/dist/code-drift.d.mts.map +0 -1
  59. package/dist/code-drift.mjs +0 -284
  60. package/dist/code-drift.mjs.map +0 -1
  61. package/dist/command-line-census.d.mts.map +0 -1
  62. package/dist/command-line-census.mjs +0 -96
  63. package/dist/command-line-census.mjs.map +0 -1
  64. package/dist/compare-workers.d.mts.map +0 -1
  65. package/dist/compare-workers.mjs.map +0 -1
  66. package/dist/control-plane-isolation.d.mts.map +0 -1
  67. package/dist/control-plane-isolation.mjs +0 -67
  68. package/dist/control-plane-isolation.mjs.map +0 -1
  69. package/dist/deploy-worker.d.mts.map +0 -1
  70. package/dist/deploy-worker.mjs.map +0 -1
  71. package/dist/doctor.d.mts.map +0 -1
  72. package/dist/doctor.mjs.map +0 -1
  73. package/dist/fleet-consistency.d.mts.map +0 -1
  74. package/dist/fleet-consistency.mjs.map +0 -1
  75. package/dist/fleet-env.d.mts.map +0 -1
  76. package/dist/fleet-env.mjs.map +0 -1
  77. package/dist/fleet-scripts.d.mts.map +0 -1
  78. package/dist/fleet-scripts.mjs +0 -41
  79. package/dist/fleet-scripts.mjs.map +0 -1
  80. package/dist/git-safe-env.d.mts.map +0 -1
  81. package/dist/git-safe-env.mjs +0 -44
  82. package/dist/git-safe-env.mjs.map +0 -1
  83. package/dist/guest-run.d.mts.map +0 -1
  84. package/dist/guest-run.mjs +0 -164
  85. package/dist/guest-run.mjs.map +0 -1
  86. package/dist/host-address.d.mts.map +0 -1
  87. package/dist/host-address.mjs.map +0 -1
  88. package/dist/host-capacity.d.mts.map +0 -1
  89. package/dist/host-capacity.mjs.map +0 -1
  90. package/dist/host-metrics.d.mts.map +0 -1
  91. package/dist/host-metrics.mjs +0 -201
  92. package/dist/host-metrics.mjs.map +0 -1
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -25
  95. package/dist/index.js.map +0 -1
  96. package/dist/local-vm.d.ts.map +0 -1
  97. package/dist/local-vm.js +0 -360
  98. package/dist/local-vm.js.map +0 -1
  99. package/dist/measure-guard.d.mts.map +0 -1
  100. package/dist/measure-guard.mjs +0 -73
  101. package/dist/measure-guard.mjs.map +0 -1
  102. package/dist/normalise-fleet.d.mts.map +0 -1
  103. package/dist/normalise-fleet.mjs +0 -76
  104. package/dist/normalise-fleet.mjs.map +0 -1
  105. package/dist/npm-cli-executable.d.mts.map +0 -1
  106. package/dist/npm-cli-executable.mjs +0 -159
  107. package/dist/npm-cli-executable.mjs.map +0 -1
  108. package/dist/probe-outcome.d.mts.map +0 -1
  109. package/dist/probe-outcome.mjs.map +0 -1
  110. package/dist/protocol-guard.d.mts.map +0 -1
  111. package/dist/protocol-guard.mjs +0 -121
  112. package/dist/protocol-guard.mjs.map +0 -1
  113. package/dist/source-walk.d.mts.map +0 -1
  114. package/dist/source-walk.mjs +0 -56
  115. package/dist/source-walk.mjs.map +0 -1
  116. package/dist/transient-fault.d.mts.map +0 -1
  117. package/dist/transient-fault.mjs.map +0 -1
  118. package/dist/utm-deprecated.d.mts.map +0 -1
  119. package/dist/utm-deprecated.mjs +0 -23
  120. package/dist/utm-deprecated.mjs.map +0 -1
  121. package/dist/worker-code-check.d.mts.map +0 -1
  122. package/dist/worker-code-check.mjs.map +0 -1
  123. package/dist/worker-health.d.mts.map +0 -1
  124. package/dist/worker-health.mjs.map +0 -1
  125. package/dist/worker-http.d.mts.map +0 -1
  126. package/dist/worker-http.mjs.map +0 -1
  127. package/dist/worker-stats.d.mts.map +0 -1
  128. package/dist/worker-stats.mjs +0 -143
  129. package/dist/worker-stats.mjs.map +0 -1
@@ -1,128 +1,46 @@
1
1
  #!/usr/bin/env node
2
- // @ts-check
3
- // Is every worker running the code in this checkout?
4
- //
5
- // npm run worker:code
6
- //
7
- // Deploying is push-then-restart and both halves fail silently: `utmctl exec` reports success
8
- // whether or not it ran, so a worker can serve the previous process indefinitely. Reading the
9
- // guest's file hash does not help, because that read goes through exec too -- when exec is
10
- // dead the check returns empty rather than mismatched, which reads as a flaky tool instead of a
11
- // failed deploy. That cost an hour, and the stale workers looked exactly like a logic bug.
12
- //
13
- // This asks each worker over HTTP, which is reachable exactly when the worker is usable and
14
- // involves no guest agent. Exit 0 when every worker matches, 1 otherwise.
15
2
  import { realpathSync } from "node:fs";
16
3
  import { pathToFileURL } from "node:url";
17
4
  import { execFileSync } from "node:child_process";
18
- import { sandboxGitEnv } from "./git-safe-env.mjs";
19
- // The WORKING-TREE value, imported rather than regex-scraped — architecture-audit.md §5, item 3.
20
- // `protocol-version.mjs` is dependency-free for exactly this: safe to import from a portable tree.
21
- import { CAPTURE_PROTOCOL_VERSION as PROTOCOL_IN_TREE } from "@a11ign/screenreader-worker/protocol-version";
22
- import { workerSourceDir } from "@a11ign/screenreader-worker/code-version";
23
- import { fleetScriptPaths } from "./fleet-scripts.mjs";
24
- import { configuredWorkers, inventoryWorkerUrls, resolveWorkerPool } from "./fleet-env.mjs";
25
- // The comparison, the remedy and the expected hash live in ONE place, because the capture entry points ask
26
- // the same question before every run and a second copy of "is this worker stale" is a second answer.
27
- import { expectedWorkerCode, codeDrift, remedyLines } from "./worker-code-check.mjs";
28
- import { refuseUnknownFlags } from "./cli-flags.mjs";
29
5
  import { errorText } from "@a11ign/screenreader-worker/error-text";
6
+ import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
7
+ import { inventoryWorkerUrls, configuredWorkers, resolveWorkerPool } from "./fleet-env.mjs";
8
+ import { expectedWorkerCode, remedyLines, codeDrift } from "./worker-code-check.mjs";
9
+ import { refuseUnknownFlags } from "./cli-flags.mjs";
30
10
  import { requestJson } from "./worker-http.mjs";
31
- /**
32
- * takes NO flags — it asks every worker what code it is running and compares. Any flag passed to it
33
- * today is discarded in silence.
34
- *
35
- * An unrecognised flag is otherwise IGNORED, so it runs the default and reports success.
36
- */
37
- refuseUnknownFlags([], { entry: import.meta.url, command: "npm run worker:code" });
38
- // Resolved from THIS module: the fleet scripts ship with this package, so a cwd-relative path was only ever
39
- // right when run from the repo root.
11
+ refuseUnknownFlags([], {
12
+ entry: import.meta.url,
13
+ command: "npm run worker:code"
14
+ });
40
15
  const CTL = fleetScriptPaths().workerCtl;
41
- // /health now reports installed runtime versions as well as the code hash. The first
42
- // request after a Windows boot may need PowerShell file-version discovery, so four seconds
43
- // was too tight and made a healthy worker look unreachable.
44
16
  const HEALTH_TIMEOUT_MS = 15000;
45
- // The guest and the host now call the SAME function over the SAME list, so they cannot disagree by
46
- // construction. This used to be two copies of one loop kept in step by a comment.
47
- /**
48
- * A STALE report is usually a real stale guest — but not when the working tree carries an uncommitted
49
- * CAPTURE_PROTOCOL_VERSION bump. Then every worker reports stale because the LOCAL hash moved, and the
50
- * obvious remedy (redeploy) would ship the bump and invalidate every cached capture.
51
- *
52
- * Saying so here costs one line and saves someone an unexplained full recapture.
53
- */
54
- function protocolBumpNote() {
55
- try {
56
- const inTree = String(PROTOCOL_IN_TREE);
57
- const committed = /CAPTURE_PROTOCOL_VERSION = (\d+)/.exec(execFileSync("git", ["-C", workerSourceDir(), "show", "HEAD:./protocol-version.mjs"], { encoding: "utf8", env: sandboxGitEnv() }))?.[1];
58
- if (inTree && committed && inTree !== committed) {
59
- return `\nNOTE: your working tree has CAPTURE_PROTOCOL_VERSION = ${inTree} but HEAD has ${committed}.\n` +
60
- "That alone changes the local hash, so the guests may not be stale at all. Deploying it would\n" +
61
- "invalidate every cached capture — `npm run worker:deploy` refuses unless you pass\n" +
62
- "--allow-protocol-change.\n";
63
- }
64
- }
65
- catch { /* not a git checkout; nothing to add */ }
66
- return "";
17
+ function workerUrls({ named = configuredWorkers, local = localPoolUrls, inventory = inventoryWorkerUrls } = {}) {
18
+ return resolveWorkerPool({
19
+ named,
20
+ inventory,
21
+ local
22
+ });
67
23
  }
68
- /**
69
- * Which workers to ask, AND WHERE THAT LIST CAME FROM — the second half is not decoration.
70
- *
71
- * This used to answer the local UTM pool or nothing, and print "no worker is running — nothing to compare"
72
- * when it found neither. Measured 2026-08-28 with five bare-metal workers serving `/health` and all five
73
- * STALE against this checkout: the bare command reported nothing to compare, and the same command with
74
- * `A11Y_WORKERS` set reported `5 stale worker(s)`. One env var apart, and the quiet answer was the wrong one.
75
- *
76
- * That is `lab:inventory`'s lesson at a different layer — *"'none here' and 'none anywhere' are different
77
- * answers, and it now refuses to turn the first into the second"* — and it lands harder here, because this
78
- * command exists to stop a corpus being captured on the wrong code. A false clean from it is the failure it
79
- * was written to prevent, delivered by the tool itself.
80
- *
81
- * The inventory was already imported and already read, twelve lines below, to print the REMEDY. So the
82
- * command could name the five workers it should have checked while insisting it had none to check.
83
- *
84
- * Reading it here is safe in a way it would not be for a capture run: this probes `/health` and starts
85
- * nothing, so the rule that naming workers means you are managing them does not apply.
86
- */
87
- export function workerUrls({ named = configuredWorkers, local = localPoolUrls, inventory = inventoryWorkerUrls, } = {}) {
88
- // ONE PRECEDENCE, in fleet-env.mjs. This function held its own -- named, then the LOCAL UTM POOL, then
89
- // the inventory -- while `doctor` went named then inventory, so on any Mac with a registered guest the
90
- // two commands described different fleets. That is the divergence the comment here used to claim it had
91
- // closed; it closed the NAMED half only.
92
- return resolveWorkerPool({ named, inventory, local });
93
- }
94
- /** The local UTM pool, or none — `utmctl` is absent on a machine that never had one. */
95
24
  function localPoolUrls() {
96
25
  try {
97
- const pool = JSON.parse(execFileSync(CTL, ["pool"], { encoding: "utf8" }));
98
- return pool.filter((/** @type {{ip?: string}} */ vm) => vm.ip)
99
- .map((/** @type {{ip: string, port: number}} */ vm) => `http://${vm.ip}:${vm.port}`);
100
- }
101
- catch (e) {
102
- // Never silent: "there is no UTM here" and "utmctl failed" are different, and the second is the one
103
- // that would otherwise send somebody to the inventory believing the local pool was empty.
104
- if (process.env.A11Y_DEBUG)
105
- console.log(` (local pool unavailable: ${errorText(e)})`);
26
+ const pool = JSON.parse(execFileSync(CTL, [
27
+ "pool"
28
+ ], {
29
+ encoding: "utf8"
30
+ }));
31
+ return pool.filter((vm)=>vm.ip).map((vm)=>`http://${vm.ip}:${vm.port}`);
32
+ } catch (e) {
33
+ if (process.env.A11Y_DEBUG) console.log(` (local pool unavailable: ${errorText(e)})`);
106
34
  return [];
107
35
  }
108
36
  }
109
- /** @param {string} url */
110
37
  async function versionOf(url) {
111
- // `requestJson`, not `fetch`: gains a real error CODE (`ECONNREFUSED`, `EHOSTUNREACH`) for the per-worker
112
- // `errorText(e)` line below, in place of fetch's undifferentiated `TypeError: fetch failed`.
113
- const response = await requestJson(`${url.replace(/\/$/, "")}/health`, { timeoutMs: HEALTH_TIMEOUT_MS });
114
- // `requestJson` returns `undefined` for unparseable JSON rather than throwing (its own docstring: a cache
115
- // miss is a normal outcome for its usual callers) -- `fetch`'s `.json()` threw, and the caller here relies
116
- // on that to report "unreachable" for a worker that answered garbage. Restored explicitly.
117
- if (response.json === undefined)
118
- throw new Error(`invalid JSON from ${url}`);
38
+ const response = await requestJson(`${url.replace(/\/$/, "")}/health`, {
39
+ timeoutMs: HEALTH_TIMEOUT_MS
40
+ });
41
+ if (void 0 === response.json) throw new Error(`invalid JSON from ${url}`);
119
42
  return response.json.code ?? "absent";
120
43
  }
121
- /**
122
- * Nothing here runs on import, for the same reason as `deploy-worker.mjs`: a module that probes every worker
123
- * over HTTP should be invoked, not merely mentioned. It also lets `code-version.test.ts` import this rather
124
- * than parse its source as text.
125
- */
126
44
  async function main() {
127
45
  const expected = expectedWorkerCode();
128
46
  const { urls, source } = workerUrls();
@@ -131,43 +49,26 @@ async function main() {
131
49
  console.log("no worker configured, running locally, or listed in inventory.yml — nothing to compare");
132
50
  process.exit(0);
133
51
  }
134
- // Say which list this is. A reading of "all current" means nothing until you know whether it examined
135
- // the fleet you are about to capture on or the empty pool on your laptop.
136
52
  console.log(`checking ${urls.length} worker(s) from ${source}`);
137
- // Read first, CLASSIFY second, and the classifier is the one the capture preflight uses. This loop used
138
- // to decide staleness itself with `actual === expected` — the same judgement in a second place, which is
139
- // exactly what `worker-code-check.mjs` exists to stop. `versionOf` stays only because the CLI reports the
140
- // network error text per worker, which a preflight has no use for.
141
53
  const readings = [];
142
- for (const url of urls) {
143
- try {
144
- // "absent" means the worker predates /health.code, which is itself a stale deploy.
145
- readings.push({ worker: url, code: await versionOf(url) });
146
- }
147
- catch (e) {
148
- console.log(` ${url} unreachable (${errorText(e)})`);
149
- readings.push({ worker: url, code: null });
150
- }
54
+ for (const url of urls)try {
55
+ readings.push({
56
+ worker: url,
57
+ code: await versionOf(url)
58
+ });
59
+ } catch (e) {
60
+ console.log(` ${url} unreachable (${errorText(e)})`);
61
+ readings.push({
62
+ worker: url,
63
+ code: null
64
+ });
151
65
  }
152
66
  const { stale } = codeDrift(expected, readings);
153
- const staleUrls = stale.map((s) => s.worker);
67
+ const staleUrls = stale.map((s)=>s.worker);
154
68
  const isStale = new Set(staleUrls);
155
- for (const { worker, code } of readings.filter((r) => r.code !== null)) {
156
- console.log(` ${worker} ${code} ${isStale.has(worker) ? "STALE — redeploy and REBOOT the guest" : "matches"}`);
157
- }
158
- if (staleUrls.length) {
159
- for (const line of remedyLines(staleUrls, inventoryWorkerUrls()))
160
- console.log(line);
161
- const note = protocolBumpNote();
162
- if (note)
163
- console.log(note);
164
- }
69
+ for (const { worker, code } of readings.filter((r)=>null !== r.code))console.log(` ${worker} ${code} ${isStale.has(worker) ? "STALE — redeploy and REBOOT the guest" : "matches"}`);
70
+ if (staleUrls.length) for (const line of remedyLines(staleUrls, inventoryWorkerUrls()))console.log(line);
165
71
  process.exit(staleUrls.length ? 1 : 0);
166
72
  }
167
- // REALPATH'D: `import.meta.url` is resolved through symlinks by Node's ESM loader and `process.argv[1]`
168
- // is not, so a bin reached via its `.bin` symlink (which is how npm always installs one) mismatched here
169
- // and this guard silently read false — the tool loaded, did nothing, and exited 0. `/var` and `/tmp` are
170
- // themselves symlinks on macOS, so this fired every time. Same defect, same fix, as `cli.ts`'s `isProgram`.
171
- if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href)
172
- await main();
173
- //# sourceMappingURL=check-worker-code.mjs.map
73
+ if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
74
+ export { workerUrls };
@@ -68,4 +68,3 @@ export function refuseUnknownFlags(known: string[], { entry, argv, command }: {
68
68
  argv?: string[];
69
69
  command?: string;
70
70
  }): void;
71
- //# sourceMappingURL=cli-flags.d.mts.map
@@ -1,207 +1,61 @@
1
- // @ts-check
2
- /**
3
- * Refuse a flag this command does not read.
4
- *
5
- * Every CLI here parses argv the same way — `process.argv.find((a) => a.startsWith("--only="))` or
6
- * `process.argv.includes("--resume")` — and every one of them therefore IGNORES anything it does not
7
- * recognise. A mistyped, renamed or hallucinated flag runs the default and reports success, and the
8
- * operator believes their value was applied. That is the same defect as an Ansible extra var a job does
9
- * not read, one layer out, and this repo has paid for it twice:
10
- *
11
- * - a blocker's own message told the reader to run `--write-baseline`; the flag is `--update-baseline`
12
- * - `--only=route-title-stale` covered 1 of the 7 cases in that family, because the match was exact-id
13
- *
14
- * Neither produced an error. Both produced a plausible wrong answer, which this file's governing rule
15
- * says to replace with a refusal that names the cause.
16
- *
17
- * ## Why the known list is passed in rather than read from the caller's source
18
- *
19
- * Deriving it at runtime — reading the calling module and regexing out its `--flags` — needs no
20
- * maintenance, and is wrong for one reason that matters: a CLI whose flags are consumed by a HELPER in
21
- * another module would refuse them, so the guard would break exactly the commands with the most moving
22
- * parts. The list is explicit here and `cli-flags.test.ts` derives the same set from source and pins the
23
- * two equal, which is this repo's remedy when a duplication is forced.
24
- */
25
1
  import { basename } from "node:path";
26
2
  import { realpathSync } from "node:fs";
27
3
  import { pathToFileURL } from "node:url";
28
- /** How far apart two flags may be and still be worth suggesting. One typo, or one word. */
29
4
  const NEAR = 4;
30
- /**
31
- * Levenshtein, small and iterative — the inputs are flag names, never long strings.
32
- * @param {string} a @param {string} b @returns {number}
33
- */
34
5
  function distance(a, b) {
35
- let previous = Array.from({ length: b.length + 1 }, (_unused, index) => index);
36
- for (let i = 1; i <= a.length; i += 1) {
37
- const row = [i];
38
- for (let j = 1; j <= b.length; j += 1) {
39
- row[j] = Math.min(row[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
40
- }
6
+ let previous = Array.from({
7
+ length: b.length + 1
8
+ }, (_unused, index)=>index);
9
+ for(let i = 1; i <= a.length; i += 1){
10
+ const row = [
11
+ i
12
+ ];
13
+ for(let j = 1; j <= b.length; j += 1)row[j] = Math.min(row[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
41
14
  previous = row;
42
15
  }
43
16
  return previous[b.length];
44
17
  }
45
- /**
46
- * The flag name alone: `--shard=0/4` and `--shard` both name `--shard`.
47
- * @param {string} argument @returns {string}
48
- */
49
- export function nameOf(argument) {
18
+ function nameOf(argument) {
50
19
  const equals = argument.indexOf("=");
51
- return equals === -1 ? argument : argument.slice(0, equals);
20
+ return -1 === equals ? argument : argument.slice(0, equals);
52
21
  }
53
- /**
54
- * `--name=value`'s value, or `undefined` if `--name=` was never passed. audit §9's "argv parsing" row:
55
- * VALIDATION is owned here (`refuseUnknownFlags`), but 15+ files each hand-rolled this exact three-line
56
- * idiom for EXTRACTION, and one of them had already drifted from the rest.
57
- *
58
- * MEASURED before writing this, across all fifteen, against five vectors (a normal value, a missing flag,
59
- * an empty value, a repeated flag, and a value containing its own `=`): fourteen agreed on all five —
60
- * `argv.find((a) => a.startsWith("--name=")).slice("--name=".length)`, verbatim or with `name` templated
61
- * in. `fleet-discover.mjs`'s `arg()` used `.split("=")[1]` instead, which is identical on four vectors and
62
- * silently WRONG on the fifth: `--url=http://host?a=b` came back as `"http://host?a"`, truncated at the
63
- * value's own `=`. Dormant today — that helper is only ever asked for `--cidr=` and `--port=`, neither of
64
- * which can contain one — but a live discrepancy the day it is asked for a URL or a `key=value` pair.
65
- *
66
- * Kept as `.slice`, matching the fourteen rather than the one, and `fleet-discover.mjs` converted to it.
67
- *
68
- * @param {readonly string[]} argv @param {string} name
69
- * @returns {string | undefined}
70
- */
71
- export function flagValue(argv, name) {
22
+ function flagValue(argv, name) {
72
23
  const prefix = `--${name}=`;
73
- const hit = argv.find((a) => a.startsWith(prefix));
74
- return hit === undefined ? undefined : hit.slice(prefix.length);
24
+ const hit = argv.find((a)=>a.startsWith(prefix));
25
+ return void 0 === hit ? void 0 : hit.slice(prefix.length);
75
26
  }
76
- /**
77
- * Which of `argv` are flags this command does not know. PURE, so it is testable without a process.
78
- *
79
- * A bare `--` is npm's separator and never a flag. Anything not starting with `-` is positional — a URL,
80
- * a worker address, a page path — and is not this guard's business.
81
- *
82
- * SINGLE-DASH FLAGS ARE INSPECTED TOO, AND THE OMISSION COST A 14-MINUTE FLEET OPERATION.
83
- *
84
- * This read `startsWith("--")`, on the reasoning that only long flags are ever this repo's own. But an
85
- * ANSIBLE-shaped argument is single-dash, and several of these commands wrap `ansible-playbook` — so
86
- * `npm run fleet:provision -- -e worker_edge_allow_downgrade=true` passed straight through the guard,
87
- * was never forwarded by the wrapper, and the whole fleet was provisioned WITHOUT the authorisation the
88
- * operator believed they had given. Measured 2026-09-05. The role then refused, correctly, with a message
89
- * telling the operator to pass the very flag they had just passed.
90
- *
91
- * That is precisely the defect this file exists to prevent — "an ignored flag runs the default and reports
92
- * success" — surviving inside its own remedy, because the remedy was written to match one flag SHAPE
93
- * rather than the idea of a flag. `-e` is not a URL and not a page path; nothing positional here begins
94
- * with a dash followed by a letter, which is what makes this safe to refuse rather than merely warn on.
95
- */
96
- /**
97
- * @param {string[]} argv @param {string[]} known @returns {string[]}
98
- */
99
- export function unknownFlags(argv, known) {
27
+ function unknownFlags(argv, known) {
100
28
  const accepted = new Set(known.map(nameOf));
101
- return argv
102
- .filter((argument) => argument !== "--" && (argument.startsWith("--") || /^-[A-Za-z]/.test(argument)))
103
- .map(nameOf)
104
- .filter((flag) => !accepted.has(flag));
29
+ return argv.filter((argument)=>"--" !== argument && (argument.startsWith("--") || /^-[A-Za-z]/.test(argument))).map(nameOf).filter((flag)=>!accepted.has(flag));
105
30
  }
106
- /**
107
- * The closest known flag, when there is one close enough to be a likely typo rather than a guess.
108
- * @param {string} flag @param {string[]} known @returns {string | undefined}
109
- */
110
- export function didYouMean(flag, known) {
111
- const ranked = known.map(nameOf)
112
- .map((candidate) => ({ candidate, gap: distance(flag, candidate) }))
113
- .sort((left, right) => left.gap - right.gap);
114
- return ranked[0] && ranked[0].gap <= NEAR ? ranked[0].candidate : undefined;
31
+ function didYouMean(flag, known) {
32
+ const ranked = known.map(nameOf).map((candidate)=>({
33
+ candidate,
34
+ gap: distance(flag, candidate)
35
+ })).sort((left, right)=>left.gap - right.gap);
36
+ return ranked[0] && ranked[0].gap <= NEAR ? ranked[0].candidate : void 0;
115
37
  }
116
- /**
117
- * Refuse, naming the flag and what this command does take. Exits 2; does not return on failure.
118
- *
119
- * `command` names the thing a human typed — the npm script, not the file — because that is what they will
120
- * retype. Defaults to the script's basename, which is right for the ones invoked directly.
121
- */
122
- /**
123
- * @param {string[]} known Every flag this command reads, `--name` or `--name=`.
124
- * @param {{entry: string, argv?: string[], command?: string}} options
125
- * `entry` is the caller's `import.meta.url`, and it is REQUIRED. `command` names the thing a HUMAN
126
- * typed — the npm script, not the file — because that is what they will retype.
127
- */
128
- // NO `= {}` DEFAULT, because `entry` is required and the docstring above has always said so. A default
129
- // that lets the whole options object be omitted contradicts that: it produces `entry === undefined`, and
130
- // the guard below decides whether THIS module is the command by comparing `entry` against argv[1] -- so a
131
- // caller who forgot it would get a guard that silently never fires. Types found the contradiction the
132
- // moment this file entered the program. Every one of the 60 real call sites passes it.
133
- export function refuseUnknownFlags(known, { entry, argv = process.argv.slice(2), command }) {
134
- // ONLY WHEN THIS MODULE IS THE COMMAND, never when it is imported.
135
- //
136
- // These calls sit at module top level, so they run on IMPORT — and then inspect the IMPORTING process's
137
- // argv. Measured 2026-08-27, an hour after the guards went in: `capture-real-pages --role=calibration`
138
- // imports `fleet-env.mjs`, whose guard woke up, saw `--role`, decided it did not know it, and killed a
139
- // 50-page capture with "unknown flag --role — did you mean --list?". The guard was right about its own
140
- // flags and asking the wrong process.
141
- //
142
- // `entry` is REQUIRED rather than defaulted, for the reason `createHostThrottle`'s `minGapMs` is: a
143
- // default here would silently restore exactly this behaviour for any caller who forgot it, and the
144
- // failure mode is a guard that fires on somebody else's command line.
145
- if (!entry) {
146
- throw new TypeError("refuseUnknownFlags needs { entry: import.meta.url } — without it the guard runs "
147
- + "on import and inspects the importing process's flags");
148
- }
149
- // REALPATH'D, and without it this guard silently does not fire through a symlink — #237.
150
- //
151
- // An entry guard in the realpath'd form reads
152
- // `import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href`, and
153
- // this comparison had no `realpathSync`. So when `argv[1]` reaches such a script through a symlink — npm's
154
- // own `.bin` links are symlinks, which is why those call sites resolve — the OUTER condition is true and
155
- // this one is FALSE. `main()` runs; the flag guard returns early and inspects nothing.
156
- //
157
- // NOT EVERY CALL SITE HAS THAT FORM (#1248). Some still compare the plain, symlink-blind
158
- // `pathToFileURL(process.argv[1] ?? "")`, which fails the other way through a symlink: the OUTER condition is
159
- // false, `main()` never runs, and the tool exits 0. Which files still do is `KNOWN_PLAIN_ENTRY_GUARDS` in
160
- // `entry-points.test.ts` (#1086), a ratchet that may shrink and may not grow. It is the list of record, so no
161
- // count is repeated here.
162
- //
163
- // Measured on `piped-exit-status-guard.mjs`, same file, same flag:
164
- //
165
- // node scripts/tmp-symlink-probe.mjs --bogus 'echo hi' -> ran, exit 0, flag IGNORED
166
- // node packages/guards/src/piped-exit-status-guard.mjs --bogus '...' -> refused, exit 2
167
- //
168
- // Through the symlink the mistyped flag is ignored and the command reports success — which is the
169
- // sentence this refusal itself prints as the reason it exists.
170
- //
171
- // It is a remedy whose TRIGGER is narrower than the thing it guards, the `refreshBrowseBuffer` shape:
172
- // reachable from the right path, with a condition that could not be true there. And the census
173
- // (`cli-flags.test.ts`) cannot see it, because it asserts a file CONTAINS `refuseUnknownFlags(` — it
174
- // cannot ask whether that call can FIRE, so every guarded CLI reads GUARDED either way.
175
- //
176
- // `realpathSync` THROWS on a path that does not exist, and `process.argv[1]` is absent for `node -e`
177
- // and `node --eval`. Absent stays absent rather than becoming a throw: the guard must be inert when
178
- // there is no script, never fatal.
179
- /** The invoking script's REAL path as a URL, so a symlinked `argv[1]` still matches `entry`. */
38
+ function refuseUnknownFlags(known, { entry, argv = process.argv.slice(2), command }) {
39
+ if (!entry) throw new TypeError("refuseUnknownFlags needs { entry: import.meta.url } — without it the guard runs on import and inspects the importing process's flags");
180
40
  let invoked;
181
41
  try {
182
42
  invoked = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : "";
43
+ } catch {
44
+ invoked = pathToFileURL(process.argv[1] ?? "").href;
183
45
  }
184
- catch {
185
- invoked = pathToFileURL(process.argv[1] ?? "").href; // unresolvable: compare what we were given
186
- }
187
- if (entry !== invoked)
188
- return;
46
+ if (entry !== invoked) return;
189
47
  const unknown = unknownFlags(argv, known);
190
- if (unknown.length === 0)
191
- return;
48
+ if (0 === unknown.length) return;
192
49
  const name = command ?? basename(process.argv[1] ?? "this command");
193
- for (const flag of unknown) {
50
+ for (const flag of unknown){
194
51
  const near = didYouMean(flag, known);
195
52
  console.error(` ${name}: unknown flag ${flag}${near ? ` — did you mean ${near}?` : ""}`);
196
53
  }
197
- // "It takes: " with nothing after it is what a command taking NO flags printed, and that reads like the
198
- // guard failed to find its own list rather than like an answer. First hit by `release:provenance`, the
199
- // first zero-flag CLI here -- the branch existed for months with no caller to exercise it.
200
- const accepted = [...known].map(nameOf).sort();
201
- console.error(accepted.length === 0
202
- ? " It takes no flags at all."
203
- : ` It takes: ${accepted.join(" ")}`);
54
+ const accepted = [
55
+ ...known
56
+ ].map(nameOf).sort();
57
+ console.error(0 === accepted.length ? " It takes no flags at all." : ` It takes: ${accepted.join(" ")}`);
204
58
  console.error(" Refusing rather than ignoring it: an ignored flag runs the default and reports success.");
205
59
  process.exit(2);
206
60
  }
207
- //# sourceMappingURL=cli-flags.mjs.map
61
+ export { didYouMean, flagValue, nameOf, refuseUnknownFlags, unknownFlags };
@@ -137,4 +137,3 @@ export function assertWorkersServe(expected: string, workers: string[], options:
137
137
  bareMetalUrls?: string[];
138
138
  sourceDir: string;
139
139
  }): Promise<void>;
140
- //# sourceMappingURL=code-drift.d.mts.map
@@ -30,4 +30,3 @@ export function readsArgv(rel: string, repoRoot: string): boolean;
30
30
  * @returns {string[]}
31
31
  */
32
32
  export function commandLineModules(repoRoot: string): string[];
33
- //# sourceMappingURL=command-line-census.d.mts.map
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=compare-workers.d.mts.map