mandrel-platform 1.12.0 → 1.13.1

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.
@@ -13,7 +13,10 @@
13
13
  *
14
14
  * The five surfaces:
15
15
  *
16
- * 1. local — `.env` / `.env.example` in the caller repo
16
+ * 1. local — `.env.example` in the caller repo (the onboarding
17
+ * contract). The doctor never reads a developer's real
18
+ * local file: it holds live values, and a surface this
19
+ * script reports on must be one every run can see.
17
20
  * 2. wrangler — `[vars]` in each Worker's wrangler config
18
21
  * 3. github — Actions secret/variable NAMES (repo + environment scope)
19
22
  * 4. cloudflare— Worker secret NAMES per resolved script name
@@ -133,7 +136,7 @@ export const KEY_SCHEMA = Object.freeze({
133
136
  kind: "'var' | 'secret'",
134
137
  sensitivity: "'public' | 'secret'",
135
138
  residency:
136
- "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
139
+ "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers: (string | {worker, environments})[], kind}|null}",
137
140
  infisical:
138
141
  "{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
139
142
  shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
@@ -456,6 +459,103 @@ function normalizeInfisicalResidency(raw, { at, name, environments }) {
456
459
  return { folders };
457
460
  }
458
461
 
462
+ /**
463
+ * Normalize `residency.cloudflare` to its canonical
464
+ * `{workers: [{worker, environments}], kind}` form.
465
+ *
466
+ * `workers` accepts a bare worker id — the shape every manifest written before
467
+ * Story #483 uses — or a `{worker, environments}` object, because a key can be
468
+ * deliberately resident on one Worker in one environment only: a peer-database
469
+ * credential scoped that tightly to bound its blast radius, or a recipient
470
+ * allowlist that exists only where non-production sending is gated. With no
471
+ * per-entry `environments`, `probeCloudflare` reconciled ONE expected-name list
472
+ * against EVERY environment, so a deliberate single-environment placement had
473
+ * to report `missing` from the others — ten findings on one consumer's correct
474
+ * manifest, every one of them false (Story #481).
475
+ *
476
+ * A bare entry keeps meaning "every environment". That is the load-bearing
477
+ * constraint rather than a convenience: every manifest in existence declares
478
+ * `workers` as a bare string array, so any other reading would break them all.
479
+ *
480
+ * Both authored shapes normalize to one array of `{worker, environments}` with
481
+ * `environments` defaulted and materialized to `manifest.environments`, so
482
+ * `probeCloudflare` has exactly one shape to read — the same
483
+ * normalize-at-parse treatment `residency.github` received in Story #459 and
484
+ * `infisical` in Story #464. Cloudflare is the surface that never got it, and
485
+ * matching them matters more than the field shape itself: three expressive
486
+ * residencies with one idiom, not three.
487
+ *
488
+ * One deliberate divergence from those two: an **empty** `environments` array
489
+ * is rejected rather than read as "resident nowhere". That state is
490
+ * indistinguishable from omitting the residency altogether, and silently
491
+ * accepting it is precisely how a manifest author comes to believe they have
492
+ * scoped something they have not — the same fail-closed posture this module
493
+ * takes on an unknown `shape`.
494
+ *
495
+ * @param {unknown} raw
496
+ * @param {{at: string, name: string, workers: Record<string, object>, environments: string[]}} ctx
497
+ * @returns {{workers: Array<{worker: string, environments: string[]}>, kind: string} | null}
498
+ */
499
+ function normalizeCloudflareResidency(raw, { at, name, workers, environments }) {
500
+ if (raw === undefined || raw === null) return null;
501
+ if (typeof raw !== "object" || Array.isArray(raw)) {
502
+ throw new Error(`${at}.residency.cloudflare must be an object with {workers, kind} (key ${name})`);
503
+ }
504
+ if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
505
+ throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
506
+ }
507
+ if (raw.kind !== "secret" && raw.kind !== "var") {
508
+ throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
509
+ }
510
+
511
+ const seenWorkers = new Set();
512
+ const normalized = raw.workers.map((entry, j) => {
513
+ const where = `${at}.residency.cloudflare.workers[${j}]`;
514
+ let worker;
515
+ let authoredEnvs;
516
+ if (typeof entry === "string") {
517
+ worker = entry;
518
+ } else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
519
+ worker = entry.worker;
520
+ authoredEnvs = entry.environments;
521
+ } else {
522
+ throw new Error(`${where} must be a worker id string or {worker, environments} (key ${name})`);
523
+ }
524
+
525
+ if (typeof worker !== "string" || !Object.hasOwn(workers, worker)) {
526
+ throw new Error(
527
+ `${at}.residency.cloudflare.workers references unknown worker id ${JSON.stringify(worker)} (key ${name})`
528
+ );
529
+ }
530
+ if (seenWorkers.has(worker)) {
531
+ throw new Error(
532
+ `${at}.residency.cloudflare repeats the worker "${worker}" — declare one entry per worker (key ${name})`
533
+ );
534
+ }
535
+ seenWorkers.add(worker);
536
+
537
+ if (authoredEnvs !== undefined) {
538
+ if (!Array.isArray(authoredEnvs) || !authoredEnvs.every((e) => typeof e === "string")) {
539
+ throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
540
+ }
541
+ if (authoredEnvs.length === 0) {
542
+ throw new Error(
543
+ `${where}.environments must not be empty — omit it to mean every environment, or drop the entry (key ${name})`
544
+ );
545
+ }
546
+ for (const e of authoredEnvs) {
547
+ if (!environments.includes(e)) {
548
+ throw new Error(`${where}.environments names "${e}", absent from manifest.environments (key ${name})`);
549
+ }
550
+ }
551
+ }
552
+
553
+ return { worker, environments: authoredEnvs ? [...authoredEnvs] : [...environments] };
554
+ });
555
+
556
+ return { workers: normalized, kind: raw.kind };
557
+ }
558
+
459
559
  /**
460
560
  * @param {unknown} entry
461
561
  * @param {number} index
@@ -494,20 +594,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
494
594
 
495
595
  const github = normalizeGitHubResidency(residency.github, { at, name, environments });
496
596
 
497
- const cloudflare = residency.cloudflare ?? null;
498
- if (cloudflare !== null) {
499
- if (!Array.isArray(cloudflare.workers) || cloudflare.workers.length === 0) {
500
- throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
501
- }
502
- for (const id of cloudflare.workers) {
503
- if (!Object.hasOwn(workers, id)) {
504
- throw new Error(`${at}.residency.cloudflare.workers references unknown worker id "${id}" (key ${name})`);
505
- }
506
- }
507
- if (cloudflare.kind !== "secret" && cloudflare.kind !== "var") {
508
- throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
509
- }
510
- }
597
+ const cloudflare = normalizeCloudflareResidency(residency.cloudflare, { at, name, workers, environments });
511
598
 
512
599
  const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
513
600
 
@@ -530,7 +617,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
530
617
  residency: {
531
618
  local,
532
619
  github,
533
- cloudflare: cloudflare ? { workers: [...cloudflare.workers], kind: cloudflare.kind } : null,
620
+ cloudflare,
534
621
  },
535
622
  infisical,
536
623
  shape: entry.shape ?? null,
@@ -582,27 +669,181 @@ export function checkShape({ value, shape, placeholderPattern = null }) {
582
669
  // ---------------------------------------------------------------------------
583
670
 
584
671
  /**
585
- * Perform a JSON request and tag any HTTP failure with `.httpStatus`, so a
586
- * caller can apply the 404-only degradation rule (see the module docblock).
672
+ * Production request bounds. Every one is a CLIENT-CONSTRUCTOR option so the
673
+ * sibling suite can pass millisecond-scale values: a suite that had to wait
674
+ * out the real budget would simply not assert the timeout at all.
675
+ *
676
+ * A nightly drift gate hanging on one unresponsive store is the fail-open this
677
+ * module exists to refuse in a slower disguise — the job burns its runner
678
+ * minutes and reports nothing, which reads in the Actions UI as a run that has
679
+ * not finished rather than a probe that failed.
680
+ */
681
+ export const DEFAULT_TIMEOUT_MS = 15_000;
682
+ export const DEFAULT_RETRY_DELAY_MS = 500;
683
+
684
+ /** Attempts per request, INCLUDING the first. */
685
+ export const MAX_ATTEMPTS = 3;
686
+
687
+ /**
688
+ * Pages a single listing may follow before the probe fails closed. A store
689
+ * that keeps handing back a `rel="next"` is malfunctioning, and truncating its
690
+ * listing silently would report every un-fetched name as an orphan-free match
691
+ * — the same "no drift because we stopped looking" this module refuses.
692
+ */
693
+ export const MAX_PAGES = 50;
694
+
695
+ /**
696
+ * Which HTTP failures are worth a second attempt. 429 and 5xx are transient by
697
+ * definition; everything else is a statement about the request itself. Retrying
698
+ * a 401 just spends the budget three times to learn what the first attempt said,
699
+ * and retrying a 404 would fight the one degradation rule this module allows.
700
+ *
701
+ * @param {number} status
702
+ * @returns {boolean}
703
+ */
704
+ export function isRetryableStatus(status) {
705
+ return status === 429 || (status >= 500 && status <= 599);
706
+ }
707
+
708
+ /**
709
+ * @param {number} ms
710
+ * @returns {Promise<void>}
711
+ */
712
+ function sleep(ms) {
713
+ return new Promise((resolve) => setTimeout(resolve, ms));
714
+ }
715
+
716
+ /**
717
+ * Fetch with a deadline that does not depend on the fetch honouring it.
718
+ *
719
+ * An `AbortSignal` alone is a REQUEST to stop, and it is only as good as the
720
+ * implementation reading it — a stub, a polyfill, or a wrapper that rebuilds
721
+ * `init` can drop `init.signal` without any error, and the await then never
722
+ * returns. So the signal is passed (real `fetch` uses it to release the socket)
723
+ * AND raced against a timer, and the timer is what actually bounds the call.
724
+ *
725
+ * @param {typeof fetch} fetchImpl
726
+ * @param {string} url
727
+ * @param {RequestInit} init
728
+ * @param {number} timeoutMs
729
+ * @returns {Promise<Response>}
730
+ */
731
+ async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
732
+ const controller = new AbortController();
733
+ let timer = null;
734
+ const deadline = new Promise((_resolve, reject) => {
735
+ timer = setTimeout(() => {
736
+ controller.abort();
737
+ const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} timed out after ${timeoutMs}ms`);
738
+ err.timedOut = true;
739
+ reject(err);
740
+ }, timeoutMs);
741
+ });
742
+ // Resolve.then keeps a fetchImpl that THROWS synchronously on the same
743
+ // rejection path as one that returns a rejected promise.
744
+ const pending = Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal }));
745
+ // The loser of the race still settles. Absorbing its rejection here is what
746
+ // keeps a post-timeout abort from surfacing as an unhandled rejection and
747
+ // tearing down a process that has already handled the timeout.
748
+ pending.catch(() => {});
749
+ try {
750
+ return await Promise.race([pending, deadline]);
751
+ } finally {
752
+ if (timer !== null) clearTimeout(timer);
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Perform one bounded, retried request and return the raw `Response`.
758
+ *
759
+ * Any HTTP failure is tagged with `.httpStatus`, so a caller can apply the
760
+ * 404-only degradation rule (see the module docblock).
761
+ *
762
+ * **A timeout is never retried.** Retrying it would multiply the wall clock by
763
+ * the attempt count, and the surface's whole contract is that it fails within
764
+ * its budget rather than eventually.
587
765
  *
588
766
  * @param {typeof fetch} fetchImpl
589
767
  * @param {string} url
590
768
  * @param {RequestInit} [init]
591
- * @returns {Promise<unknown>}
769
+ * @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
770
+ * @returns {Promise<Response>}
592
771
  */
593
- export async function requestJson(fetchImpl, url, init = {}) {
594
- const res = await fetchImpl(url, init);
595
- if (!res.ok) {
772
+ export async function requestResponse(fetchImpl, url, init = {}, options = {}) {
773
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
774
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
775
+ const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS;
776
+
777
+ let lastError = null;
778
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
779
+ let res;
780
+ try {
781
+ res = await fetchWithTimeout(fetchImpl, url, init, timeoutMs);
782
+ } catch (err) {
783
+ if (err?.timedOut) throw err;
784
+ lastError = err;
785
+ if (attempt === maxAttempts) throw err;
786
+ await sleep(retryDelayMs * 2 ** (attempt - 1));
787
+ continue;
788
+ }
789
+ if (res.ok) return res;
596
790
  // The body can echo request context; it is never a secret VALUE (these are
597
791
  // name-listing endpoints), but it is also not needed — the status is what
598
792
  // routes the decision, so only the status and a redacted URL are surfaced.
599
793
  const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} failed: ${res.status} ${res.statusText}`);
600
794
  err.httpStatus = res.status;
601
- throw err;
795
+ lastError = err;
796
+ if (!isRetryableStatus(res.status) || attempt === maxAttempts) throw err;
797
+ await sleep(retryDelayMs * 2 ** (attempt - 1));
602
798
  }
799
+ /* c8 ignore next 2 -- unreachable: every loop exit above returns or throws. */
800
+ throw lastError ?? new Error(`${redactUrl(url)} failed with no attempt recorded`);
801
+ }
802
+
803
+ /**
804
+ * `requestResponse`, decoded as JSON — what every non-paginating call wants.
805
+ *
806
+ * @param {typeof fetch} fetchImpl
807
+ * @param {string} url
808
+ * @param {RequestInit} [init]
809
+ * @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
810
+ * @returns {Promise<unknown>}
811
+ */
812
+ export async function requestJson(fetchImpl, url, init = {}, options = {}) {
813
+ const res = await requestResponse(fetchImpl, url, init, options);
603
814
  return res.json();
604
815
  }
605
816
 
817
+ /**
818
+ * The `rel="next"` URL of an RFC 8288 `Link` header, or `null`.
819
+ *
820
+ * Parsed by splitting rather than by regex, deliberately twice over: a pattern
821
+ * over a header carrying a URL is the shape CodeQL flags as an unanchored host
822
+ * match, and the grammar here — comma-separated `<uri>; param=value` — is
823
+ * cleanly separable without one.
824
+ *
825
+ * @param {string | null | undefined} header
826
+ * @returns {string | null}
827
+ */
828
+ export function linkNextUrl(header) {
829
+ if (typeof header !== "string" || header.length === 0) return null;
830
+ for (const part of header.split(",")) {
831
+ const segment = part.trim();
832
+ if (!segment.startsWith("<")) continue;
833
+ const close = segment.indexOf(">");
834
+ if (close === -1) continue;
835
+ const url = segment.slice(1, close);
836
+ for (const param of segment.slice(close + 1).split(";")) {
837
+ const [rawName, ...rest] = param.split("=");
838
+ if (rawName.trim().toLowerCase() !== "rel") continue;
839
+ let value = rest.join("=").trim();
840
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
841
+ if (value.toLowerCase() === "next" && url.length > 0) return url;
842
+ }
843
+ }
844
+ return null;
845
+ }
846
+
606
847
  /**
607
848
  * Strip the query string from a URL before it reaches a log line. Query
608
849
  * parameters carry project ids and environment slugs, and a future caller
@@ -634,8 +875,9 @@ export function isAbsentStatus(err) {
634
875
 
635
876
  /**
636
877
  * Parse a dotenv-style file into a name -> value map. Used for the local
637
- * surface; values are read (a local `.env` is already on the developer's disk)
638
- * but only names cross the boundary unless the shape stage asks.
878
+ * surface, whose only input is the committed `.env.example` — a placeholder
879
+ * file by construction. Values are parsed because the format has them, and
880
+ * only names cross the boundary.
639
881
  *
640
882
  * @param {string} text
641
883
  * @returns {Record<string, string>}
@@ -661,26 +903,98 @@ export function parseDotenv(text) {
661
903
  return out;
662
904
  }
663
905
 
906
+ /**
907
+ * Reduce a JSONC document to JSON: drop `//` and block comments, drop trailing
908
+ * commas, and leave everything inside a string literal untouched.
909
+ *
910
+ * A character scanner rather than a substitution, for two independent reasons.
911
+ * The correctness one: a pattern cannot tell a `//` that opens a comment from
912
+ * one inside `"https://example.test"`, and the line-comment substitution this
913
+ * replaces truncated exactly that value — quietly, since the result usually
914
+ * still parsed. The policy one: this repo's SAST refuses a dynamically built
915
+ * `RegExp` outright, so the parsing rules a config like this needs are written
916
+ * as code or not at all.
917
+ *
918
+ * `wrangler.jsonc` is a real shape, not a hypothetical: create-cloudflare's own
919
+ * template emits trailing commas, and until Story #487 every one of them made
920
+ * this function return an empty set — which the caller then read as "this
921
+ * worker declares no vars", the false no-drift verdict.
922
+ *
923
+ * @param {string} text
924
+ * @returns {string}
925
+ */
926
+ export function stripJsonc(text) {
927
+ let out = "";
928
+ let inString = false;
929
+ for (let i = 0; i < text.length; i += 1) {
930
+ const ch = text[i];
931
+ if (inString) {
932
+ out += ch;
933
+ if (ch === "\\") {
934
+ out += text[i + 1] ?? "";
935
+ i += 1;
936
+ continue;
937
+ }
938
+ if (ch === '"') inString = false;
939
+ continue;
940
+ }
941
+ if (ch === '"') {
942
+ inString = true;
943
+ out += ch;
944
+ continue;
945
+ }
946
+ if (ch === "/" && text[i + 1] === "/") {
947
+ while (i < text.length && text[i] !== "\n") i += 1;
948
+ // Keep the newline: JSON ignores it, but a preserved line count keeps a
949
+ // `JSON.parse` position error pointing at the author's own line.
950
+ out += "\n";
951
+ continue;
952
+ }
953
+ if (ch === "/" && text[i + 1] === "*") {
954
+ i += 2;
955
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
956
+ if (text[i] === "\n") out += "\n";
957
+ i += 1;
958
+ }
959
+ i += 1;
960
+ continue;
961
+ }
962
+ if (ch === ",") {
963
+ let j = i + 1;
964
+ while (j < text.length && (text[j] === " " || text[j] === "\t" || text[j] === "\n" || text[j] === "\r")) j += 1;
965
+ if (text[j] === "}" || text[j] === "]") continue;
966
+ }
967
+ out += ch;
968
+ }
969
+ return out;
970
+ }
971
+
664
972
  /**
665
973
  * Extract the `[vars]` block key names from a wrangler config. Supports both
666
974
  * TOML (`[vars]` / `[env.<name>.vars]`) and JSON/JSONC (`"vars": {…}`) — the
667
975
  * two shapes wrangler accepts.
668
976
  *
977
+ * **Throws** when a `.json`/`.jsonc` config cannot be parsed even after the
978
+ * JSONC reduction. Returning `[]` there — as this did until Story #487 — is
979
+ * indistinguishable from a config that genuinely declares nothing, so the
980
+ * caller reported no drift precisely because it could not read the file. The
981
+ * caller turns the throw into one `fail` finding on the `wrangler` surface.
982
+ *
669
983
  * @param {string} text
670
984
  * @param {string} path Used only to pick the parser by extension.
671
985
  * @returns {string[]} Sorted var names.
986
+ * @throws {Error} With `.wranglerParseFailure === true` on unparseable JSONC.
672
987
  */
673
988
  export function parseWranglerVars(text, path) {
674
989
  const names = new Set();
675
990
  if (/\.jsonc?$/.test(path)) {
676
- // Strip line comments so JSONC parses; block comments are not used by
677
- // wrangler's own generated configs.
678
- const stripped = text.replace(/^\s*\/\/.*$/gm, "");
679
991
  let doc;
680
992
  try {
681
- doc = JSON.parse(stripped);
682
- } catch {
683
- return [];
993
+ doc = JSON.parse(stripJsonc(text));
994
+ } catch (err) {
995
+ const failure = new Error(`is not parseable as JSON/JSONC even after comment and trailing-comma removal: ${err.message}`);
996
+ failure.wranglerParseFailure = true;
997
+ throw failure;
684
998
  }
685
999
  collectJsonVars(doc, names);
686
1000
  return [...names].sort();
@@ -725,41 +1039,73 @@ function collectJsonVars(node, names) {
725
1039
  * access to either collection, so the default token can never serve this probe
726
1040
  * (verified against the workflow-syntax permissions reference, 2026-09).
727
1041
  *
1042
+ * `per_page=100` bounds a PAGE, not the collection. A repository with more
1043
+ * than a hundred Actions secrets returns the first hundred and a `Link` header
1044
+ * naming the rest, and reading only page one reports every name beyond it as
1045
+ * `missing` while every genuine orphan past the boundary goes unseen — drift
1046
+ * invented and drift hidden by the same omission. So every listing follows
1047
+ * `rel="next"` to completion, and a listing that will not end fails closed.
1048
+ *
728
1049
  * @param {object} opts
729
1050
  * @param {string} opts.token
730
1051
  * @param {string} opts.repo "owner/name"
731
1052
  * @param {typeof fetch} [opts.fetchImpl]
732
1053
  * @param {string} [opts.apiBase]
1054
+ * @param {number} [opts.timeoutMs]
1055
+ * @param {number} [opts.retryDelayMs]
733
1056
  */
734
- export function createGitHubClient({ token, repo, fetchImpl = fetch, apiBase = GITHUB_API_BASE }) {
1057
+ export function createGitHubClient({
1058
+ token,
1059
+ repo,
1060
+ fetchImpl = fetch,
1061
+ apiBase = GITHUB_API_BASE,
1062
+ timeoutMs = DEFAULT_TIMEOUT_MS,
1063
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
1064
+ }) {
735
1065
  const headers = {
736
1066
  Authorization: `Bearer ${token}`,
737
1067
  Accept: "application/vnd.github+json",
738
1068
  "X-GitHub-Api-Version": "2022-11-28",
739
1069
  };
740
- const get = (path) => requestJson(fetchImpl, `${apiBase}${path}`, { headers });
1070
+ const bounds = { timeoutMs, retryDelayMs };
1071
+
1072
+ /**
1073
+ * @param {string} path
1074
+ * @param {(body: any) => Array<{name: string}>} pick
1075
+ * @returns {Promise<string[]>}
1076
+ */
1077
+ async function listAll(path, pick) {
1078
+ const names = [];
1079
+ let url = `${apiBase}${path}`;
1080
+ for (let page = 0; page < MAX_PAGES; page += 1) {
1081
+ const res = await requestResponse(fetchImpl, url, { headers }, bounds);
1082
+ const body = await res.json();
1083
+ for (const entry of pick(body) ?? []) if (entry?.name) names.push(entry.name);
1084
+ const next = linkNextUrl(typeof res.headers?.get === "function" ? res.headers.get("link") : null);
1085
+ if (!next) return names.sort();
1086
+ url = next;
1087
+ }
1088
+ throw new Error(`${redactUrl(`${apiBase}${path}`)} still offered a rel="next" after ${MAX_PAGES} pages`);
1089
+ }
1090
+
1091
+ /**
1092
+ * @param {string} prefix
1093
+ * @returns {Promise<{secret: string[], var: string[]}>}
1094
+ */
1095
+ async function namesUnder(prefix) {
1096
+ const [secret, vars] = await Promise.all([
1097
+ listAll(`${prefix}/secrets?per_page=100`, (b) => b.secrets ?? []),
1098
+ listAll(`${prefix}/variables?per_page=100`, (b) => b.variables ?? []),
1099
+ ]);
1100
+ return { secret, var: vars };
1101
+ }
741
1102
 
742
1103
  return {
743
- async repositoryNames() {
744
- const [secrets, variables] = await Promise.all([
745
- get(`/repos/${repo}/actions/secrets?per_page=100`),
746
- get(`/repos/${repo}/actions/variables?per_page=100`),
747
- ]);
748
- return {
749
- secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
750
- var: (variables.variables ?? []).map((v) => v.name).sort(),
751
- };
1104
+ repositoryNames() {
1105
+ return namesUnder(`/repos/${repo}/actions`);
752
1106
  },
753
- async environmentNames(environment) {
754
- const env = encodeURIComponent(environment);
755
- const [secrets, variables] = await Promise.all([
756
- get(`/repos/${repo}/environments/${env}/secrets?per_page=100`),
757
- get(`/repos/${repo}/environments/${env}/variables?per_page=100`),
758
- ]);
759
- return {
760
- secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
761
- var: (variables.variables ?? []).map((v) => v.name).sort(),
762
- };
1107
+ environmentNames(environment) {
1108
+ return namesUnder(`/repos/${repo}/environments/${encodeURIComponent(environment)}`);
763
1109
  },
764
1110
  };
765
1111
  }
@@ -773,21 +1119,71 @@ export function createGitHubClient({ token, repo, fetchImpl = fetch, apiBase = G
773
1119
  * @param {string} opts.accountId
774
1120
  * @param {typeof fetch} [opts.fetchImpl]
775
1121
  * @param {string} [opts.apiBase]
1122
+ * @param {number} [opts.timeoutMs]
1123
+ * @param {number} [opts.retryDelayMs]
776
1124
  */
777
- export function createCloudflareClient({ token, accountId, fetchImpl = fetch, apiBase = CLOUDFLARE_API_BASE }) {
1125
+ export function createCloudflareClient({
1126
+ token,
1127
+ accountId,
1128
+ fetchImpl = fetch,
1129
+ apiBase = CLOUDFLARE_API_BASE,
1130
+ timeoutMs = DEFAULT_TIMEOUT_MS,
1131
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
1132
+ }) {
778
1133
  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1134
+ const bounds = { timeoutMs, retryDelayMs };
779
1135
  return {
780
1136
  async secretNames(scriptName) {
781
1137
  const body = await requestJson(
782
1138
  fetchImpl,
783
1139
  `${apiBase}/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`,
784
- { headers }
1140
+ { headers },
1141
+ bounds
785
1142
  );
786
1143
  return (body.result ?? []).map((s) => s.name).sort();
787
1144
  },
788
1145
  };
789
1146
  }
790
1147
 
1148
+ /**
1149
+ * Flatten a v4 secrets response into the secrets RESIDENT AT THE REQUESTED
1150
+ * FOLDER — its own entries plus everything reaching it through an import.
1151
+ *
1152
+ * The v4 list endpoint answers in two parts: `secrets[]` holds what the queried
1153
+ * folder defines itself, and a separate top-level `imports[]` holds one group
1154
+ * per import, each carrying the SOURCE folder in `secretPath`. Reading only the
1155
+ * first part is what made a folder that imports its whole content report every
1156
+ * key `missing` — the store had the secret, the Worker would resolve it, and
1157
+ * the doctor said it was absent.
1158
+ *
1159
+ * `imports[].secretPath` is deliberately DISCARDED rather than used to
1160
+ * re-attribute the name. The manifest declares where a key must be RESOLVABLE,
1161
+ * which is the folder the deploy reads; attributing an imported key back to
1162
+ * `/shared` would report it missing from the folder that legitimately resolves
1163
+ * it and orphaned in a folder the manifest never asked about.
1164
+ *
1165
+ * A name defined directly in the queried folder wins over an imported one of
1166
+ * the same name, matching Infisical's own precedence — so the shape stage
1167
+ * checks the value the deploy would actually see.
1168
+ *
1169
+ * @param {unknown} body
1170
+ * @returns {Array<{secretKey: string, secretValue?: string}>}
1171
+ */
1172
+ export function collectInfisicalSecrets(body) {
1173
+ const merged = new Map();
1174
+ const add = (entry) => {
1175
+ if (entry && typeof entry.secretKey === "string" && entry.secretKey.length > 0) {
1176
+ merged.set(entry.secretKey, entry);
1177
+ }
1178
+ };
1179
+ const imports = Array.isArray(body?.imports) ? body.imports : [];
1180
+ for (const group of imports) {
1181
+ for (const entry of Array.isArray(group?.secrets) ? group.secrets : []) add(entry);
1182
+ }
1183
+ for (const entry of Array.isArray(body?.secrets) ? body.secrets : []) add(entry);
1184
+ return [...merged.values()];
1185
+ }
1186
+
791
1187
  /**
792
1188
  * Infisical client. Authenticates with a pre-issued access token when one is
793
1189
  * supplied, otherwise with a Universal Auth machine identity.
@@ -807,6 +1203,8 @@ export function createCloudflareClient({ token, accountId, fetchImpl = fetch, ap
807
1203
  * @param {string} opts.projectId
808
1204
  * @param {string} [opts.siteUrl]
809
1205
  * @param {typeof fetch} [opts.fetchImpl]
1206
+ * @param {number} [opts.timeoutMs]
1207
+ * @param {number} [opts.retryDelayMs]
810
1208
  */
811
1209
  export function createInfisicalClient({
812
1210
  token = null,
@@ -815,17 +1213,25 @@ export function createInfisicalClient({
815
1213
  projectId,
816
1214
  siteUrl = INFISICAL_DEFAULT_SITE,
817
1215
  fetchImpl = fetch,
1216
+ timeoutMs = DEFAULT_TIMEOUT_MS,
1217
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
818
1218
  }) {
819
1219
  const base = siteUrl.replace(/\/+$/, "");
1220
+ const bounds = { timeoutMs, retryDelayMs };
820
1221
  let accessToken = token;
821
1222
 
822
1223
  async function auth() {
823
1224
  if (accessToken) return accessToken;
824
- const body = await requestJson(fetchImpl, `${base}/api/v1/auth/universal-auth/login`, {
825
- method: "POST",
826
- headers: { "Content-Type": "application/json" },
827
- body: JSON.stringify({ clientId, clientSecret }),
828
- });
1225
+ const body = await requestJson(
1226
+ fetchImpl,
1227
+ `${base}/api/v1/auth/universal-auth/login`,
1228
+ {
1229
+ method: "POST",
1230
+ headers: { "Content-Type": "application/json" },
1231
+ body: JSON.stringify({ clientId, clientSecret }),
1232
+ },
1233
+ bounds
1234
+ );
829
1235
  if (!body.accessToken) throw new Error("Infisical universal-auth login returned no accessToken");
830
1236
  accessToken = body.accessToken;
831
1237
  return accessToken;
@@ -838,11 +1244,19 @@ export function createInfisicalClient({
838
1244
  environment,
839
1245
  secretPath: folder || "/",
840
1246
  viewSecretValue: withValues ? "true" : "false",
1247
+ // Sent EXPLICITLY. The upstream default is documented as true, and a
1248
+ // default is not a contract — a server-side change to it would silently
1249
+ // hide every imported secret and report each one `missing`, which is the
1250
+ // false-drift twin of the false no-drift this module is built against.
1251
+ includeImports: "true",
841
1252
  });
842
- const body = await requestJson(fetchImpl, `${base}/api/v4/secrets?${params.toString()}`, {
843
- headers: { Authorization: `Bearer ${t}` },
844
- });
845
- return body.secrets ?? [];
1253
+ const body = await requestJson(
1254
+ fetchImpl,
1255
+ `${base}/api/v4/secrets?${params.toString()}`,
1256
+ { headers: { Authorization: `Bearer ${t}` } },
1257
+ bounds
1258
+ );
1259
+ return collectInfisicalSecrets(body);
846
1260
  }
847
1261
 
848
1262
  return {
@@ -870,17 +1284,35 @@ export function createInfisicalClient({
870
1284
  * parse: a reference can appear anywhere an expression can, including inside
871
1285
  * a `run:` block's shell, and a structural walk would miss those.
872
1286
  *
1287
+ * Two narrowings keep that reach from over-claiming, and each one had produced
1288
+ * a manifest key that had to exist for a variable that does not:
1289
+ *
1290
+ * - **A whole-line YAML comment is prose, not a reference.** The `#` line
1291
+ * documenting which secret a caller should pass is the single most common
1292
+ * place either token appears, and demanding a manifest entry for it makes
1293
+ * the doctor fail on its own documentation.
1294
+ * - **`vars` reached as a property of something else is not the `vars`
1295
+ * context.** `steps.build.outputs.vars.PROFILE` is a step output that
1296
+ * happens to be named `vars`; `\b` matched it, because a `.` is a word
1297
+ * boundary. The lookbehind refuses any match preceded by a `.` or an
1298
+ * identifier character, which is exactly the set of ways a longer path
1299
+ * can end just before this one starts.
1300
+ *
873
1301
  * @param {string} text
874
1302
  * @returns {{secrets: string[], vars: string[]}}
875
1303
  */
876
1304
  export function collectWorkflowReferences(text) {
877
1305
  const secrets = new Set();
878
1306
  const vars = new Set();
879
- const re = /\b(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)/g;
880
- let m = re.exec(text);
1307
+ const scannable = text
1308
+ .split(/\r?\n/)
1309
+ .map((line) => (line.trimStart().startsWith("#") ? "" : line))
1310
+ .join("\n");
1311
+ const re = /(?<![\w.])(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)/g;
1312
+ let m = re.exec(scannable);
881
1313
  while (m !== null) {
882
1314
  (m[1] === "secrets" ? secrets : vars).add(m[2]);
883
- m = re.exec(text);
1315
+ m = re.exec(scannable);
884
1316
  }
885
1317
  return { secrets: [...secrets].sort(), vars: [...vars].sort() };
886
1318
  }
@@ -977,10 +1409,32 @@ export function runOfflineChecks({ manifest, repoRoot }) {
977
1409
  });
978
1410
  continue;
979
1411
  }
1412
+ let present;
1413
+ try {
1414
+ present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
1415
+ } catch (err) {
1416
+ // One finding, and the worker's expected/orphan reconciliation is
1417
+ // skipped entirely: reporting every declared var as `missing` from a
1418
+ // file nobody could read blames the manifest for the config's syntax.
1419
+ findings.push({
1420
+ severity: "fail",
1421
+ kind: "unreadable",
1422
+ key: null,
1423
+ surface: "wrangler",
1424
+ environment: null,
1425
+ detail: `manifest.workers["${id}"].config ${worker.config} ${err.message}`,
1426
+ });
1427
+ continue;
1428
+ }
980
1429
  checked.push(`wrangler:${id}`);
981
- const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
982
1430
  const expected = manifest.keys.filter(
983
- (k) => k.residency.cloudflare?.kind === "var" && k.residency.cloudflare.workers.includes(id)
1431
+ // Environment-agnostic by design: this check reports `environment: null`
1432
+ // and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
1433
+ // is no environment axis to narrow against. A var declared for ANY
1434
+ // environment stays expected in that worker's config.
1435
+ (k) =>
1436
+ k.residency.cloudflare?.kind === "var" &&
1437
+ k.residency.cloudflare.workers.some((w) => w.worker === id)
984
1438
  );
985
1439
  for (const key of expected) {
986
1440
  if (!present.has(key.name)) {
@@ -1080,6 +1534,13 @@ export function reconcileNames({ expected, present, surface, environment, scope
1080
1534
  * lapsing quietly would re-raise a finding the operator already chose to defer
1081
1535
  * without anyone noticing the deferral had run out.
1082
1536
  *
1537
+ * `severity` selects WHICH finding an entry silences and defaults to `"fail"`.
1538
+ * The `"orphan"` form exists because the alternative was worse: the only way
1539
+ * to silence one known orphan under `--strict-orphans` was to add a manifest
1540
+ * key for a secret the project does not actually declare, which buys quiet by
1541
+ * making the manifest lie — and a lying manifest is the exact false no-drift
1542
+ * this whole module refuses. An orphan exception still expires on its date.
1543
+ *
1083
1544
  * @param {unknown} raw
1084
1545
  * @returns {object[]}
1085
1546
  */
@@ -1096,10 +1557,18 @@ export function parseExceptions(raw) {
1096
1557
  if (Number.isNaN(Date.parse(`${revisit}T00:00:00Z`))) {
1097
1558
  throw new Error(`exceptions[${i}] ("${e.key}") has an unparseable revisit-date "${revisit}"`);
1098
1559
  }
1560
+ const severity = e.severity ?? "fail";
1561
+ if (severity !== "fail" && severity !== "orphan") {
1562
+ throw new Error(
1563
+ `exceptions[${i}] ("${e.key}").severity must be "fail" or "orphan" — got "${severity}". ` +
1564
+ `Omit it to default to "fail".`
1565
+ );
1566
+ }
1099
1567
  return {
1100
1568
  key: e.key,
1101
1569
  surface: e.surface ?? null,
1102
1570
  environment: e.environment ?? null,
1571
+ severity,
1103
1572
  reason: typeof e.reason === "string" ? e.reason : "",
1104
1573
  revisitDate: revisit,
1105
1574
  };
@@ -1126,10 +1595,11 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
1126
1595
  const match = active.find(
1127
1596
  (e) =>
1128
1597
  e.key === f.key &&
1598
+ e.severity === f.severity &&
1129
1599
  (e.surface === null || e.surface === f.surface) &&
1130
1600
  (e.environment === null || e.environment === f.environment)
1131
1601
  );
1132
- if (match && f.severity === "fail") {
1602
+ if (match) {
1133
1603
  suppressed.push({ ...f, exception: match });
1134
1604
  } else {
1135
1605
  kept.push(f);
@@ -1142,6 +1612,48 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
1142
1612
  // Exit contract
1143
1613
  // ---------------------------------------------------------------------------
1144
1614
 
1615
+ /**
1616
+ * Resolve the environments to check, failing CLOSED on one the manifest does
1617
+ * not declare.
1618
+ *
1619
+ * Nothing downstream can catch a misspelling. `--environments prodcution`
1620
+ * against a manifest declaring `production` narrows every reconcile to a slug
1621
+ * no key claims, so `expected` is empty everywhere, the stores are asked for
1622
+ * folders and environments that do not exist, and the run exits 0 with every
1623
+ * surface `checked` and zero findings — the most convincing possible report
1624
+ * that nothing is wrong, produced by a run that examined nothing. A typo in a
1625
+ * cron-scheduled workflow input can hold that state indefinitely.
1626
+ *
1627
+ * An empty request is not an error: it means "use the manifest's own list",
1628
+ * which is exactly what the reusable workflow's empty `environments` input
1629
+ * interpolates to.
1630
+ *
1631
+ * @param {object} opts
1632
+ * @param {string | null | undefined} opts.requested Raw comma-separated CLI/input value.
1633
+ * @param {{environments: string[]}} opts.manifest
1634
+ * @returns {string[]}
1635
+ * @throws {Error} When a requested slug is not in `manifest.environments`.
1636
+ */
1637
+ export function resolveEnvironments({ requested, manifest }) {
1638
+ const wanted =
1639
+ typeof requested === "string"
1640
+ ? requested
1641
+ .split(",")
1642
+ .map((slug) => slug.trim())
1643
+ .filter(Boolean)
1644
+ : [];
1645
+ if (wanted.length === 0) return manifest.environments;
1646
+ const unknown = wanted.filter((slug) => !manifest.environments.includes(slug));
1647
+ if (unknown.length > 0) {
1648
+ throw new Error(
1649
+ `--environments requested ${unknown.map((slug) => `"${slug}"`).join(", ")}, which manifest.environments ` +
1650
+ `does not declare. Declared environments: ${manifest.environments.join(", ")}. ` +
1651
+ `Nothing was probed — an undeclared environment would report zero findings on every surface.`
1652
+ );
1653
+ }
1654
+ return wanted;
1655
+ }
1656
+
1145
1657
  /**
1146
1658
  * Decide the process exit code from a completed report.
1147
1659
  *
@@ -1202,7 +1714,8 @@ export function renderReport(report) {
1202
1714
  lines.push("Suppressed by an active exception:");
1203
1715
  for (const s of report.suppressed) {
1204
1716
  lines.push(
1205
- ` - ${s.key} [${s.surface}${s.environment ? `/${s.environment}` : ""}] — revisit ${s.exception.revisitDate}${s.exception.reason ? `: ${s.exception.reason}` : ""}`
1717
+ ` - ${s.key} [${s.surface}${s.environment ? `/${s.environment}` : ""}] (${s.severity}) — ` +
1718
+ `revisit ${s.exception.revisitDate}${s.exception.reason ? `: ${s.exception.reason}` : ""}`
1206
1719
  );
1207
1720
  }
1208
1721
  }
@@ -1417,10 +1930,37 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1417
1930
  }
1418
1931
 
1419
1932
  /**
1933
+ * Reconcile the Cloudflare surface per `(worker, environment)` pair.
1934
+ *
1935
+ * `expected` is narrowed to the keys whose residency names BOTH this worker
1936
+ * and this environment, so a deliberate single-environment placement no longer
1937
+ * reports `missing` from the environments it never claimed (Story #481).
1938
+ *
1939
+ * Two consequences of that narrowing are load-bearing, and neither is
1940
+ * incidental:
1941
+ *
1942
+ * 1. **A worker is still probed in an environment it expects nothing in**,
1943
+ * as long as it expects something SOMEWHERE. Skipping it would take the
1944
+ * surface's most interesting finding with it: a production-only key
1945
+ * turning up in staging is undeclared presence, and only an
1946
+ * empty-`expected` reconcile against a non-empty `present` reports it.
1947
+ * Cross-environment orphans go unsuppressed here exactly as they do on
1948
+ * the GitHub and Infisical surfaces. A worker that declares nothing in
1949
+ * any environment is still skipped entirely — that is the manifest
1950
+ * saying it has no opinion, which is not the same statement.
1951
+ * 2. **A 404 is only a finding where something WAS expected.** A worker
1952
+ * deployed to one environment by design 404s in the other, and with
1953
+ * nothing declared there that agrees with the manifest rather than
1954
+ * contradicting it. Reporting it would re-introduce, one layer down, the
1955
+ * same false failure this narrowing removes.
1956
+ *
1420
1957
  * @param {object} ctx
1421
1958
  */
1422
1959
  async function probeCloudflare({ manifest, environments, cloudflare, surfaces, findings, unavailability = {} }) {
1423
1960
  const cfKeys = manifest.keys.filter((k) => k.residency.cloudflare?.kind === "secret");
1961
+ /** Does any key declare this worker in any environment at all? */
1962
+ const declaresWorker = (id) =>
1963
+ cfKeys.some((k) => k.residency.cloudflare.workers.some((w) => w.worker === id));
1424
1964
  if (!cloudflare) {
1425
1965
  surfaces.push({
1426
1966
  surface: "cloudflare",
@@ -1432,8 +1972,12 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1432
1972
  try {
1433
1973
  for (const environment of environments) {
1434
1974
  for (const [id, worker] of Object.entries(manifest.workers)) {
1435
- const expected = cfKeys.filter((k) => k.residency.cloudflare.workers.includes(id)).map((k) => k.name);
1436
- if (expected.length === 0) continue;
1975
+ const expected = cfKeys
1976
+ .filter((k) =>
1977
+ k.residency.cloudflare.workers.some((w) => w.worker === id && w.environments.includes(environment))
1978
+ )
1979
+ .map((k) => k.name);
1980
+ if (!declaresWorker(id)) continue;
1437
1981
  const scriptName = resolveScriptName(worker.scriptName, environment);
1438
1982
  let present;
1439
1983
  try {
@@ -1441,15 +1985,20 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1441
1985
  } catch (err) {
1442
1986
  if (!isAbsentStatus(err)) throw err;
1443
1987
  // A 404 is the one status that legitimately means "absent": the
1444
- // Worker has not been deployed to this environment yet.
1445
- findings.push({
1446
- severity: "fail",
1447
- kind: "missing",
1448
- key: null,
1449
- surface: "cloudflare",
1450
- environment,
1451
- detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
1452
- });
1988
+ // Worker has not been deployed to this environment yet. That is only
1989
+ // drift where the manifest expected something here; a worker
1990
+ // deliberately absent from an environment it declares nothing in is
1991
+ // agreement, not a finding.
1992
+ if (expected.length > 0) {
1993
+ findings.push({
1994
+ severity: "fail",
1995
+ kind: "missing",
1996
+ key: null,
1997
+ surface: "cloudflare",
1998
+ environment,
1999
+ detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
2000
+ });
2001
+ }
1453
2002
  continue;
1454
2003
  }
1455
2004
  findings.push(
@@ -1747,12 +2296,13 @@ async function main() {
1747
2296
  }
1748
2297
  }
1749
2298
 
1750
- const environments = opts.environments
1751
- ? opts.environments
1752
- .split(",")
1753
- .map((s) => s.trim())
1754
- .filter(Boolean)
1755
- : manifest.environments;
2299
+ let environments;
2300
+ try {
2301
+ environments = resolveEnvironments({ requested: opts.environments, manifest });
2302
+ } catch (err) {
2303
+ process.stderr.write(`[env-doctor] ERROR: ${err.message}\n`);
2304
+ process.exit(1);
2305
+ }
1756
2306
 
1757
2307
  // Offline builds no clients at all; `runDoctor` marks the live surfaces
1758
2308
  // skipped-because-offline rather than reaching for an unavailability reason.
@@ -1780,12 +2330,19 @@ async function main() {
1780
2330
  process.stdout.write(renderReport(report));
1781
2331
  }
1782
2332
 
2333
+ // Annotations go to STDERR in every mode, not just under `--json`. Actions
2334
+ // reads workflow commands from both streams, so nothing is lost — but stdout
2335
+ // is the machine channel, and `--json` promising one JSON document while
2336
+ // appending `::notice` lines to it made `JSON.parse(stdout)` throw for every
2337
+ // consumer whose run had an unchecked surface, which is most of them.
2338
+ // Splitting by mode would leave the text mode's stdout un-pipeable for the
2339
+ // same reason, so the rule is unconditional.
1783
2340
  for (const s of report.surfaces) {
1784
2341
  if (s.status === "unchecked") {
1785
- process.stdout.write(`::notice title=env-doctor surface unchecked::${s.surface}: ${s.notice}\n`);
2342
+ process.stderr.write(`::notice title=env-doctor surface unchecked::${s.surface}: ${s.notice}\n`);
1786
2343
  }
1787
2344
  if (s.status === "error") {
1788
- process.stdout.write(`::error title=env-doctor probe failed::${s.surface}: ${s.notice}\n`);
2345
+ process.stderr.write(`::error title=env-doctor probe failed::${s.surface}: ${s.notice}\n`);
1789
2346
  }
1790
2347
  }
1791
2348