@a11ign/screenreader-fleet 0.2.0 → 0.4.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 (130) 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 -110
  5. package/dist/cli-flags.d.mts +0 -1
  6. package/dist/cli-flags.mjs +33 -179
  7. package/dist/code-drift.d.mts +4 -4
  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/doctor.d.mts +0 -1
  13. package/dist/doctor.mjs +361 -809
  14. package/dist/fleet-consistency.d.mts +0 -1
  15. package/dist/fleet-consistency.mjs +155 -379
  16. package/dist/fleet-env.d.mts +1 -2
  17. package/dist/fleet-env.mjs +148 -438
  18. package/dist/fleet-scripts.d.mts +0 -1
  19. package/dist/git-safe-env.d.mts +0 -1
  20. package/dist/guest-run.d.mts +0 -1
  21. package/dist/host-address.d.mts +0 -1
  22. package/dist/host-address.mjs +19 -90
  23. package/dist/host-capacity.d.mts +0 -1
  24. package/dist/host-capacity.mjs +22 -136
  25. package/dist/host-metrics.d.mts +0 -1
  26. package/dist/index.d.ts +0 -1
  27. package/dist/index.mjs +233 -0
  28. package/dist/local-vm.d.ts +0 -1
  29. package/dist/measure-guard.d.mts +0 -1
  30. package/dist/normalise-fleet.d.mts +0 -1
  31. package/dist/npm-cli-executable.d.mts +0 -1
  32. package/dist/probe-outcome.d.mts +0 -1
  33. package/dist/probe-outcome.mjs +50 -96
  34. package/dist/protocol-guard.d.mts +0 -1
  35. package/dist/source-walk.d.mts +0 -1
  36. package/dist/src_fleet-scripts_mjs.mjs +16 -0
  37. package/dist/src_git-safe-env_mjs.mjs +9 -0
  38. package/dist/transient-fault.d.mts +0 -1
  39. package/dist/transient-fault.mjs +21 -81
  40. package/dist/utm-deprecated.d.mts +0 -1
  41. package/dist/worker-code-check.d.mts +0 -1
  42. package/dist/worker-code-check.mjs +127 -83
  43. package/dist/worker-health.d.mts +0 -1
  44. package/dist/worker-health.mjs +16 -61
  45. package/dist/worker-http.d.mts +0 -1
  46. package/dist/worker-http.mjs +42 -234
  47. package/dist/worker-stats.d.mts +0 -1
  48. package/package.json +13 -7
  49. package/src/local-worker/worker-ctl.sh +1 -1
  50. package/src/provisioning/bootstrap-windows-worker.ps1 +1 -1
  51. package/dist/capture-client.d.mts.map +0 -1
  52. package/dist/capture-client.mjs.map +0 -1
  53. package/dist/check-worker-code.d.mts.map +0 -1
  54. package/dist/check-worker-code.mjs.map +0 -1
  55. package/dist/cli-flags.d.mts.map +0 -1
  56. package/dist/cli-flags.mjs.map +0 -1
  57. package/dist/code-drift.d.mts.map +0 -1
  58. package/dist/code-drift.mjs +0 -284
  59. package/dist/code-drift.mjs.map +0 -1
  60. package/dist/command-line-census.d.mts.map +0 -1
  61. package/dist/command-line-census.mjs +0 -96
  62. package/dist/command-line-census.mjs.map +0 -1
  63. package/dist/compare-workers.d.mts.map +0 -1
  64. package/dist/compare-workers.mjs.map +0 -1
  65. package/dist/control-plane-isolation.d.mts.map +0 -1
  66. package/dist/control-plane-isolation.mjs +0 -67
  67. package/dist/control-plane-isolation.mjs.map +0 -1
  68. package/dist/deploy-worker.d.mts +0 -3
  69. package/dist/deploy-worker.d.mts.map +0 -1
  70. package/dist/deploy-worker.mjs +0 -333
  71. package/dist/deploy-worker.mjs.map +0 -1
  72. package/dist/doctor.d.mts.map +0 -1
  73. package/dist/doctor.mjs.map +0 -1
  74. package/dist/fleet-consistency.d.mts.map +0 -1
  75. package/dist/fleet-consistency.mjs.map +0 -1
  76. package/dist/fleet-env.d.mts.map +0 -1
  77. package/dist/fleet-env.mjs.map +0 -1
  78. package/dist/fleet-scripts.d.mts.map +0 -1
  79. package/dist/fleet-scripts.mjs +0 -41
  80. package/dist/fleet-scripts.mjs.map +0 -1
  81. package/dist/git-safe-env.d.mts.map +0 -1
  82. package/dist/git-safe-env.mjs +0 -44
  83. package/dist/git-safe-env.mjs.map +0 -1
  84. package/dist/guest-run.d.mts.map +0 -1
  85. package/dist/guest-run.mjs +0 -164
  86. package/dist/guest-run.mjs.map +0 -1
  87. package/dist/host-address.d.mts.map +0 -1
  88. package/dist/host-address.mjs.map +0 -1
  89. package/dist/host-capacity.d.mts.map +0 -1
  90. package/dist/host-capacity.mjs.map +0 -1
  91. package/dist/host-metrics.d.mts.map +0 -1
  92. package/dist/host-metrics.mjs +0 -201
  93. package/dist/host-metrics.mjs.map +0 -1
  94. package/dist/index.d.ts.map +0 -1
  95. package/dist/index.js +0 -25
  96. package/dist/index.js.map +0 -1
  97. package/dist/local-vm.d.ts.map +0 -1
  98. package/dist/local-vm.js +0 -360
  99. package/dist/local-vm.js.map +0 -1
  100. package/dist/measure-guard.d.mts.map +0 -1
  101. package/dist/measure-guard.mjs +0 -73
  102. package/dist/measure-guard.mjs.map +0 -1
  103. package/dist/normalise-fleet.d.mts.map +0 -1
  104. package/dist/normalise-fleet.mjs +0 -76
  105. package/dist/normalise-fleet.mjs.map +0 -1
  106. package/dist/npm-cli-executable.d.mts.map +0 -1
  107. package/dist/npm-cli-executable.mjs +0 -159
  108. package/dist/npm-cli-executable.mjs.map +0 -1
  109. package/dist/probe-outcome.d.mts.map +0 -1
  110. package/dist/probe-outcome.mjs.map +0 -1
  111. package/dist/protocol-guard.d.mts.map +0 -1
  112. package/dist/protocol-guard.mjs +0 -121
  113. package/dist/protocol-guard.mjs.map +0 -1
  114. package/dist/source-walk.d.mts.map +0 -1
  115. package/dist/source-walk.mjs +0 -56
  116. package/dist/source-walk.mjs.map +0 -1
  117. package/dist/transient-fault.d.mts.map +0 -1
  118. package/dist/transient-fault.mjs.map +0 -1
  119. package/dist/utm-deprecated.d.mts.map +0 -1
  120. package/dist/utm-deprecated.mjs +0 -23
  121. package/dist/utm-deprecated.mjs.map +0 -1
  122. package/dist/worker-code-check.d.mts.map +0 -1
  123. package/dist/worker-code-check.mjs.map +0 -1
  124. package/dist/worker-health.d.mts.map +0 -1
  125. package/dist/worker-health.mjs.map +0 -1
  126. package/dist/worker-http.d.mts.map +0 -1
  127. package/dist/worker-http.mjs.map +0 -1
  128. package/dist/worker-stats.d.mts.map +0 -1
  129. package/dist/worker-stats.mjs +0 -143
  130. package/dist/worker-stats.mjs.map +0 -1
@@ -1,100 +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 { fleetScriptPaths } from "./fleet-scripts.mjs";
19
- import { configuredWorkers, inventoryWorkerUrls, resolveWorkerPool } from "./fleet-env.mjs";
20
- // The comparison, the remedy and the expected hash live in ONE place, because the capture entry points ask
21
- // the same question before every run and a second copy of "is this worker stale" is a second answer.
22
- import { expectedWorkerCode, codeDrift, remedyLines } from "./worker-code-check.mjs";
23
- import { refuseUnknownFlags } from "./cli-flags.mjs";
24
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";
25
10
  import { requestJson } from "./worker-http.mjs";
26
- /**
27
- * takes NO flags — it asks every worker what code it is running and compares. Any flag passed to it
28
- * today is discarded in silence.
29
- *
30
- * An unrecognised flag is otherwise IGNORED, so it runs the default and reports success.
31
- */
32
- refuseUnknownFlags([], { entry: import.meta.url, command: "npm run worker:code" });
33
- // Resolved from THIS module: the fleet scripts ship with this package, so a cwd-relative path was only ever
34
- // right when run from the repo root.
11
+ refuseUnknownFlags([], {
12
+ entry: import.meta.url,
13
+ command: "npm run worker:code"
14
+ });
35
15
  const CTL = fleetScriptPaths().workerCtl;
36
- // /health now reports installed runtime versions as well as the code hash. The first
37
- // request after a Windows boot may need PowerShell file-version discovery, so four seconds
38
- // was too tight and made a healthy worker look unreachable.
39
16
  const HEALTH_TIMEOUT_MS = 15000;
40
- /**
41
- * Which workers to ask, AND WHERE THAT LIST CAME FROM — the second half is not decoration.
42
- *
43
- * This used to answer the local UTM pool or nothing, and print "no worker is running — nothing to compare"
44
- * when it found neither. Measured 2026-08-28 with five bare-metal workers serving `/health` and all five
45
- * STALE against this checkout: the bare command reported nothing to compare, and the same command with
46
- * `A11Y_WORKERS` set reported `5 stale worker(s)`. One env var apart, and the quiet answer was the wrong one.
47
- *
48
- * That is `lab:inventory`'s lesson at a different layer — *"'none here' and 'none anywhere' are different
49
- * answers, and it now refuses to turn the first into the second"* — and it lands harder here, because this
50
- * command exists to stop a corpus being captured on the wrong code. A false clean from it is the failure it
51
- * was written to prevent, delivered by the tool itself.
52
- *
53
- * The inventory was already imported and already read, twelve lines below, to print the REMEDY. So the
54
- * command could name the five workers it should have checked while insisting it had none to check.
55
- *
56
- * Reading it here is safe in a way it would not be for a capture run: this probes `/health` and starts
57
- * nothing, so the rule that naming workers means you are managing them does not apply.
58
- */
59
- export function workerUrls({ named = configuredWorkers, local = localPoolUrls, inventory = inventoryWorkerUrls, } = {}) {
60
- // ONE PRECEDENCE, in fleet-env.mjs. This function held its own -- named, then the LOCAL UTM POOL, then
61
- // the inventory -- while `doctor` went named then inventory, so on any Mac with a registered guest the
62
- // two commands described different fleets. That is the divergence the comment here used to claim it had
63
- // closed; it closed the NAMED half only.
64
- return resolveWorkerPool({ named, inventory, local });
17
+ function workerUrls({ named = configuredWorkers, local = localPoolUrls, inventory = inventoryWorkerUrls } = {}) {
18
+ return resolveWorkerPool({
19
+ named,
20
+ inventory,
21
+ local
22
+ });
65
23
  }
66
- /** The local UTM pool, or none — `utmctl` is absent on a machine that never had one. */
67
24
  function localPoolUrls() {
68
25
  try {
69
- const pool = JSON.parse(execFileSync(CTL, ["pool"], { encoding: "utf8" }));
70
- return pool.filter((/** @type {{ip?: string}} */ vm) => vm.ip)
71
- .map((/** @type {{ip: string, port: number}} */ vm) => `http://${vm.ip}:${vm.port}`);
72
- }
73
- catch (e) {
74
- // Never silent: "there is no UTM here" and "utmctl failed" are different, and the second is the one
75
- // that would otherwise send somebody to the inventory believing the local pool was empty.
76
- if (process.env.A11Y_DEBUG)
77
- 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)})`);
78
34
  return [];
79
35
  }
80
36
  }
81
- /** @param {string} url */
82
37
  async function versionOf(url) {
83
- // `requestJson`, not `fetch`: gains a real error CODE (`ECONNREFUSED`, `EHOSTUNREACH`) for the per-worker
84
- // `errorText(e)` line below, in place of fetch's undifferentiated `TypeError: fetch failed`.
85
- const response = await requestJson(`${url.replace(/\/$/, "")}/health`, { timeoutMs: HEALTH_TIMEOUT_MS });
86
- // `requestJson` returns `undefined` for unparseable JSON rather than throwing (its own docstring: a cache
87
- // miss is a normal outcome for its usual callers) -- `fetch`'s `.json()` threw, and the caller here relies
88
- // on that to report "unreachable" for a worker that answered garbage. Restored explicitly.
89
- if (response.json === undefined)
90
- 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}`);
91
42
  return response.json.code ?? "absent";
92
43
  }
93
- /**
94
- * Nothing here runs on import, for the same reason as `deploy-worker.mjs`: a module that probes every worker
95
- * over HTTP should be invoked, not merely mentioned. It also lets `code-version.test.ts` import this rather
96
- * than parse its source as text.
97
- */
98
44
  async function main() {
99
45
  const expected = expectedWorkerCode();
100
46
  const { urls, source } = workerUrls();
@@ -103,40 +49,26 @@ async function main() {
103
49
  console.log("no worker configured, running locally, or listed in inventory.yml — nothing to compare");
104
50
  process.exit(0);
105
51
  }
106
- // Say which list this is. A reading of "all current" means nothing until you know whether it examined
107
- // the fleet you are about to capture on or the empty pool on your laptop.
108
52
  console.log(`checking ${urls.length} worker(s) from ${source}`);
109
- // Read first, CLASSIFY second, and the classifier is the one the capture preflight uses. This loop used
110
- // to decide staleness itself with `actual === expected` — the same judgement in a second place, which is
111
- // exactly what `worker-code-check.mjs` exists to stop. `versionOf` stays only because the CLI reports the
112
- // network error text per worker, which a preflight has no use for.
113
53
  const readings = [];
114
- for (const url of urls) {
115
- try {
116
- // "absent" means the worker predates /health.code, which is itself a stale deploy.
117
- readings.push({ worker: url, code: await versionOf(url) });
118
- }
119
- catch (e) {
120
- console.log(` ${url} unreachable (${errorText(e)})`);
121
- readings.push({ worker: url, code: null });
122
- }
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
+ });
123
65
  }
124
66
  const { stale } = codeDrift(expected, readings);
125
- const staleUrls = stale.map((s) => s.worker);
67
+ const staleUrls = stale.map((s)=>s.worker);
126
68
  const isStale = new Set(staleUrls);
127
- for (const { worker, code } of readings.filter((r) => r.code !== null)) {
128
- console.log(` ${worker} ${code} ${isStale.has(worker) ? "STALE — redeploy and REBOOT the guest" : "matches"}`);
129
- }
130
- if (staleUrls.length) {
131
- for (const line of remedyLines(staleUrls, inventoryWorkerUrls()))
132
- console.log(line);
133
- }
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);
134
71
  process.exit(staleUrls.length ? 1 : 0);
135
72
  }
136
- // REALPATH'D: `import.meta.url` is resolved through symlinks by Node's ESM loader and `process.argv[1]`
137
- // is not, so a bin reached via its `.bin` symlink (which is how npm always installs one) mismatched here
138
- // and this guard silently read false — the tool loaded, did nothing, and exited 0. `/var` and `/tmp` are
139
- // themselves symlinks on macOS, so this fired every time. Same defect, same fix, as `cli.ts`'s `isProgram`.
140
- if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href)
141
- await main();
142
- //# 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 };
@@ -31,9 +31,10 @@ export function codeDrift(expected: string, readings: Array<{
31
31
  /**
32
32
  * Name the deploy route that can actually reach these workers.
33
33
  *
34
- * There are two, they share no mechanism, and the wrong one wastes real time. `worker:deploy` is
35
- * `utmctl file push` plus a `utmctl` reboot: it takes a VM UUID and fails immediately off macOS, so it
36
- * cannot touch a physical box. Bare-metal workers are git-cloned and deploy by PULLING, through Ansible.
34
+ * Only bare-metal workers have one: they are git-cloned and deploy by PULLING, through Ansible. The UTM
35
+ * route (`a11ign-worker-deploy`, `utmctl file push` plus a `utmctl` reboot, keyed on a VM UUID) is gone: it
36
+ * pushed from the worker package's `src`, which a built install does not have (#3765), and no fleet box
37
+ * was ever reachable by it.
37
38
  *
38
39
  * This printed the utmctl advice unconditionally, including to a fleet of four mini PCs where none of it
39
40
  * applies — a tool confidently prescribing a remedy for a different kind of machine. Which kind a worker is
@@ -137,4 +138,3 @@ export function assertWorkersServe(expected: string, workers: string[], options:
137
138
  bareMetalUrls?: string[];
138
139
  sourceDir: string;
139
140
  }): 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