clearotron 0.3.0 → 0.3.1-beta.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.
@@ -25,6 +25,7 @@ import { join, dirname } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { findings, sentences } from "./changelog-plain-language.mjs";
27
27
  import { tagsHere } from "./release-cut-decision.mjs";
28
+ import { preModeFrom } from "./release-dist-tag.mjs";
28
29
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
29
30
 
30
31
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -41,6 +42,70 @@ export function alreadyOut({ version, tags = [], published = false }) {
41
42
  return where;
42
43
  }
43
44
 
45
+ // ── THE CHANNEL DECIDES THE MODE, AND IT HAS TO BE DECIDED HERE ───────────────────────────────────
46
+ //
47
+ // `changeset version` computes a pre-release number or a stable one depending on whether the tree is in
48
+ // pre-release mode when it runs. So something has to put the tree in the mode the chosen channel needs.
49
+ //
50
+ // THAT USED TO BE A STEP IN THE WORKFLOW, AND IT COULD NEVER HAVE WORKED. The step edited
51
+ // `.changeset/pre.json` in the working tree, and the action that runs this script rebuilds that tree
52
+ // before running it: `git checkout changeset-release/main`, then `git reset --hard` to the commit the
53
+ // run was dispatched from. Read in the pinned action's own source, in this order — the rebuild, then
54
+ // this script, then a commit made with `git add .` from the working tree, and nothing in between that
55
+ // touches git. So an edit made before the action is discarded, and an edit made here is committed.
56
+ //
57
+ // The failure had two halves and only one of them was visible. A stable dispatch could not leave
58
+ // pre-release mode, so it computed another beta and refused at the channel check — loud, and it cost a
59
+ // cut. The other half is silent: a stable deletes `pre.json` rather than leaving it saying `exit`, so a
60
+ // later beta dispatch finds no flag, cannot enter pre mode, and computes a STABLE version from a button
61
+ // marked beta. Fixing only the direction that announces itself would leave that one live, and it would
62
+ // surface weeks later as a release published to the wrong channel.
63
+ //
64
+ // Both directions are therefore driven from here, and both are no-ops when the tree is already right:
65
+ // the mode a cut needs is a fact about the channel asked for, not about what the last cut happened to do.
66
+ export const CUT_FLAG = "--cut=";
67
+
68
+ /**
69
+ * PURE. What this cut must do to the tree's pre-release mode before the version is computed.
70
+ *
71
+ * `null` means leave the tree alone — a rehearsal, an ordinary push, or a tree already in the right
72
+ * mode. Anything else is the `changeset pre` arguments that put it there.
73
+ *
74
+ * An UNKNOWN channel leaves the tree alone rather than guessing. This runs on every push as well as on
75
+ * a dispatch, and a push carries no channel at all: changing the mode there would make an ordinary
76
+ * merge silently switch the line it is publishing on.
77
+ */
78
+ export function modeTransition({ cut, mode }) {
79
+ const inPre = mode === "pre";
80
+ if (cut === "beta") return inPre ? null : ["pre", "enter", "beta"];
81
+ if (cut === "stable") return inPre ? ["pre", "exit"] : null;
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * The channel this run asked for, and the arguments to forward on without it.
87
+ *
88
+ * SPLIT RATHER THAN READ, because everything not consumed here is forwarded verbatim to
89
+ * `changeset version`, which refuses an argument it does not know. A flag added to this script that is
90
+ * not removed from that list stops the cut.
91
+ */
92
+ export function splitCut(args = []) {
93
+ const flag = args.find((a) => a.startsWith(CUT_FLAG));
94
+ return { cut: flag ? flag.slice(CUT_FLAG.length) : "", rest: args.filter((a) => a !== flag) };
95
+ }
96
+
97
+ /**
98
+ * The tree's pre-release mode, or `"none"` when it carries no flag at all.
99
+ *
100
+ * `"none"` is a real state here rather than a missing reading: it is what a tree looks like after a
101
+ * stable, because `changeset version` DELETES the file rather than leaving it saying `exit`.
102
+ */
103
+ export function preModeHere(root = ROOT) {
104
+ const p = join(root, ".changeset", "pre.json");
105
+ if (!existsSync(p)) return "none";
106
+ return preModeFrom(readFileSync(p, "utf8")) ? "pre" : "exit";
107
+ }
108
+
44
109
  /**
45
110
  * Whether the registry already carries `name@version`: true, false, or null when it could not be asked.
46
111
  *
@@ -223,11 +288,23 @@ export function writeRootChangelog({ version, groups }, root = ROOT) {
223
288
  }
224
289
 
225
290
  function main() {
226
- const args = process.argv.slice(2);
291
+ const { cut, rest } = splitCut(process.argv.slice(2));
227
292
  const run = (...a) => execFileSync(process.execPath,
228
293
  [join(ROOT, "node_modules/@changesets/cli/bin.js"), ...a], { cwd: ROOT, stdio: "inherit" });
229
294
 
230
- run("version", ...args);
295
+ // BEFORE THE VERSION IS COMPUTED, because the mode is what decides the number. The header above says
296
+ // why this cannot live in the workflow. It is announced either way: a cut that did nothing to the mode
297
+ // and a cut whose mode change was lost look identical in a log that only speaks up when it acts.
298
+ const modeWas = preModeHere();
299
+ const transition = modeTransition({ cut, mode: modeWas });
300
+ console.log(`release-version: asked for ${cut || "no channel"}, tree is in pre-release mode ${modeWas}`
301
+ + ` — ${transition ? `running changeset ${transition.join(" ")}` : "nothing to change"}`);
302
+ if (transition) {
303
+ run(...transition);
304
+ console.log(`release-version: pre-release mode is now ${preModeHere()}`);
305
+ }
306
+
307
+ run("version", ...rest);
231
308
  const version = groupVersion();
232
309
 
233
310
  // ── A VERSION ALREADY OUT IS REFUSED HERE, BEFORE ANYTHING IS STAMPED ─────────────────────────────
@@ -258,8 +335,11 @@ function main() {
258
335
  console.error(`release-version: this cut computed ${version}, and that version is already out: `
259
336
  + `${out.join(" and ")}. Publishing it would fail after main is stamped and the notes are consumed.\n`
260
337
  + " The pre-release line keeps no record of its last number: .changeset/pre.json holds only its mode "
261
- + "and tag, and a stable release deletes it, so this was counted again from package.json. Put the "
262
- + "line back where its last published version left it, then cut again.");
338
+ + "and tag, and a stable release deletes it, so this was counted again from package.json.\n"
339
+ + " The MODE is not the thing to repair — this step sets it from the channel that was asked for, and "
340
+ + "the line above says which mode it found and what it did. What is wrong is the NUMBER the manifests "
341
+ + "count from: open an ordinary pull request setting them to the last published version, which "
342
+ + "publishes nothing, and cut again.");
263
343
  process.exitCode = 1;
264
344
  return;
265
345
  }
@@ -61,6 +61,27 @@ export const SUPERVISED = "supervised";
61
61
  * because a command printed without it is command-not-found in the terminal the reader is sitting in:
62
62
  * doctor's own guard runs every command doctor prints, and it caught this one.
63
63
  */
64
+ /**
65
+ * Which credential did the door ask for when it refused? `"proxy"`, `"key"`, or null for "it did not say".
66
+ *
67
+ * THE DOOR'S OWN SENTENCE IS THE EVIDENCE, because the header is not. An identity-proxy door and a key
68
+ * door both answer an unauthenticated request with 401 and neither sends `www-authenticate`, so the only
69
+ * thing on the wire that distinguishes them is what the refusal says it wants.
70
+ *
71
+ * THIS MATCHES A SPELLING, AND A SPELLING CAN MOVE. The two sentences are written by the door
72
+ * (`mcp-server/lib/cf-access.mjs` for the proxy refusal, `mcp-server/lib/http-handler.mjs` for the key
73
+ * one). If either is reworded this stops recognising it and the verdict degrades to "did not say" — the
74
+ * reported state, not a wrong confident one, which is the right direction to fail in. A test pins both
75
+ * sentences against the doors that send them so the drift is named rather than silent.
76
+ */
77
+ export function doorCredential(probe) {
78
+ const body = String(probe?.body ?? "");
79
+ if (!body) return null;
80
+ if (/auth-proxy jwt|jwt-assertion|access token/i.test(body)) return "proxy";
81
+ if (/access key|[?&]token=/i.test(body)) return "key";
82
+ return null;
83
+ }
84
+
64
85
  export function triggerLaneVerdict({ url = null, hasToken = false, verbs = null, posture = SUPERVISED, probe = null, invoke = "" } = {}) {
65
86
  const startCmd = `${invoke}clearotron start`;
66
87
  const raw = String(url ?? "").trim();
@@ -131,6 +152,51 @@ export function triggerLaneVerdict({ url = null, hasToken = false, verbs = null,
131
152
  const challenge = challengeVerdict(probe);
132
153
  if (challenge.blocked) return { state: "fail", message: blockedByAccessChallenge(raw, probe.status) };
133
154
  if (probe.ok) {
155
+ // ── A 401 IS ANSWERED BY STATUS AND REFUSED IN SUBSTANCE ─────────────────────────────────────────
156
+ //
157
+ // `probe.ok` is `status < 500`, so a door REFUSING the caller lands here and was reported as a lane
158
+ // that answers. That is not a near miss: an identity-proxy door and a key door both refuse an
159
+ // unauthenticated GET with 401 and NEITHER sends `www-authenticate`, so the challenge test above
160
+ // cannot separate them and this line printed the same sentence for a deployment that works and one
161
+ // that cannot carry a Start. A line that is identical in both states carries no information about
162
+ // either, which is what makes it a defect rather than an optimistic guess.
163
+ //
164
+ // WHAT SEPARATES THEM IS THE DOOR'S OWN SENTENCE. Each mode says which credential it wants, in the
165
+ // body: the proxy door asks for a proxy assertion, the key door asks for an access key. This portal
166
+ // holds a key and cannot produce a proxy identity, so the first is unreachable to it no matter what
167
+ // the key is.
168
+ const wants = doorCredential(probe);
169
+ if (probe.status === 401 && wants === "proxy") {
170
+ return { state: "fail",
171
+ message: `${raw} is fronted by an identity proxy: it refused an unauthenticated request by asking `
172
+ + "for a proxy identity, and this portal presents an access key. The Start button will fail at "
173
+ + "the door with a 401 upstream and a 502 to the client, whatever the key is. Either put the "
174
+ + "portal behind the same proxy, or give it a door that accepts the key." };
175
+ }
176
+ if (probe.status === 401 && wants === "key") {
177
+ return { state: "pass",
178
+ message: `the trigger lane answers at ${raw} (401 to an unauthenticated check, asking for the `
179
+ + `access key this portal holds)${challengeNote(challenge)}` };
180
+ }
181
+ if (probe.status === 401) {
182
+ // THE BODY IS NEW EVIDENCE, NOT A REPLACEMENT FOR WHAT WE HAD. A door that challenges for a bearer
183
+ // token has said, in the header, that it takes the kind of credential this portal holds — that is
184
+ // the reading this verdict was built on and it stays. Only when the refusal names no credential in
185
+ // EITHER place is there nothing to go on.
186
+ if (/bearer|oauth/i.test(String(probe.challenge ?? ""))) {
187
+ return { state: "pass",
188
+ message: `the trigger lane answers at ${raw} (401 to an unauthenticated check, challenging for a `
189
+ + `token)${challengeNote(challenge)}` };
190
+ }
191
+ // REPORTED, NOT JUDGED — the doctrine this block already follows for an ambiguous challenge. A 401
192
+ // that names no credential in its body and offers no challenge may be a proxy we have not met or a
193
+ // door that changed its wording; claiming either way would be the original defect with the
194
+ // confidence pointed somewhere new.
195
+ return { state: "unsettled",
196
+ message: `${raw} refused an unauthenticated check with 401 and did not say which credential it `
197
+ + "wants, in its challenge or its body, so this cannot tell whether the portal's access key will "
198
+ + "be accepted. Present the key against it before relying on the Start button." };
199
+ }
134
200
  return { state: "pass",
135
201
  message: `the trigger lane answers at ${raw}${probe.status ? ` (${probe.status})` : ""}${challengeNote(challenge)}` };
136
202
  }
package/shared/wsl.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Whether this is a Linux running under Windows. Shared because two readers need the one answer: the install
5
5
  // (which binaries on a Windows drive to pass over) and the connect lines (which must say to run them inside
6
6
  // WSL, where their paths exist).
7
- import { readFileSync } from "node:fs";
7
+ import { existsSync, readFileSync } from "node:fs";
8
8
 
9
9
  /**
10
10
  * BOTH SIGNALS INJECTABLE, for the reason `platformEngineRefusal` gives: the readers this protects are the
@@ -15,9 +15,26 @@ import { readFileSync } from "node:fs";
15
15
  * resolution exactly as it was before this existed. Claiming WSL on a could-not-read would start refusing
16
16
  * candidates under /mnt on an ordinary Linux box with an ordinary mount.
17
17
  */
18
- export function isWsl({ env = process.env, procVersion = null } = {}) {
18
+ // THE INTEROP REGISTRATION BOTH GENERATIONS INSTALL, and the reason this file no longer matches a vendor
19
+ // string. The older generation's /proc/version carries a vendor name and NOT the token "wsl", so the only
20
+ // thing catching it was that name — identifying a platform by a third party's name, on a public surface,
21
+ // for want of another signal. This is the other signal, and it names the platform rather than a company.
22
+ //
23
+ // IT SHIPS ON REASONING, which is worth saying rather than implying. Neither generation runs on any box
24
+ // here, so nothing in this suite can demonstrate that the older one registers this entry: the newer one
25
+ // demonstrably does, the older one is documented to, and a walk through a real install is the test that
26
+ // exists. If it turns out not to, the failure is the one this file already fails safe into — "not WSL",
27
+ // which leaves the resolution exactly as it was, rather than claiming WSL on a box that is not one.
28
+ export const WSL_INTEROP_ENTRY = "/proc/sys/fs/binfmt_misc/WSLInterop";
29
+
30
+ export function isWsl({ env = process.env, procVersion = null, interopEntry = null } = {}) {
19
31
  if (String(env.WSL_DISTRO_NAME ?? "").trim()) return true;
20
32
  if (String(env.WSL_INTEROP ?? "").trim()) return true;
33
+ // Injectable like the other two, and for the same reason this file already gives: the readers this
34
+ // protects cannot run the suite to find out, so a Linux runner has to be able to drive both answers
35
+ // rather than read the source and agree with it.
36
+ const interop = interopEntry ?? (() => { try { return existsSync(WSL_INTEROP_ENTRY); } catch { return false; } })();
37
+ if (interop) return true;
21
38
  const v = procVersion ?? (() => { try { return readFileSync("/proc/version", "utf8"); } catch { return ""; } })();
22
- return /microsoft|wsl/i.test(v);
39
+ return /wsl/i.test(v);
23
40
  }