@a11ign/screenreader-fleet 0.0.0-reserved.0 → 0.1.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 (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +94 -2
  3. package/dist/capture-client.d.mts +49 -0
  4. package/dist/capture-client.d.mts.map +1 -0
  5. package/dist/capture-client.mjs +352 -0
  6. package/dist/capture-client.mjs.map +1 -0
  7. package/dist/check-worker-code.d.mts +34 -0
  8. package/dist/check-worker-code.d.mts.map +1 -0
  9. package/dist/check-worker-code.mjs +173 -0
  10. package/dist/check-worker-code.mjs.map +1 -0
  11. package/dist/cli-flags.d.mts +71 -0
  12. package/dist/cli-flags.d.mts.map +1 -0
  13. package/dist/cli-flags.mjs +207 -0
  14. package/dist/cli-flags.mjs.map +1 -0
  15. package/dist/code-drift.d.mts +140 -0
  16. package/dist/code-drift.d.mts.map +1 -0
  17. package/dist/code-drift.mjs +284 -0
  18. package/dist/code-drift.mjs.map +1 -0
  19. package/dist/command-line-census.d.mts +33 -0
  20. package/dist/command-line-census.d.mts.map +1 -0
  21. package/dist/command-line-census.mjs +96 -0
  22. package/dist/command-line-census.mjs.map +1 -0
  23. package/dist/compare-workers.d.mts +3 -0
  24. package/dist/compare-workers.d.mts.map +1 -0
  25. package/dist/compare-workers.mjs +332 -0
  26. package/dist/compare-workers.mjs.map +1 -0
  27. package/dist/control-plane-isolation.d.mts +45 -0
  28. package/dist/control-plane-isolation.d.mts.map +1 -0
  29. package/dist/control-plane-isolation.mjs +67 -0
  30. package/dist/control-plane-isolation.mjs.map +1 -0
  31. package/dist/deploy-worker.d.mts +3 -0
  32. package/dist/deploy-worker.d.mts.map +1 -0
  33. package/dist/deploy-worker.mjs +333 -0
  34. package/dist/deploy-worker.mjs.map +1 -0
  35. package/dist/doctor.d.mts +216 -0
  36. package/dist/doctor.d.mts.map +1 -0
  37. package/dist/doctor.mjs +962 -0
  38. package/dist/doctor.mjs.map +1 -0
  39. package/dist/fleet-consistency.d.mts +235 -0
  40. package/dist/fleet-consistency.d.mts.map +1 -0
  41. package/dist/fleet-consistency.mjs +436 -0
  42. package/dist/fleet-consistency.mjs.map +1 -0
  43. package/dist/fleet-env.d.mts +228 -0
  44. package/dist/fleet-env.d.mts.map +1 -0
  45. package/dist/fleet-env.mjs +509 -0
  46. package/dist/fleet-env.mjs.map +1 -0
  47. package/dist/fleet-scripts.d.mts +11 -0
  48. package/dist/fleet-scripts.d.mts.map +1 -0
  49. package/dist/fleet-scripts.mjs +41 -0
  50. package/dist/fleet-scripts.mjs.map +1 -0
  51. package/dist/git-safe-env.d.mts +10 -0
  52. package/dist/git-safe-env.d.mts.map +1 -0
  53. package/dist/git-safe-env.mjs +44 -0
  54. package/dist/git-safe-env.mjs.map +1 -0
  55. package/dist/guest-run.d.mts +26 -0
  56. package/dist/guest-run.d.mts.map +1 -0
  57. package/dist/guest-run.mjs +164 -0
  58. package/dist/guest-run.mjs.map +1 -0
  59. package/dist/host-address.d.mts +33 -0
  60. package/dist/host-address.d.mts.map +1 -0
  61. package/dist/host-address.mjs +105 -0
  62. package/dist/host-address.mjs.map +1 -0
  63. package/dist/host-capacity.d.mts +64 -0
  64. package/dist/host-capacity.d.mts.map +1 -0
  65. package/dist/host-capacity.mjs +152 -0
  66. package/dist/host-capacity.mjs.map +1 -0
  67. package/dist/host-metrics.d.mts +116 -0
  68. package/dist/host-metrics.d.mts.map +1 -0
  69. package/dist/host-metrics.mjs +201 -0
  70. package/dist/host-metrics.mjs.map +1 -0
  71. package/dist/index.d.ts +23 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +25 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/local-vm.d.ts +125 -0
  76. package/dist/local-vm.d.ts.map +1 -0
  77. package/dist/local-vm.js +360 -0
  78. package/dist/local-vm.js.map +1 -0
  79. package/dist/measure-guard.d.mts +34 -0
  80. package/dist/measure-guard.d.mts.map +1 -0
  81. package/dist/measure-guard.mjs +73 -0
  82. package/dist/measure-guard.mjs.map +1 -0
  83. package/dist/normalise-fleet.d.mts +2 -0
  84. package/dist/normalise-fleet.d.mts.map +1 -0
  85. package/dist/normalise-fleet.mjs +76 -0
  86. package/dist/normalise-fleet.mjs.map +1 -0
  87. package/dist/npm-cli-executable.d.mts +42 -0
  88. package/dist/npm-cli-executable.d.mts.map +1 -0
  89. package/dist/npm-cli-executable.mjs +159 -0
  90. package/dist/npm-cli-executable.mjs.map +1 -0
  91. package/dist/probe-outcome.d.mts +89 -0
  92. package/dist/probe-outcome.d.mts.map +1 -0
  93. package/dist/probe-outcome.mjs +104 -0
  94. package/dist/probe-outcome.mjs.map +1 -0
  95. package/dist/protocol-guard.d.mts +34 -0
  96. package/dist/protocol-guard.d.mts.map +1 -0
  97. package/dist/protocol-guard.mjs +121 -0
  98. package/dist/protocol-guard.mjs.map +1 -0
  99. package/dist/source-walk.d.mts +12 -0
  100. package/dist/source-walk.d.mts.map +1 -0
  101. package/dist/source-walk.mjs +56 -0
  102. package/dist/source-walk.mjs.map +1 -0
  103. package/dist/transient-fault.d.mts +6 -0
  104. package/dist/transient-fault.d.mts.map +1 -0
  105. package/dist/transient-fault.mjs +86 -0
  106. package/dist/transient-fault.mjs.map +1 -0
  107. package/dist/utm-deprecated.d.mts +6 -0
  108. package/dist/utm-deprecated.d.mts.map +1 -0
  109. package/dist/utm-deprecated.mjs +23 -0
  110. package/dist/utm-deprecated.mjs.map +1 -0
  111. package/dist/worker-code-check.d.mts +29 -0
  112. package/dist/worker-code-check.d.mts.map +1 -0
  113. package/dist/worker-code-check.mjs +78 -0
  114. package/dist/worker-code-check.mjs.map +1 -0
  115. package/dist/worker-health.d.mts +56 -0
  116. package/dist/worker-health.d.mts.map +1 -0
  117. package/dist/worker-health.mjs +73 -0
  118. package/dist/worker-health.mjs.map +1 -0
  119. package/dist/worker-http.d.mts +103 -0
  120. package/dist/worker-http.d.mts.map +1 -0
  121. package/dist/worker-http.mjs +277 -0
  122. package/dist/worker-http.mjs.map +1 -0
  123. package/dist/worker-stats.d.mts +66 -0
  124. package/dist/worker-stats.d.mts.map +1 -0
  125. package/dist/worker-stats.mjs +143 -0
  126. package/dist/worker-stats.mjs.map +1 -0
  127. package/package.json +96 -4
  128. package/src/local-worker/autounattend.xml +280 -0
  129. package/src/local-worker/build-vm.sh +218 -0
  130. package/src/local-worker/clone-worker.sh +141 -0
  131. package/src/local-worker/create-utm-vm.sh +202 -0
  132. package/src/local-worker/fetch-windows-iso.sh +238 -0
  133. package/src/local-worker/first-boot.cmd +58 -0
  134. package/src/local-worker/worker-ctl.sh +442 -0
  135. package/src/provisioning/README.md +28 -0
  136. package/src/provisioning/apply-foreground-lock-timeout.ps1 +71 -0
  137. package/src/provisioning/bare-metal/README.md +213 -0
  138. package/src/provisioning/bare-metal/a11y-bootstrap.service +58 -0
  139. package/src/provisioning/bare-metal/autounattend.xml +428 -0
  140. package/src/provisioning/bare-metal/serve-bootstrap.sh +86 -0
  141. package/src/provisioning/bootstrap-control-plane.sh +463 -0
  142. package/src/provisioning/bootstrap-windows-worker.ps1 +649 -0
  143. package/src/provisioning/build-lean-worker-image.ps1 +275 -0
  144. package/src/provisioning/diagnose-nvda-worker.ps1 +174 -0
  145. package/src/provisioning/provision-nvda-worker.ps1 +827 -0
  146. package/src/provisioning/set-display-mode.ps1 +411 -0
  147. package/src/provisioning/stamp-provision-revision.ps1 +184 -0
@@ -0,0 +1,173 @@
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
+ import { realpathSync } from "node:fs";
16
+ import { pathToFileURL } from "node:url";
17
+ 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
+ import { errorText } from "@a11ign/screenreader-worker/error-text";
30
+ 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.
40
+ 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
+ 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 "";
67
+ }
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
+ function localPoolUrls() {
96
+ 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)})`);
106
+ return [];
107
+ }
108
+ }
109
+ /** @param {string} url */
110
+ 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}`);
119
+ return response.json.code ?? "absent";
120
+ }
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
+ async function main() {
127
+ const expected = expectedWorkerCode();
128
+ const { urls, source } = workerUrls();
129
+ console.log(`this checkout: ${expected}`);
130
+ if (!urls.length) {
131
+ console.log("no worker configured, running locally, or listed in inventory.yml — nothing to compare");
132
+ process.exit(0);
133
+ }
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
+ 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
+ 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
+ }
151
+ }
152
+ const { stale } = codeDrift(expected, readings);
153
+ const staleUrls = stale.map((s) => s.worker);
154
+ 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
+ }
165
+ process.exit(staleUrls.length ? 1 : 0);
166
+ }
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-worker-code.mjs","sourceRoot":"","sources":["../src/check-worker-code.mjs"],"names":[],"mappings":"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"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The flag name alone: `--shard=0/4` and `--shard` both name `--shard`.
3
+ * @param {string} argument @returns {string}
4
+ */
5
+ export function nameOf(argument: string): string;
6
+ /**
7
+ * `--name=value`'s value, or `undefined` if `--name=` was never passed. audit §9's "argv parsing" row:
8
+ * VALIDATION is owned here (`refuseUnknownFlags`), but 15+ files each hand-rolled this exact three-line
9
+ * idiom for EXTRACTION, and one of them had already drifted from the rest.
10
+ *
11
+ * MEASURED before writing this, across all fifteen, against five vectors (a normal value, a missing flag,
12
+ * an empty value, a repeated flag, and a value containing its own `=`): fourteen agreed on all five —
13
+ * `argv.find((a) => a.startsWith("--name=")).slice("--name=".length)`, verbatim or with `name` templated
14
+ * in. `fleet-discover.mjs`'s `arg()` used `.split("=")[1]` instead, which is identical on four vectors and
15
+ * silently WRONG on the fifth: `--url=http://host?a=b` came back as `"http://host?a"`, truncated at the
16
+ * value's own `=`. Dormant today — that helper is only ever asked for `--cidr=` and `--port=`, neither of
17
+ * which can contain one — but a live discrepancy the day it is asked for a URL or a `key=value` pair.
18
+ *
19
+ * Kept as `.slice`, matching the fourteen rather than the one, and `fleet-discover.mjs` converted to it.
20
+ *
21
+ * @param {readonly string[]} argv @param {string} name
22
+ * @returns {string | undefined}
23
+ */
24
+ export function flagValue(argv: readonly string[], name: string): string | undefined;
25
+ /**
26
+ * Which of `argv` are flags this command does not know. PURE, so it is testable without a process.
27
+ *
28
+ * A bare `--` is npm's separator and never a flag. Anything not starting with `-` is positional — a URL,
29
+ * a worker address, a page path — and is not this guard's business.
30
+ *
31
+ * SINGLE-DASH FLAGS ARE INSPECTED TOO, AND THE OMISSION COST A 14-MINUTE FLEET OPERATION.
32
+ *
33
+ * This read `startsWith("--")`, on the reasoning that only long flags are ever this repo's own. But an
34
+ * ANSIBLE-shaped argument is single-dash, and several of these commands wrap `ansible-playbook` — so
35
+ * `npm run fleet:provision -- -e worker_edge_allow_downgrade=true` passed straight through the guard,
36
+ * was never forwarded by the wrapper, and the whole fleet was provisioned WITHOUT the authorisation the
37
+ * operator believed they had given. Measured 2026-09-05. The role then refused, correctly, with a message
38
+ * telling the operator to pass the very flag they had just passed.
39
+ *
40
+ * That is precisely the defect this file exists to prevent — "an ignored flag runs the default and reports
41
+ * success" — surviving inside its own remedy, because the remedy was written to match one flag SHAPE
42
+ * rather than the idea of a flag. `-e` is not a URL and not a page path; nothing positional here begins
43
+ * with a dash followed by a letter, which is what makes this safe to refuse rather than merely warn on.
44
+ */
45
+ /**
46
+ * @param {string[]} argv @param {string[]} known @returns {string[]}
47
+ */
48
+ export function unknownFlags(argv: string[], known: string[]): string[];
49
+ /**
50
+ * The closest known flag, when there is one close enough to be a likely typo rather than a guess.
51
+ * @param {string} flag @param {string[]} known @returns {string | undefined}
52
+ */
53
+ export function didYouMean(flag: string, known: string[]): string | undefined;
54
+ /**
55
+ * Refuse, naming the flag and what this command does take. Exits 2; does not return on failure.
56
+ *
57
+ * `command` names the thing a human typed — the npm script, not the file — because that is what they will
58
+ * retype. Defaults to the script's basename, which is right for the ones invoked directly.
59
+ */
60
+ /**
61
+ * @param {string[]} known Every flag this command reads, `--name` or `--name=`.
62
+ * @param {{entry: string, argv?: string[], command?: string}} options
63
+ * `entry` is the caller's `import.meta.url`, and it is REQUIRED. `command` names the thing a HUMAN
64
+ * typed — the npm script, not the file — because that is what they will retype.
65
+ */
66
+ export function refuseUnknownFlags(known: string[], { entry, argv, command }: {
67
+ entry: string;
68
+ argv?: string[];
69
+ command?: string;
70
+ }): void;
71
+ //# sourceMappingURL=cli-flags.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-flags.d.mts","sourceRoot":"","sources":["../src/cli-flags.mjs"],"names":[],"mappings":"AAgDA;;;GAGG;AACH,iCAFW,MAAM,GAAqB,MAAM,CAK3C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,gCAHW,SAAS,MAAM,EAAE,QAAe,MAAM,GACpC,MAAM,GAAG,SAAS,CAM9B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH;;GAEG;AACH,mCAFW,MAAM,EAAE,SAAe,MAAM,EAAE,GAAkB,MAAM,EAAE,CAQnE;AAED;;;GAGG;AACH,iCAFW,MAAM,SAAe,MAAM,EAAE,GAAkB,MAAM,GAAG,SAAS,CAO3E;AAED;;;;;GAKG;AACH;;;;;GAKG;AAMH,0CAVW,MAAM,EAAE,4BACR;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAC,QA+E5D"}
@@ -0,0 +1,207 @@
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
+ import { basename } from "node:path";
26
+ import { realpathSync } from "node:fs";
27
+ import { pathToFileURL } from "node:url";
28
+ /** How far apart two flags may be and still be worth suggesting. One typo, or one word. */
29
+ 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
+ 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
+ }
41
+ previous = row;
42
+ }
43
+ return previous[b.length];
44
+ }
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) {
50
+ const equals = argument.indexOf("=");
51
+ return equals === -1 ? argument : argument.slice(0, equals);
52
+ }
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) {
72
+ const prefix = `--${name}=`;
73
+ const hit = argv.find((a) => a.startsWith(prefix));
74
+ return hit === undefined ? undefined : hit.slice(prefix.length);
75
+ }
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) {
100
+ 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));
105
+ }
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;
115
+ }
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`. */
180
+ let invoked;
181
+ try {
182
+ invoked = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : "";
183
+ }
184
+ catch {
185
+ invoked = pathToFileURL(process.argv[1] ?? "").href; // unresolvable: compare what we were given
186
+ }
187
+ if (entry !== invoked)
188
+ return;
189
+ const unknown = unknownFlags(argv, known);
190
+ if (unknown.length === 0)
191
+ return;
192
+ const name = command ?? basename(process.argv[1] ?? "this command");
193
+ for (const flag of unknown) {
194
+ const near = didYouMean(flag, known);
195
+ console.error(` ${name}: unknown flag ${flag}${near ? ` — did you mean ${near}?` : ""}`);
196
+ }
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(" ")}`);
204
+ console.error(" Refusing rather than ignoring it: an ignored flag runs the default and reports success.");
205
+ process.exit(2);
206
+ }
207
+ //# sourceMappingURL=cli-flags.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-flags.mjs","sourceRoot":"","sources":["../src/cli-flags.mjs"],"names":[],"mappings":"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"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Split a set of `/health.code` readings into stale and unreachable. PURE.
3
+ *
4
+ * `null` is UNREACHABLE and is not a finding, matching `assertOneBrowserAcross`: a box that is asleep
5
+ * contributes no evidence and no mismatch, and treating silence as a fault is how a check earns a
6
+ * reputation for crying wolf. `"absent"` IS a finding — a worker predating `/health.code` is itself a
7
+ * stale deploy.
8
+ *
9
+ * `answered` is returned rather than left to be inferred from the two lists, because "nothing was wrong"
10
+ * and "nothing was examined" produce the SAME empty `stale` — and a caller with only the lists cannot
11
+ * distinguish 3 clean workers from 3 silent ones. Every number carrying what it was computed from is this
12
+ * file's parent rule; this is the one number that was missing.
13
+ *
14
+ * @param {string} expected
15
+ * @param {Array<{worker: string, code: string|null|undefined}>} readings
16
+ * @returns {{expected: string, stale: Array<{worker: string, serving: string}>, unreachable: string[],
17
+ * answered: number}}
18
+ */
19
+ export function codeDrift(expected: string, readings: Array<{
20
+ worker: string;
21
+ code: string | null | undefined;
22
+ }>): {
23
+ expected: string;
24
+ stale: Array<{
25
+ worker: string;
26
+ serving: string;
27
+ }>;
28
+ unreachable: string[];
29
+ answered: number;
30
+ };
31
+ /**
32
+ * Name the deploy route that can actually reach these workers.
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.
37
+ *
38
+ * This printed the utmctl advice unconditionally, including to a fleet of four mini PCs where none of it
39
+ * applies — a tool confidently prescribing a remedy for a different kind of machine. Which kind a worker is
40
+ * is not a guess: `inventory.yml` is the single source of truth for the bare-metal fleet (ADR 0012).
41
+ *
42
+ * PURE, and the bare-metal list is a parameter rather than a read, because a remedy that only appears when
43
+ * something is already broken is one nobody sees until it matters. This repo has shipped an inert remedy
44
+ * before (`refreshBrowseBuffer`, whose trigger was never set) and confirmed it by results it had no part in
45
+ * producing. Returning lines makes both branches assertable with nothing stale and no fleet.
46
+ *
47
+ * @param {string[]} staleUrls
48
+ * @param {string[]} bareMetalUrls
49
+ * @returns {string[]}
50
+ */
51
+ export function remedyLines(staleUrls: string[], bareMetalUrls: string[]): string[];
52
+ /**
53
+ * The refusal text, or `null` when the fleet is running this checkout. PURE.
54
+ *
55
+ * Every number it reports carries what it was computed from, which is this file's parent rule: the hash,
56
+ * which side is dirty, and how many boxes were silent rather than merely absent from the count.
57
+ *
58
+ * @param {{expected: string, stale: Array<{worker: string, serving: string}>, unreachable: string[],
59
+ * answered?: number}} drift
60
+ * @param {{when?: string, bareMetalUrls?: string[], sourceDirty?: string}} options
61
+ * @returns {string|null}
62
+ */
63
+ export function describeCodeDrift(drift: {
64
+ expected: string;
65
+ stale: Array<{
66
+ worker: string;
67
+ serving: string;
68
+ }>;
69
+ unreachable: string[];
70
+ answered?: number;
71
+ }, { when, bareMetalUrls, sourceDirty }?: {
72
+ when?: string;
73
+ bareMetalUrls?: string[];
74
+ sourceDirty?: string;
75
+ }): string | null;
76
+ /** What a single worker is serving, or `null` when it did not answer. */
77
+ /** @param {string} url */
78
+ export function readWorkerCode(url: string): Promise<any>;
79
+ /**
80
+ * Is the worker source in `sourceDir` modified against HEAD?
81
+ *
82
+ * The DIRECTORY IS THE CALLER'S, never a path guessed here: this file is reached from `control` (which asks
83
+ * `layer-checkouts.mjs`) and from `worker-fleet` (which asks the worker package by name), and a guessed
84
+ * monorepo path would read the wrong tree the day the layer lives in another repository (#3394).
85
+ *
86
+ * Guarded, exactly like `protocolBumpNote`: outside a git checkout there is simply nothing to add, and a
87
+ * precondition that throws because `git` is missing is worse than the drift it was checking for.
88
+ *
89
+ * @param {string} sourceDir
90
+ */
91
+ export function workerSourceDirty(sourceDir: string): string;
92
+ /**
93
+ * AN EMPTY POOL IS NOT A CLEAN FLEET — the refusal, as a value, so it can be tested without exiting.
94
+ *
95
+ * `assertFleetRunsThisCheckout([])` used to print "Fleet runs this checkout (worker code …, 0 worker(s)
96
+ * checked)" and return: an affirmative claim about a fleet it had not looked at. The count sitting in the
97
+ * sentence is the only reason that was ever arguable, and "0 worker(s) checked" under a heading saying the
98
+ * fleet is fine is precisely how "verified" comes to mean "unexamined".
99
+ *
100
+ * REFUSED, not reported-and-continued. The pre-push hook's loud skip is right for `runs/`, whose absence is
101
+ * legitimate; an empty pool at a capture boundary never is. Both callers pass a pool a capture is about to
102
+ * dispatch to, so failing here with a named cause beats failing later as "0 captured".
103
+ *
104
+ * Returned rather than written, and that split is this file's own convention: the classification and the
105
+ * message are pure and driven directly by the tests, because a function that calls `process.exit` cannot be
106
+ * asserted on without stubbing the runtime out from under it.
107
+ *
108
+ * @param {string[] | undefined} workers
109
+ * @param {string} expected
110
+ * @returns {string | null} the refusal, or null when there is a pool to check
111
+ */
112
+ export function describeEmptyPool(workers: string[] | undefined, expected: string): string | null;
113
+ /**
114
+ * Refuse to run against a fleet that is not serving `expected` — the shared body of
115
+ * `assertFleetRunsThisCheckout`, taking the hash as a parameter rather than computing it.
116
+ *
117
+ * That split is the whole reason this function exists rather than living only in `worker-code-check.mjs`:
118
+ * computing `expected` needs `codeVersion`/`workerSourceDir`, and reaching those from `packages/control`
119
+ * cannot go through a relative path (TS5055 in `worker-fleet`'s own build) or a package-name import (no
120
+ * `node_modules` on the control plane, ADR 0012) at once — see this file's header. So the CALLER computes
121
+ * `expected` however it can safely reach a hasher, and this function starts from the answer.
122
+ *
123
+ * Exits 3, matching `assertOneBrowserAcross`: a precondition the operator must act on, distinct from 1
124
+ * (captures failed) and 2 (the request was malformed).
125
+ *
126
+ * @param {string} expected
127
+ * @param {string[]} workers
128
+ * `sourceDir` is the worker source the `expected` hash was computed over, so a dirty tree is read from the
129
+ * same place the hash came from.
130
+ *
131
+ * @param {{when?: string, allow?: boolean, read?: (url: string) => Promise<string|null>, bareMetalUrls?: string[], sourceDir: string}} options
132
+ */
133
+ export function assertWorkersServe(expected: string, workers: string[], options: {
134
+ when?: string;
135
+ allow?: boolean;
136
+ read?: (url: string) => Promise<string | null>;
137
+ bareMetalUrls?: string[];
138
+ sourceDir: string;
139
+ }): Promise<void>;
140
+ //# sourceMappingURL=code-drift.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"code-drift.d.mts","sourceRoot":"","sources":["../src/code-drift.mjs"],"names":[],"mappings":"AAoCA;;;;;;;;;;;;;;;;;GAiBG;AACH,oCALW,MAAM,YACN,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAC,IAAI,GAAC,SAAS,CAAA;CAAC,CAAC,GAClD;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,CAAC,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAC;IACzF,QAAQ,EAAE,MAAM,CAAA;CAAC,CAgB9B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,uCAJW,MAAM,EAAE,iBACR,MAAM,EAAE,GACN,MAAM,EAAE,CAmBpB;AAED;;;;;;;;;;GAUG;AACH,yCALW;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,CAAC,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAC;IACxF,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAC,yCACpB;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAC,GAC7D,MAAM,GAAC,IAAI,CA0DvB;AAED,yEAAyE;AACzE,0BAA0B;AAC1B,oCADY,MAAM,gBAoBjB;AAED;;;;;;;;;;;GAWG;AACH,6CAFW,MAAM,UAahB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2CAJW,MAAM,EAAE,GAAG,SAAS,YACpB,MAAM,GACJ,MAAM,GAAG,IAAI,CAOzB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,6CAPW,MAAM,WACN,MAAM,EAAE,WAIR;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAC,iBA2BrI"}