@reddoorla/maintenance 0.86.0 → 0.87.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 (167) hide show
  1. package/dist/airtable-ZBUPQJSC.js +9 -0
  2. package/dist/{announce-MXTUQ6C7.js → announce-I2Z4JN3I.js} +49 -28
  3. package/dist/announce-I2Z4JN3I.js.map +1 -0
  4. package/dist/attachments-NLZIJ5NU.js +9 -0
  5. package/dist/{bump-deps-EC74CPLS.js → bump-deps-CGVELRLK.js} +2 -2
  6. package/dist/{chunk-2ZPT7GLV.js → chunk-3JQ2FO3D.js} +3 -3
  7. package/dist/chunk-3JQ2FO3D.js.map +1 -0
  8. package/dist/{chunk-KTH5ZODA.js → chunk-7BZUOZPU.js} +2 -2
  9. package/dist/{chunk-LDWZSLSU.js → chunk-7NYN2P6I.js} +7 -4
  10. package/dist/{chunk-LDWZSLSU.js.map → chunk-7NYN2P6I.js.map} +1 -1
  11. package/dist/{chunk-3VQWSE3F.js → chunk-7UAYMAKR.js} +4 -4
  12. package/dist/{chunk-BYK66EWO.js → chunk-BBUSGEES.js} +52 -46
  13. package/dist/chunk-BBUSGEES.js.map +1 -0
  14. package/dist/{chunk-SLUQ7R6I.js → chunk-C6CLRKPV.js} +5 -5
  15. package/dist/{chunk-P2FE5AI6.js → chunk-CROKUHJB.js} +2 -2
  16. package/dist/{chunk-W7Z3BGCI.js → chunk-DEWJMMG3.js} +2 -2
  17. package/dist/{chunk-ACBRLLFT.js → chunk-F3JWOPTG.js} +7 -3
  18. package/dist/chunk-F3JWOPTG.js.map +1 -0
  19. package/dist/{chunk-TERE5SDF.js → chunk-HEWSRXYV.js} +4 -4
  20. package/dist/chunk-HHMMUR4P.js +335 -0
  21. package/dist/chunk-HHMMUR4P.js.map +1 -0
  22. package/dist/{chunk-X3B723J4.js → chunk-HSI6Q7MI.js} +38 -9
  23. package/dist/chunk-HSI6Q7MI.js.map +1 -0
  24. package/dist/{chunk-Z7AXYKQB.js → chunk-IY6256GK.js} +1 -1
  25. package/dist/{chunk-CQR4LIM7.js → chunk-MT2JINSS.js} +5 -1
  26. package/dist/{chunk-CQR4LIM7.js.map → chunk-MT2JINSS.js.map} +1 -1
  27. package/dist/{chunk-F5KM4LEQ.js → chunk-NCMZMLY7.js} +3 -3
  28. package/dist/{chunk-2NDRHXMR.js → chunk-NDWYJLRF.js} +17 -140
  29. package/dist/chunk-NDWYJLRF.js.map +1 -0
  30. package/dist/{chunk-PPE5KZJK.js → chunk-NXNPDCF3.js} +3 -3
  31. package/dist/{chunk-ITIOFN7A.js → chunk-P6AORG32.js} +29 -10
  32. package/dist/chunk-P6AORG32.js.map +1 -0
  33. package/dist/{chunk-N45M3FDK.js → chunk-SDDPUAWH.js} +4 -4
  34. package/dist/{chunk-ZKXSX4LO.js → chunk-TD2NNXLZ.js} +9 -9
  35. package/dist/{chunk-ILUNZHYV.js → chunk-YFAXOD7Z.js} +10 -6
  36. package/dist/chunk-YFAXOD7Z.js.map +1 -0
  37. package/dist/{chunk-2EVEFOZY.js → chunk-ZBDZTJUO.js} +2 -2
  38. package/dist/chunk-ZGBEIWSC.js +146 -0
  39. package/dist/chunk-ZGBEIWSC.js.map +1 -0
  40. package/dist/cli/bin.js +32 -27
  41. package/dist/cli/bin.js.map +1 -1
  42. package/dist/cli/commands/audit.js +15 -11
  43. package/dist/cli/commands/audit.js.map +1 -1
  44. package/dist/{convert-to-pnpm-6L6P74WR.js → convert-to-pnpm-QFJOEP5V.js} +2 -2
  45. package/dist/{db-B35OGV3G.js → db-UXNIZ2RG.js} +6 -6
  46. package/dist/{digest-BL6UJYKH.js → digest-FW6QMZZJ.js} +17 -16
  47. package/dist/{digest-BL6UJYKH.js.map → digest-FW6QMZZJ.js.map} +1 -1
  48. package/dist/{email-VTTTKKL5.js → email-KJXASVH5.js} +3 -3
  49. package/dist/{ensure-site-DFTB7MN3.js → ensure-site-4DCEBAPM.js} +19 -11
  50. package/dist/ensure-site-4DCEBAPM.js.map +1 -0
  51. package/dist/fleet-state-7TJ2GYT2.js +39 -0
  52. package/dist/{forms-notify-target-75U6OU7L.js → forms-notify-target-2EAWCWBW.js} +7 -4
  53. package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
  54. package/dist/{github-signals-IJUPGCUX.js → github-signals-E2WPYTBK.js} +9 -9
  55. package/dist/{header-image-AWLLQA4L.js → header-image-6IC53W27.js} +7 -7
  56. package/dist/{header-images-335L2AKB.js → header-images-SQBCE5OD.js} +21 -1
  57. package/dist/header-images-SQBCE5OD.js.map +1 -0
  58. package/dist/{health-endpoint-WJC2UMYS.js → health-endpoint-RFXL2F4A.js} +2 -2
  59. package/dist/health-mirror-L5JBEJVA.js +41 -0
  60. package/dist/health-mirror-L5JBEJVA.js.map +1 -0
  61. package/dist/{import-airtable-HE43FB4H.js → import-airtable-OHVY344G.js} +7 -5
  62. package/dist/index.d.ts +149 -0
  63. package/dist/index.js +147 -50
  64. package/dist/index.js.map +1 -1
  65. package/dist/{init-NJEOEO7I.js → init-XRAQZYID.js} +9 -9
  66. package/dist/{launch-XNAX427U.js → launch-IX4Y5JJC.js} +55 -26
  67. package/dist/launch-IX4Y5JJC.js.map +1 -0
  68. package/dist/{lighthouse-airtable-LBVIYYP5.js → lighthouse-airtable-3GUWHETW.js} +3 -3
  69. package/dist/{onboard-GK4PHPNI.js → onboard-O4IAHJMO.js} +2 -2
  70. package/dist/{orchestrate-77UO4BYJ.js → orchestrate-YLWQ6KK2.js} +10 -9
  71. package/dist/{parity-MWS24A3H.js → parity-56KA2DGR.js} +4 -4
  72. package/dist/{pipeline-S6CVHDGB.js → pipeline-3AANZFCR.js} +2 -2
  73. package/dist/{preflight-6WBNU2GZ.js → preflight-TNORKGCX.js} +11 -10
  74. package/dist/{preflight-6WBNU2GZ.js.map → preflight-TNORKGCX.js.map} +1 -1
  75. package/dist/{prismic-ci-3RHOZUUT.js → prismic-ci-G2AK53VL.js} +5 -5
  76. package/dist/{prismic-models-QBGUT2Q3.js → prismic-models-O6Z3OK3A.js} +13 -8
  77. package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
  78. package/dist/prospect/types.d.ts +267 -0
  79. package/dist/prospect/types.js +1 -0
  80. package/dist/{prospect-audit-IUNCJ2LV.js → prospect-audit-KSXUNQ5J.js} +4 -4
  81. package/dist/recipes/sync-configs.js +2 -2
  82. package/dist/{render-AQAVQDPZ.js → render-IFOGFB5T.js} +3 -3
  83. package/dist/render-IFOGFB5T.js.map +1 -0
  84. package/dist/render-from-row-BWSOIYDS.js +17 -0
  85. package/dist/render-from-row-BWSOIYDS.js.map +1 -0
  86. package/dist/{renovate-dispatch-QJY75ODR.js → renovate-dispatch-6GQWTRKX.js} +10 -10
  87. package/dist/{renovate-dispatch-QJY75ODR.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
  88. package/dist/{report-4EVNDHOZ.js → report-R3E3Y7HR.js} +63 -21
  89. package/dist/report-R3E3Y7HR.js.map +1 -0
  90. package/dist/report-mirror-4MNORYQ4.js +49 -0
  91. package/dist/report-mirror-4MNORYQ4.js.map +1 -0
  92. package/dist/rerender-3FM6DNRH.js +32 -0
  93. package/dist/rerender-3FM6DNRH.js.map +1 -0
  94. package/dist/{self-updating-6DWI2Z56.js → self-updating-UCIWESAX.js} +2 -2
  95. package/dist/{selftest-RC36MTKP.js → selftest-VGHWPN72.js} +22 -20
  96. package/dist/{selftest-RC36MTKP.js.map → selftest-VGHWPN72.js.map} +1 -1
  97. package/dist/site-mirror-NIREX5ME.js +52 -0
  98. package/dist/site-mirror-NIREX5ME.js.map +1 -0
  99. package/dist/{smoke-suite-VYY2LXM2.js → smoke-suite-U3UKD3NS.js} +2 -2
  100. package/dist/{submissions-Q7DRFOCD.js → submissions-IQEJX6FK.js} +2 -2
  101. package/dist/{svelte-codemods-25J2FO22.js → svelte-codemods-GOKXTBHW.js} +2 -2
  102. package/dist/{sync-34HNZZ56.js → sync-CTUVXMCK.js} +4 -4
  103. package/dist/{sync-configs-3SE6PSCO.js → sync-configs-SPTLFJCC.js} +4 -4
  104. package/dist/{upgrade-WBRQ62FX.js → upgrade-W6ZR3FJH.js} +2 -2
  105. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  106. package/dist/{websites-SXWTP72S.js → websites-D6WYFAES.js} +2 -2
  107. package/dist/websites-D6WYFAES.js.map +1 -0
  108. package/dist/{write-audits-to-airtable-MBEILLPU.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
  109. package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
  110. package/package.json +5 -1
  111. package/dist/airtable-G7WBQQMI.js +0 -9
  112. package/dist/announce-MXTUQ6C7.js.map +0 -1
  113. package/dist/chunk-2NDRHXMR.js.map +0 -1
  114. package/dist/chunk-2ZPT7GLV.js.map +0 -1
  115. package/dist/chunk-ACBRLLFT.js.map +0 -1
  116. package/dist/chunk-BYK66EWO.js.map +0 -1
  117. package/dist/chunk-ILUNZHYV.js.map +0 -1
  118. package/dist/chunk-ITIOFN7A.js.map +0 -1
  119. package/dist/chunk-X3B723J4.js.map +0 -1
  120. package/dist/ensure-site-DFTB7MN3.js.map +0 -1
  121. package/dist/forms-notify-target-75U6OU7L.js.map +0 -1
  122. package/dist/header-images-335L2AKB.js.map +0 -1
  123. package/dist/health-mirror-4B2L7T2Y.js +0 -75
  124. package/dist/health-mirror-4B2L7T2Y.js.map +0 -1
  125. package/dist/launch-XNAX427U.js.map +0 -1
  126. package/dist/prismic-models-QBGUT2Q3.js.map +0 -1
  127. package/dist/report-4EVNDHOZ.js.map +0 -1
  128. /package/dist/{airtable-G7WBQQMI.js.map → airtable-ZBUPQJSC.js.map} +0 -0
  129. /package/dist/{import-airtable-HE43FB4H.js.map → attachments-NLZIJ5NU.js.map} +0 -0
  130. /package/dist/{bump-deps-EC74CPLS.js.map → bump-deps-CGVELRLK.js.map} +0 -0
  131. /package/dist/{chunk-KTH5ZODA.js.map → chunk-7BZUOZPU.js.map} +0 -0
  132. /package/dist/{chunk-3VQWSE3F.js.map → chunk-7UAYMAKR.js.map} +0 -0
  133. /package/dist/{chunk-SLUQ7R6I.js.map → chunk-C6CLRKPV.js.map} +0 -0
  134. /package/dist/{chunk-P2FE5AI6.js.map → chunk-CROKUHJB.js.map} +0 -0
  135. /package/dist/{chunk-W7Z3BGCI.js.map → chunk-DEWJMMG3.js.map} +0 -0
  136. /package/dist/{chunk-TERE5SDF.js.map → chunk-HEWSRXYV.js.map} +0 -0
  137. /package/dist/{chunk-Z7AXYKQB.js.map → chunk-IY6256GK.js.map} +0 -0
  138. /package/dist/{chunk-F5KM4LEQ.js.map → chunk-NCMZMLY7.js.map} +0 -0
  139. /package/dist/{chunk-PPE5KZJK.js.map → chunk-NXNPDCF3.js.map} +0 -0
  140. /package/dist/{chunk-N45M3FDK.js.map → chunk-SDDPUAWH.js.map} +0 -0
  141. /package/dist/{chunk-ZKXSX4LO.js.map → chunk-TD2NNXLZ.js.map} +0 -0
  142. /package/dist/{chunk-2EVEFOZY.js.map → chunk-ZBDZTJUO.js.map} +0 -0
  143. /package/dist/{convert-to-pnpm-6L6P74WR.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
  144. /package/dist/{db-B35OGV3G.js.map → db-UXNIZ2RG.js.map} +0 -0
  145. /package/dist/{email-VTTTKKL5.js.map → email-KJXASVH5.js.map} +0 -0
  146. /package/dist/{lighthouse-airtable-LBVIYYP5.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
  147. /package/dist/{github-signals-IJUPGCUX.js.map → github-signals-E2WPYTBK.js.map} +0 -0
  148. /package/dist/{header-image-AWLLQA4L.js.map → header-image-6IC53W27.js.map} +0 -0
  149. /package/dist/{health-endpoint-WJC2UMYS.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
  150. /package/dist/{orchestrate-77UO4BYJ.js.map → import-airtable-OHVY344G.js.map} +0 -0
  151. /package/dist/{init-NJEOEO7I.js.map → init-XRAQZYID.js.map} +0 -0
  152. /package/dist/{parity-MWS24A3H.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
  153. /package/dist/{onboard-GK4PHPNI.js.map → onboard-O4IAHJMO.js.map} +0 -0
  154. /package/dist/{pipeline-S6CVHDGB.js.map → orchestrate-YLWQ6KK2.js.map} +0 -0
  155. /package/dist/{render-AQAVQDPZ.js.map → parity-56KA2DGR.js.map} +0 -0
  156. /package/dist/{websites-SXWTP72S.js.map → pipeline-3AANZFCR.js.map} +0 -0
  157. /package/dist/{prismic-ci-3RHOZUUT.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
  158. /package/dist/{write-audits-to-airtable-MBEILLPU.js.map → prospect/types.js.map} +0 -0
  159. /package/dist/{prospect-audit-IUNCJ2LV.js.map → prospect-audit-KSXUNQ5J.js.map} +0 -0
  160. /package/dist/{self-updating-6DWI2Z56.js.map → self-updating-UCIWESAX.js.map} +0 -0
  161. /package/dist/{smoke-suite-VYY2LXM2.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
  162. /package/dist/{submissions-Q7DRFOCD.js.map → submissions-IQEJX6FK.js.map} +0 -0
  163. /package/dist/{svelte-codemods-25J2FO22.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
  164. /package/dist/{sync-34HNZZ56.js.map → sync-CTUVXMCK.js.map} +0 -0
  165. /package/dist/{sync-configs-3SE6PSCO.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
  166. /package/dist/{upgrade-WBRQ62FX.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
  167. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/cli/commands/prismic-models.ts","../src/cli/commands/prismic-models-report.ts"],"sourcesContent":["// The CLI face of the model pipeline: one site, one Prismic repository, one\n// comparison — and, with `--apply`, the one call in this repository that writes\n// to a live client's Prismic content model.\n//\n// EVERY EXIT FROM THIS FILE IS A VERDICT SOMEBODY ACTS ON. In CI the dry run's\n// output is posted verbatim as the PR comment a reviewer approves from, and the\n// apply run's exit code is what says the merge landed. So the two failure shapes\n// this module must never produce are (a) a report that looks complete and is\n// not, and (b) a green exit for a run that learned nothing.\n//\n// THE RULE THIS FILE IS WRITTEN AGAINST — the same one as every module under it:\n// \"I could not read X\" must never produce the same result as \"X does not exist.\"\n// Here it takes the form of exit codes and returned prose rather than empty\n// arrays, and there are six places it bites:\n//\n// - checkout unreadable -> exit 1, naming the path. A repoRoot that does\n// not exist would otherwise reach the skip\n// below (every config read is ENOENT), so a\n// typo'd path or a failed clone reported \"not a\n// Prismic site\" and exited 0 — under `--apply`\n// too.\n// - checkout has no working -> exit 1, naming the path. A directory holding\n// tree `.git` and nothing else is what a KILLED clone\n// leaves behind, and every config read in it is\n// ENOENT — so it read as \"not a Prismic site\"\n// too, and a fleet workdir reuses it forever.\n// - no config file at all -> exit 0, \"not a Prismic site\" (a genuine skip:\n// the reusable workflow runs on repos that have\n// no Prismic at all)\n// - config THERE and broken -> exit 1, naming the file. NOT the skip above.\n// - local models unreadable -> exit 1, naming the file. NOT \"the repo\n// declares no models\", which with `--apply`\n// would push a repo's model set from a\n// half-read checkout.\n// - remote unreadable -> exit 1, quoting the API error. NEVER an empty\n// remote, which sorts every local model into\n// `toCreate` and pushes the lot.\n//\n// Each of those is a `catch` that returns a NAMED error result — never a default\n// that lets the comparison proceed on a guess. `clean` is `null` on every one of\n// them, so nothing downstream can read a failed check as a verdict either, and\n// `status` says whether it was a skip or a failure so a sweep cannot conflate\n// the two.\n//\n// They live in ONE function, `readSiteInputs`, and every mode of this command\n// goes through it — the comparison, `--apply` and `--pull`. `--pull` needs them\n// MORE, not less: it is the mode that writes into a live client's working tree,\n// so a checkout it never read or a model set it half-read would put Prismic's\n// copy of a model on top of a repo nobody managed to look at.\nimport { readdir, readFile, rename, rm, stat, writeFile } from \"node:fs/promises\";\nimport { basename, join, resolve } from \"node:path\";\nimport { makeSpawn, type SpawnFn } from \"../../audits/util/spawn.js\";\nimport { resolveSites } from \"../fleet/resolve-sites.js\";\nimport {\n prepareFleetSites,\n appendSkipNotice,\n type FleetPrepResult,\n type SkippedSite,\n} from \"../fleet/prepare-sites.js\";\nimport { fleetWorkdir } from \"../../util/fleet-workdir.js\";\nimport { siteLabel } from \"../../util/site.js\";\n// Shared with `prismic-ci`, which reaches the identical fleet checkout by the\n// identical route — see the module's own comment for why it is not two copies.\nimport { noWorkingTreeFailure } from \"../../util/working-tree.js\";\nimport type { Site } from \"../../types.js\";\nimport {\n diffModels,\n localModels,\n prismicTokenEnvName,\n pushModels,\n readPrismicConfig,\n remoteModels as remoteModelsImpl,\n resolvePrismicToken,\n sendModel as sendModelImpl,\n writeModelFile,\n type FormatModelFile,\n type LocalEntry,\n type PrismicConfig,\n type PrismicModel,\n type RemoteEntry,\n} from \"../../prismic/models/index.js\";\n// `siteSlug` is a VALUE import and deliberately safe to take: websites.ts imports\n// nothing at runtime (its `airtable`/client imports are all `import type`), so\n// this does not put the Airtable SDK into the module graph of a consuming fleet\n// site's `reddoor-maint prismic-models` CI run. `FleetWriteResult` and\n// `PrismicModelsWriteback` are type-only and erase entirely.\nimport { siteSlug, type PrismicModelsWriteback } from \"../../reports/airtable/websites.js\";\nimport type { FleetWriteResult } from \"../../audits/write-audits-to-airtable.js\";\nimport { formatWithPrettier, PRETTIER_FLAG_NOTE } from \"../../recipes/_prettier.js\";\nimport {\n isClean,\n reconcileReport,\n renderModelReport,\n type ReportOptions,\n} from \"./prismic-models-report.js\";\n\nexport type PrismicModelsCommandOptions = {\n apply?: boolean;\n pull?: boolean;\n tokens?: boolean;\n fleet?: string;\n workdir?: string;\n writeAirtable?: boolean;\n commentFile?: string;\n cwd?: string;\n};\n\n/** Injected IO, so the command is testable without a network or a real token. */\nexport type PrismicModelsDeps = {\n remoteModels: (repo: string, token: string) => Promise<RemoteEntry[]>;\n sendModel: (\n repo: string,\n token: string,\n entry: LocalEntry,\n action: \"insert\" | \"update\",\n ) => Promise<void>;\n env: Record<string, string | undefined>;\n /**\n * Used by `--pull` alone, and never handed to a module.\n *\n * This is an arbitrary-argv primitive: whoever holds it can run `rm -rf` over\n * a model, `git rm` it, or `curl -X DELETE` at the Types API. Task 12's red\n * team pushed `run(\"rm\", [\"-rf\", model])` through this exact injection point\n * and it passed every assertion, which is why `writeModelFile` now takes a\n * {@link FormatModelFile} — \"format these paths in this repo\" — instead. The\n * argv is built at the one binding below, where a reviewer can see it.\n */\n spawn: SpawnFn;\n /**\n * The fleet's record, opened only by `--write-airtable` — see\n * {@link PrismicVerdictSink}.\n *\n * REQUIRED rather than optional, and that is the point: an optional dep with a\n * real-Airtable default would let any future test that passes `writeAirtable:\n * true` reach a live base if `AIRTABLE_PAT` happened to be in the environment.\n * Required, every test has to hand over a stub, and the ones that never write\n * hand over a stub that throws.\n */\n openVerdictSink: () => Promise<PrismicVerdictSink>;\n};\n\nexport const defaultDeps = (): PrismicModelsDeps => ({\n remoteModels: (repo, token) => remoteModelsImpl(repo, token),\n sendModel: (repo, token, entry, action) => sendModelImpl(repo, token, entry, action),\n env: process.env,\n spawn: makeSpawn(),\n // Imported HERE rather than at the top of the file: the Airtable client is a\n // devDependency of this package, and a consuming fleet site running the in-repo\n // check in its own CI has no `airtable` installed. A static import would make\n // every one of those runs fail on module load.\n openVerdictSink: async () => {\n const { openBase, readAirtableConfig } = await import(\"../../reports/airtable/client.js\");\n const { listWebsites, updatePrismicModels } =\n await import(\"../../reports/airtable/websites.js\");\n // `openBase` throttles every HTTP call this base makes at its single funnel\n // (≤4.5 req/s), so the serial writes below cannot burst past Airtable's rate\n // limit no matter how large the fleet gets.\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n return {\n websites: websites.map((w) => ({ id: w.id, name: w.name })),\n update: (recordId, models) => updatePrismicModels(base, recordId, models),\n };\n },\n});\n\n/**\n * A thrown value rendered for a report line, for ANY thrown value.\n *\n * `(e as Error).message` is the obvious version and it is wrong here for the\n * same reason it was wrong in `push.ts` (see `messageOf` there, which enumerates\n * the shapes and cites the verification): `remoteModels` and `sendModel` are\n * INJECTED, so this module can promise nothing about what they throw. A thrown\n * string renders as `undefined` through the cast — an unreadable remote reported\n * with a blank reason, which is precisely the \"could not read\" fact this file\n * exists to keep legible. `String(e)` is not safe either: `Object.create(null)`\n * has no `toString`, and an Error can carry a `message` getter that throws.\n *\n * Both `catch`es below SWALLOW rather than rethrow. Both run only on a path that\n * is already reporting a failure, so the worst either can do is degrade a\n * message; neither can influence what is reported for a SUCCESSFUL run.\n */\nconst describeThrown = (e: unknown): string => {\n try {\n if (e instanceof Error && typeof e.message === \"string\") return e.message;\n } catch {\n // A throwing `message` getter tells us nothing — fall through.\n }\n try {\n return String(e);\n } catch {\n return `<a thrown ${typeof e} that cannot be converted to a string>`;\n }\n};\n\n/**\n * Which of the three outcomes a check reached, as DATA rather than as a shape a\n * consumer has to infer.\n *\n * - `checked` — the comparison ran. `clean` is a boolean verdict.\n * - `skipped` — a readable repo with no Prismic config. Not a Prismic site;\n * nothing to alarm about. The reusable workflow runs on repos\n * that have no Prismic at all, so this is a routine outcome.\n * - `failed` — we could not find out. Unreadable checkout, broken config,\n * missing token, unreadable local models, unreadable remote.\n *\n * `clean: null` alone cannot separate the last two, and they are OPPOSITE\n * operational facts: one is \"nothing to do here\", the other is this pipeline's\n * governing failure (\"I could not read X\" wearing the face of \"X does not\n * exist\"). Task 17's majority-failure exit rule and Task 19's Airtable write\n * both need the distinction, and the only signal available to them without this\n * field is `repositoryName !== null` — which is WRONG for exactly the case that\n * matters: a config that is present and broken never yields a repositoryName,\n * so it is indistinguishable from a genuine skip. Count `status`, not the\n * absence of a name.\n */\nexport type SiteCheckStatus = \"checked\" | \"skipped\" | \"failed\";\n\n/** The outcome of checking one site.\n *\n * `clean` is the machine-readable verdict the fleet sweep writes to Airtable and\n * the cockpit alarms on, and it is deliberately three-valued: `true` in sync,\n * `false` diverged, `null` NOT KNOWN — the check itself failed. A boolean here\n * would have to make \"we could not find out\" wear one of the other two faces.\n *\n * `clean` and `status` answer different questions and neither implies the\n * other: `status: \"checked\"` with `clean: false` is a site that was read\n * perfectly and disagrees with Prismic (a finding to write down), while\n * `status: \"failed\"` with `clean: null` is a site nobody managed to read (an\n * outage). A sweep that counted only `clean` would report those identically. */\nexport type SiteCheck = {\n output: string;\n code: number;\n clean: boolean | null;\n status: SiteCheckStatus;\n repositoryName?: string;\n};\n\n/** Everything a mode needs about one site — or the NAMED failure that stopped\n * it, already shaped as a {@link SiteCheck} so no caller has to invent a verdict\n * for a read that did not happen. */\ntype SiteInputs =\n | { ok: true; cfg: PrismicConfig; token: string; local: LocalEntry[]; remote: RemoteEntry[] }\n | { ok: false; failure: SiteCheck };\n\nconst inputFailure = (\n output: string,\n status: SiteCheckStatus,\n code: number,\n repositoryName?: string,\n): SiteInputs => ({\n ok: false,\n failure: {\n output,\n code,\n // `null` on EVERY one of these, so nothing downstream can read a failed read\n // as a verdict about the site.\n clean: null,\n status,\n ...(repositoryName !== undefined ? { repositoryName } : {}),\n },\n});\n\n/**\n * Every read this command opens with, and the refusals listed at the top of this\n * file that keep them honest — in ONE place, because a mode that repeats them by\n * hand is a mode that can omit one.\n *\n * That is not hypothetical: the first draft of `--pull` made all of these reads\n * with no guard on any of them, which turned a missing checkout into \"not a\n * Prismic site — skipped\", exit 0, for a mode that WRITES INTO A LIVE CLIENT\n * REPO — and would have let an unreadable local model set sort every remote\n * model into `remoteOnly` and pull the lot over the top of the repo.\n *\n * `noEffect` is the one thing that legitimately differs: the modes undo nothing,\n * but they do different things, and \"Nothing was compared and nothing was\n * pushed\" is the wrong sentence to print for a pull-down. It is a phrase and not\n * a mode enum on purpose — the guards must not be able to branch on which mode\n * called them.\n */\nasync function readSiteInputs(\n repoRoot: string,\n deps: PrismicModelsDeps,\n opts: { allowGenericToken: boolean; noEffect: string },\n): Promise<SiteInputs> {\n // THE DIRECTORY ITSELF, before anything inside it. `readPrismicConfig` reads\n // `<repoRoot>/slicemachine.config.json`, and for a repoRoot that does not\n // exist that read is ENOENT — which it correctly interprets as \"this repo does\n // not have this file\" and, having exhausted the candidates, returns null for.\n // That null then lands on the skip below: \"not a Prismic site — skipped\",\n // exit 0. So a typo'd path, a bad `working-directory:` in a workflow, or a\n // clone that silently failed all produce a GREEN CI RUN WITH A REASSURING\n // MESSAGE — including under `--apply`, the mode that writes to a live client's\n // Prismic repository.\n //\n // This is the governing rule of the pipeline at the command layer: \"I could\n // not read X\" must never produce the same result as \"X does not exist\". A\n // missing directory is not a repo without Prismic; it is a repo we never\n // looked at. `readdir` is the cheapest call that distinguishes all three\n // failures at once — ENOENT (not there), ENOTDIR (a file, not a checkout) and\n // EACCES (there and unreadable) — and it is exactly the readability the skip\n // verdict below claims to have established.\n let entries: string[];\n try {\n entries = await readdir(repoRoot);\n } catch (e) {\n return inputFailure(\n `cannot read this checkout at ${repoRoot}: ${describeThrown(e)}. ${opts.noEffect}` +\n ` — this is NOT \"no Prismic in this repo\".` +\n ` Check the path (a typo, a wrong working-directory, or a clone that failed).`,\n \"failed\",\n 1,\n );\n }\n\n // A readable directory is not a checkout. The skip below is an assertion about\n // a repo we READ, and it is drawn from what is NOT in this directory — see\n // {@link noWorkingTreeFailure} for the clone that leaves a `.git` and nothing\n // else, and why that answer would then repeat forever.\n const noTree = noWorkingTreeFailure(repoRoot, entries, opts.noEffect);\n if (noTree) return inputFailure(noTree, \"failed\", 1);\n\n let cfg: PrismicConfig | null;\n try {\n cfg = await readPrismicConfig(repoRoot);\n } catch (e) {\n // `readPrismicConfig` returns null for \"no Prismic here\" and THROWS for a\n // config that is present and unusable. Those two must not land on the same\n // line: the skip below is a green exit, and a live site whose config broke\n // would then leave CI green while nothing was compared at all.\n return inputFailure(\n `Prismic config present but unusable: ${describeThrown(e)}. ${opts.noEffect}` +\n ` — this is NOT \"no Prismic in this repo\".`,\n \"failed\",\n 1,\n );\n }\n if (!cfg) {\n return inputFailure(\"not a Prismic site (no repositoryName) — skipped\", \"skipped\", 0);\n }\n\n // Derived BEFORE the lookup, because the name is what the operator needs in\n // the failure message and `prismicTokenEnvName` can itself throw (a\n // repositoryName with no alphanumeric characters would collapse every such\n // repo onto the bare `PRISMIC_TOKEN_` prefix, so it refuses). Uncaught, that\n // throw would take out the whole run — including the comment file — for a\n // config typo.\n let canonicalEnv: string;\n try {\n canonicalEnv = prismicTokenEnvName(cfg.repositoryName);\n } catch (e) {\n return inputFailure(\n `cannot work out which secret holds this repository's token: ${describeThrown(e)}`,\n \"failed\",\n 1,\n cfg.repositoryName,\n );\n }\n\n const resolved = resolvePrismicToken(cfg.repositoryName, deps.env, {\n allowGeneric: opts.allowGenericToken,\n });\n if (!resolved) {\n const names = [canonicalEnv];\n if (opts.allowGenericToken) names.push(\"PRISMIC_WRITE_TOKEN\");\n return inputFailure(\n `no write token for Prismic repository \"${cfg.repositoryName}\" — set ${names.join(\" or \")}`,\n \"failed\",\n 1,\n cfg.repositoryName,\n );\n }\n\n // Local first: it is free, and a repo that cannot be read has nothing to say\n // to Prismic. `localModels` throws on a model file that is present and\n // unreadable, and on a duplicate model id — both are \"this checkout cannot be\n // trusted\", and with `--apply` treating either as \"the repo declares fewer\n // models\" is a push computed from a half-read repository.\n //\n // The pull-down reads it for the mirror-image reason: models it cannot see\n // locally sort into `remoteOnly`, and a pull-down of a model the repo already\n // has overwrites the local file with Prismic's copy — losing exactly the edits\n // that were waiting to be pushed.\n let local: LocalEntry[];\n try {\n local = await localModels(repoRoot, cfg.libraries);\n } catch (e) {\n return inputFailure(\n `could not read this repo's own models: ${describeThrown(e)}. ${opts.noEffect}.`,\n \"failed\",\n 1,\n cfg.repositoryName,\n );\n }\n\n let remote: RemoteEntry[];\n try {\n remote = await deps.remoteModels(cfg.repositoryName, resolved.token);\n } catch (e) {\n // The single most destructive default available in this pipeline is `[]`\n // here: an empty remote sorts every local model into `toCreate` and, under\n // `--apply`, pushes a whole model set at a repository that may already hold\n // perfectly good models. An expired token is the likely trigger and Prismic's\n // token expiry is undocumented, so this is not a rare branch.\n return inputFailure(\n `could not read Prismic models: ${describeThrown(e)}`,\n \"failed\",\n 1,\n cfg.repositoryName,\n );\n }\n\n return { ok: true, cfg, token: resolved.token, local, remote };\n}\n\n/** One site, one Prismic repository, one comparison. Shared by in-repo and fleet\n * modes so a nightly verdict and a CI verdict can never disagree by construction.\n *\n * Every failure RETURNS rather than throws. In-repo that keeps the report and\n * the comment file — a throw would lose both and leave a reviewer with a stack\n * trace instead of a reason. In fleet mode it is stronger than that: one repo\n * with a broken config or a duplicate model id must not abort the sweep of the\n * other fourteen. */\nexport async function checkOneSite(\n repoRoot: string,\n deps: PrismicModelsDeps,\n opts: { apply: boolean; allowGenericToken: boolean },\n): Promise<SiteCheck> {\n const inputs = await readSiteInputs(repoRoot, deps, {\n allowGenericToken: opts.allowGenericToken,\n noEffect: \"Nothing was compared and nothing was pushed\",\n });\n if (!inputs.ok) return inputs.failure;\n const { cfg, token, local, remote } = inputs;\n\n const diff = diffModels(local, remote);\n const report = await pushModels(diff, {\n apply: opts.apply,\n send: (entry: LocalEntry, _remote: PrismicModel | undefined, action) =>\n deps.sendModel(cfg.repositoryName, token, entry, action),\n });\n\n // Zero models on BOTH sides passes `isClean`, and the renderer prints a warning\n // saying in words that it is a misconfiguration rather than a clean run. This\n // is the same verdict in the field the cockpit reads: reporting `clean: true`\n // for the state the report itself calls broken is how a wrong repositoryName,\n // a dead slice-library path, or a partial checkout becomes a green row in\n // Airtable. `false` (diverged — a human should look) rather than `null`,\n // because the check itself did not fail; it succeeded and found nothing, which\n // is a finding.\n const foundNothingAnywhere = isClean(diff) && diff.unchanged.length === 0;\n\n // ONE options object, built here and handed to BOTH the reconciliation and the\n // renderer. Two objects would be two questions, and the verdict could then\n // disagree with the prose printed directly above it.\n //\n // Pass the WHOLE report, not `failed: report.failed`.\n //\n // This is the only place in the pipeline that constructs `ReportOptions`, so\n // destructuring one field here is what decides whether the renderer's\n // reconciliation ever runs at all. With only `failed` supplied, `opts.report`\n // is undefined at every call site and every cross-check that needs a report —\n // remoteOnly, apply-vs-mode, and the sent/failed bucket invariant — silently\n // does nothing, and the safeguard ships dark.\n //\n // `report` INSTEAD OF `failed`, not in addition: with one source for the\n // failure list there is nothing to reconcile and that one check correctly\n // skips, while the rest light up. Pinned by\n // tests/cli/prismic-models-wiring.test.ts, which feeds in a push report that\n // disagrees with the diff and asserts the report says so.\n const reportOptions: ReportOptions = { apply: opts.apply, report };\n\n // The renderer's head says, in prose, \"⚠ INCONSISTENT — ... do not act on\n // this.\" A machine-readable verdict of `clean: true` under that sentence is\n // the report contradicting itself, and it is the field — not the prose — that\n // reaches Airtable and the cockpit. So the same fact drives both, read as\n // DATA. It is deliberately NOT recovered by grepping the rendered string: that\n // would make the exact wording of a warning load-bearing, and rewording it\n // would silently turn every inconsistent site green.\n const inconsistencies = reconcileReport(diff, reportOptions);\n\n return {\n output: renderModelReport(cfg.repositoryName, diff, reportOptions),\n // A dry run NEVER fails on drift: a model PR is supposed to differ from the\n // remote, and the comment is the review artifact, not a gate. Only a real\n // push failure — or one of the \"we could not find out\" returns above — is an\n // error.\n //\n // An inconsistency IS such an error, on both modes. Drift is a fact about\n // the site; an inconsistency is a fact about the report, which says every\n // figure in it is unreliable. Letting that pass green is a reviewer\n // approving a merge from numbers the report itself disowns.\n code: report.failed.length > 0 || inconsistencies.length > 0 ? 1 : 0,\n // `false`, not `null`, and for the same reason as `foundNothingAnywhere`\n // above: the check ran to completion and produced a finding — its inputs\n // disagree — so a human needs to look. `null` means \"we never found out\".\n //\n // An earlier version of this comment justified that by saying `null` would\n // count toward Task 17's majority-failure rule; Task 17 falsified it. That\n // rule counts `status`, and this row is `status: \"checked\"` on either value,\n // so the sweep's exit code would not move — which is the point. `clean` is\n // the PER-SITE verdict: the field the sweep carries for each row and the one\n // Task 19 writes to Airtable and the cockpit alarms on. `null` there writes\n // no verdict at all for a check that ran to completion and found something,\n // so the loudest failure this renderer can report would be the one row the\n // fleet never hears about.\n clean: inconsistencies.length > 0 ? false : isClean(diff) && !foundNothingAnywhere,\n status: \"checked\",\n repositoryName: cfg.repositoryName,\n };\n}\n\n/**\n * One site's token situation — the row `--tokens` prints, as DATA.\n *\n * `present` and `reads` are separate on purpose: Prismic write-token expiry is\n * undocumented and was never proven either way, and \"present but no longer\n * reads\" is exactly the shape an expiry would take. Collapsing them into one\n * boolean would report an expired token as a missing one and send the operator\n * to mint a duplicate.\n *\n * `reads` is THREE-valued for the same family of reason, one step further out.\n * This doctor makes no Prismic call at all, so for every probe it builds today\n * `reads` is `null` — NOBODY CHECKED — and the renderer says so in those words.\n * A two-valued `reads` would have to spell \"I did not look\" as either \"it works\"\n * or \"it is broken\", and the first of those is this pipeline's governing failure\n * pointed at its own output.\n *\n * NOTE WHAT IS NOT HERE: a token value. There is no field for one, so no\n * renderer, no formatter and no future edit to this row can print a live write\n * credential into a CI log or an operator's scrollback. The check that decides\n * `present` reads the environment variable's emptiness and keeps nothing.\n */\nexport type TokenProbe = {\n /** The checkout this row is about — the repo directory name, which is what the\n * operator has in front of them. Printed alongside `repositoryName` because\n * the two routinely differ (beachfront-dentistry's Prismic repository is\n * `48bb12d1`, which is otherwise unattributable to any site). */\n site: string;\n /**\n * Which of the three outcomes this probe reached — and the field the renderer\n * keys on, for exactly the reason {@link SiteCheckStatus} exists one screen up.\n *\n * A config that is THERE and broken yields no `repositoryName`, precisely like\n * a repo that has no Prismic at all. A renderer keyed on `repositoryName ===\n * null` therefore reports a live client's site whose config just broke as \"no\n * Prismic config — this site needs no token\", and the operator mints nothing\n * for it. Count `status`, not the absence of a name.\n */\n status: SiteCheckStatus;\n repositoryName: string | null;\n expectedEnv: string | null;\n present: boolean;\n /** `true` a read succeeded, `false` it failed, `null` NOBODY CHECKED. */\n reads: boolean | null;\n /** Why this site could not be read (`status: \"failed\"`), or why a present\n * token did not read. Never a token value. */\n error?: string;\n};\n\nconst SITE_COL = 28;\nconst REPO_COL = 26;\n/** Wide enough for the longest name the rule can derive in this fleet\n * (`PRISMIC_TOKEN_MEDICAL_SOLUTIONS_OF_TEXAS`, 40 characters). Names are never\n * shortened to fit — a truncated secret name is a secret nobody can mint. */\nconst ENV_COL = 40;\nconst NO_VALUE = \"—\";\n\n/** Prose above the table. Deliberately contains neither \"OK\" nor \"MISSING\":\n * those two words are verdicts in the rows below, and a test that asserts a\n * verdict is ABSENT would be satisfied by nothing if the header said it. */\nconst DOCTOR_HEADER: readonly string[] = [\n \"Prismic write-token doctor — which secret each site needs, and whether it is set.\",\n \"\",\n \"The naming rule is PRISMIC_TOKEN_<REPOSITORY NAME>, upper-snaked from the PRISMIC\",\n \"repository name, which is routinely not the repo directory name: reddoor-website's\",\n \"repository is reddoor-la, data-dynamiq's is reddoor-wireframer, and\",\n \"beachfront-dentistry's is 48bb12d1. Prefix-first is deliberate — it guarantees a\",\n \"leading letter, and 48BB12D1_PRISMIC is rejected both by shell sourcing and by this\",\n \"repo's own credentials parser.\",\n \"\",\n \"The generic PRISMIC_WRITE_TOKEN is NOT consulted here. It is the in-repo CI fallback\",\n \"for whichever single repository a workflow is checked out against; this table is the\",\n \"per-repository checklist, and a fallback cannot tick a repository's own box.\",\n \"\",\n \"Nothing below was verified against Prismic and no token value is ever printed — this\",\n \"reads the environment for emptiness only. A secret that is set is reported as\",\n \"PRESENT (not verified); run `reddoor-maint prismic-models` in the site to find out\",\n \"whether it actually works.\",\n \"\",\n `${\"repo\".padEnd(SITE_COL)} ${\"Prismic repository\".padEnd(REPO_COL)} ${\"secret\".padEnd(ENV_COL)} verdict`,\n];\n\n/** The operator's rename checklist. Prints the env var NAME that was looked for,\n * never a token value — see {@link TokenProbe}, which holds no value to print. */\nexport function renderTokenDoctor(probes: readonly TokenProbe[]): string {\n const lines: string[] = [...DOCTOR_HEADER];\n let ok = 0;\n let missing = 0;\n let failing = 0;\n let unverified = 0;\n let unreadable = 0;\n let skipped = 0;\n for (const p of probes) {\n const cols =\n `${p.site.padEnd(SITE_COL)} ${(p.repositoryName ?? NO_VALUE).padEnd(REPO_COL)}` +\n ` ${(p.expectedEnv ?? NO_VALUE).padEnd(ENV_COL)}`;\n let verdict: string;\n if (p.status === \"failed\") {\n unreadable++;\n // NOT the skip below, and the wording carries the distinction rather than\n // leaving it to a column: this site may well need a secret, and nobody\n // found out which one.\n verdict =\n `CANNOT TELL — ${p.error ?? \"this site could not be read\"}.` +\n ` Nothing was established about this site's secret.`;\n } else if (p.status === \"skipped\") {\n skipped++;\n verdict = \"no Prismic config (skipped) — this site needs no token\";\n } else if (!p.present) {\n missing++;\n verdict = \"MISSING — mint this secret\";\n } else if (p.reads === false) {\n failing++;\n verdict = `PRESENT BUT 403/FAILED — ${p.error ?? \"read failed\"}`;\n } else if (p.reads === true) {\n ok++;\n verdict = \"OK\";\n } else {\n unverified++;\n verdict = \"PRESENT (not verified)\";\n }\n lines.push(`${cols} ${verdict}`);\n }\n lines.push(\"\");\n if (unreadable > 0) {\n lines.push(\n `⚠ ${unreadable} site(s) could not be read at all. Those are NOT sites without` +\n ` Prismic — nothing was established about their secrets, so do not read this table` +\n ` as a complete checklist.`,\n );\n }\n // Every probe lands in exactly one of these, so a site can never fall out of\n // the total: a summary that counted only the three outcomes an operator likes\n // is how unreadable sites disappear from a checklist.\n lines.push(\n `${ok} ok, ${missing} missing, ${failing} failing, ${unverified} unverified,` +\n ` ${unreadable} unreadable, ${skipped} skipped.`,\n );\n return lines.join(\"\\n\");\n}\n\n/**\n * Work out which secret ONE checkout needs, and whether it is set.\n *\n * The guards are the same three the in-repo check opens with, in the same order\n * and for the same reason — a missing checkout and a broken config both read as\n * \"no Prismic config here\" if you only ask `readPrismicConfig`, and this command\n * is the one an operator uses to decide which secrets to mint. Reporting \"needs\n * no token\" for a site nobody could read is how a live client's site quietly\n * drops off the checklist.\n *\n * `resolvePrismicToken` is deliberately NOT used: it RETURNS THE TOKEN, and this\n * function must never hold one. It asks the same question of the environment —\n * a value that is missing or whitespace-only is absent — so the doctor's verdict\n * and the pipeline's own resolution cannot disagree.\n *\n * `label` is how the FLEET doctor names the row, and it has to be passed in\n * rather than derived here: a site that could not be PREPARED has no directory\n * to take a basename from, so its row can only be labelled by the inventory —\n * and one table that labels some rows by directory and others by inventory name\n * is a table in which the same site appears under two identities. In-repo mode\n * passes nothing and gets the directory name, which is what the operator has in\n * front of them there.\n */\nasync function probeSiteToken(\n repoRoot: string,\n env: Record<string, string | undefined>,\n label?: string,\n): Promise<TokenProbe> {\n const site = label !== undefined && label.trim() !== \"\" ? label : basename(repoRoot) || repoRoot;\n const blank = {\n site,\n repositoryName: null,\n expectedEnv: null,\n present: false,\n reads: null,\n } as const;\n\n let entries: string[];\n try {\n entries = await readdir(repoRoot);\n } catch (e) {\n return {\n ...blank,\n status: \"failed\",\n error: `cannot read this checkout at ${repoRoot}: ${describeThrown(e)}`,\n };\n }\n\n // The doctor's `skipped` row says \"this repo needs no secret\", which is the\n // same dangerous answer the sweep's skip is, reached the same way — see\n // {@link noWorkingTreeFailure}. Sharing the check rather than repeating it,\n // because two copies of a guard are two chances to fix only one of them.\n const noTree = noWorkingTreeFailure(\n repoRoot,\n entries,\n \"Nothing was established about this repo's token\",\n );\n if (noTree) return { ...blank, status: \"failed\", error: noTree };\n\n let cfg: PrismicConfig | null;\n try {\n cfg = await readPrismicConfig(repoRoot);\n } catch (e) {\n return {\n ...blank,\n status: \"failed\",\n error: `Prismic config present but unusable: ${describeThrown(e)}`,\n };\n }\n if (!cfg) return { ...blank, status: \"skipped\" };\n\n let expectedEnv: string;\n try {\n expectedEnv = prismicTokenEnvName(cfg.repositoryName);\n } catch (e) {\n return {\n ...blank,\n status: \"failed\",\n repositoryName: cfg.repositoryName,\n error: `cannot work out which secret holds this repository's token: ${describeThrown(e)}`,\n };\n }\n\n return {\n site,\n status: \"checked\",\n repositoryName: cfg.repositoryName,\n expectedEnv,\n // The one read of the environment, and only its emptiness survives this\n // expression. `.trim()` matches `resolvePrismicToken` exactly: a\n // whitespace-only secret is one the pipeline itself refuses, so a doctor\n // that called it present would tick a box the push would then fail on.\n present: (env[expectedEnv] ?? \"\").trim() !== \"\",\n reads: null,\n };\n}\n\n/**\n * Is there something here for the operator to DO — in one place, because the\n * in-repo doctor and the fleet doctor must not be able to disagree about what\n * counts as a finding.\n *\n * Three things do: a secret nobody has minted, a secret that is set and does not\n * read, and a site nobody could read at all. The last is the one that is easy to\n * leave out and the one this whole pipeline is written against: an unreadable\n * checkout has an UNKNOWN token requirement, and exiting 0 on it hands the\n * operator a checklist with a silent hole in it.\n */\nconst tokenDoctorActionable = (probes: readonly TokenProbe[]): boolean =>\n probes.some(\n (p) => p.status === \"failed\" || (p.status === \"checked\" && (!p.present || p.reads === false)),\n );\n\n/** `--tokens`: the doctor. Non-zero when there is something for the operator to\n * do — a secret to mint, or a site nobody could read. */\nasync function runTokenDoctor(\n repoRoot: string,\n deps: PrismicModelsDeps,\n): Promise<{ output: string; code: number }> {\n const probes = [await probeSiteToken(repoRoot, deps.env)];\n return { output: renderTokenDoctor(probes), code: tokenDoctorActionable(probes) ? 1 : 0 };\n}\n\n/**\n * `--pull`: bring models that exist ONLY in Prismic into the repo, as files.\n *\n * This is the safe answer to `remoteOnly`. CI reports those models and can never\n * delete them, which leaves exactly two resolutions: adopt the model into the\n * repo (here) or delete it in the Prismic dashboard (a human, in a browser).\n * Without this the nightly drift alarm has no non-manual way to clear.\n *\n * Operator-invoked only — never in CI. It mutates a working tree, and the\n * resulting files are meant to land as a reviewed PR.\n */\nasync function pullRemoteOnly(\n repoRoot: string,\n deps: PrismicModelsDeps,\n): Promise<{ output: string; code: number }> {\n // The same guards the comparison runs, and for a WRITE mode they matter more,\n // not less: a missing checkout reported as \"not a Prismic site\" or a half-read\n // local model set both end with Prismic's copy of a model written over a repo\n // nobody managed to read.\n const inputs = await readSiteInputs(repoRoot, deps, {\n // TRUE, for the same reason the in-repo check sets it: one checkout, one\n // Prismic repository, and `--pull` is refused outright in fleet mode — where\n // a single generic token would attach the wrong credential to every site\n // after the first.\n allowGenericToken: true,\n noEffect: \"Nothing was pulled and nothing was written\",\n });\n if (!inputs.ok) return { output: inputs.failure.output, code: inputs.failure.code };\n const { cfg, local, remote } = inputs;\n\n const diff = diffModels(local, remote);\n if (diff.remoteOnly.length === 0) {\n return { output: `nothing to pull — no remote-only models in ${cfg.repositoryName}`, code: 0 };\n }\n\n // NOT `cfg.libraries[0] ?? \"./src/lib/slices\"`. That default re-collapses a\n // distinction `config.ts` deliberately makes: an ABSENT `libraries` key gets\n // the fleet default THERE, but `libraries: []` is a STATEMENT — \"this site has\n // no slice libraries\" — and config.ts documents it as such. Fabricating the\n // fleet default here writes a slice model into a directory the site does not\n // use and reports it as a successful pull.\n //\n // The blank is passed STRAIGHT THROUGH to `modelFilePath`, which refuses it\n // per model, rather than being turned into a whole-run refusal: a custom type\n // needs no slice library, and a site with `libraries: []` must still be able\n // to adopt one. Refusing the run would make the fleet's own config statement\n // into a hard stop on a mode that has nothing to do with slices.\n //\n // `[0]` is an unstated rule when several libraries exist. The fleet is uniform\n // at one today, so this is not yet a live ambiguity — but the report SAYS\n // which one was chosen rather than letting index 0 decide in silence.\n const library = cfg.libraries[0] ?? \"\";\n\n // `writeModelFile` takes a FORMAT capability, not a process spawner — see\n // `deps.spawn`. The argv is built here, at the single injection site, which is\n // the one place a reviewer can see it. Passing `deps.spawn` straight through\n // is a tsc error, deliberately.\n const format: FormatModelFile = (root, paths, fmtOpts) =>\n formatWithPrettier(deps.spawn, root, paths, fmtOpts);\n\n const lines: string[] = [`Prismic pull-down — repository: ${cfg.repositoryName}`];\n if (cfg.libraries.length === 0) {\n lines.push(\n `slice library: NONE — this site's Prismic config declares no slice library, so no` +\n ` slice can be placed. Custom types are unaffected.`,\n );\n } else {\n lines.push(\n `slice library: ${library}` +\n (cfg.libraries.length > 1\n ? ` (the first of ${cfg.libraries.length} declared; the others are not written to)`\n : \"\"),\n );\n }\n lines.push(\"\");\n\n // FAILURE IS PER-MODEL, exactly as in `pushModels`. The guards in\n // `writeModelFile` make refusals genuinely reachable — a directory already\n // occupied by a DIFFERENT model id is the live case, because slice directory\n // names are derived from the id and the fleet copies slices between sites —\n // and a loop that threw on the first refusal would leave the models it had\n // already written on disk with no record of which. That is the same \"report\n // that silently omits models\" `push.ts` refuses to produce.\n let unformatted = 0;\n let refused = 0;\n for (const entry of diff.remoteOnly) {\n try {\n const res = await writeModelFile(format, repoRoot, entry, library);\n if (!res.formatted) unformatted++;\n lines.push(\n `pulled ${entry.kind} ${entry.id} -> ${res.path}${res.formatted ? \"\" : \" (unformatted)\"}`,\n );\n } catch (e) {\n refused++;\n lines.push(`REFUSED ${entry.kind} ${entry.id} — ${describeThrown(e)}`);\n }\n }\n if (unformatted > 0) lines.push(`⚠ ${PRETTIER_FLAG_NOTE}`);\n lines.push(\"\");\n lines.push(\n `${diff.remoteOnly.length - refused} of ${diff.remoteOnly.length} model(s) pulled` +\n (refused > 0 ? `, ${refused} refused` : \"\") +\n `.`,\n );\n if (refused < diff.remoteOnly.length) lines.push(\"Review, commit, and open a PR.\");\n if (refused > 0) {\n lines.push(\n `A refusal leaves that model ONLY in Prismic — nothing was deleted there and nothing` +\n ` was overwritten here. Act on each REFUSED line above.`,\n );\n }\n // A refusal is a real finding the operator must act on, so it must not exit 0.\n return { output: lines.join(\"\\n\"), code: refused > 0 ? 1 : 0 };\n}\n\n/** Exit code for a fleet sweep. Non-zero when failures are the MAJORITY of the\n * fleet, matching `githubSignalsExitCode` — a run where 11/12 repos could not be\n * read is an outage, not a flake. DRIFT is never a failure: a readable site that\n * diverges is a finding written to Airtable, and reddening the nightly for it\n * would make the alarm meaningless the first time someone edits a model.\n *\n * An exact tie is NOT a majority, deliberately and in agreement with\n * `githubSignalsExitCode`: 6 read and 6 unreadable is bad, but it is the\n * Airtable rows for the 6 that were read — not this exit code — that say so, and\n * a rule that reddened on a tie would fire the first time half a small fleet was\n * legitimately mid-migration. */\nexport function prismicSweepExitCode(checked: number, failed: number): number {\n return failed > checked ? 1 : 0;\n}\n\n/**\n * WHICH COMMIT the comparison was actually made against.\n *\n * Two-valued and never a bare string, for the reason `clean` is three-valued: a\n * row that could not name its commit must not be indistinguishable from one that\n * did. `resolved` is a sha read out of the checkout's own refs; `unresolved`\n * carries the REASON, which is printed rather than swallowed.\n */\nexport type CheckoutCommit = { resolved: string } | { unresolved: string };\n\n/** One row per site from a fleet sweep, ready for the Airtable writer. */\nexport type SweepRow = {\n site: string;\n repositoryName: string | null;\n /** The commit this site's verdict describes, or `null` when no checkout was\n * established at all (a prep failure — there is nothing on disk to name).\n * Present because the sweep REUSES checkouts and never refreshes them; see\n * {@link resolveCheckoutCommit}. */\n commit: CheckoutCommit | null;\n /** Did the check RUN — separate from what it found. `skipped` means this repo\n * has no Prismic config; `failed` means we could not find out. Those were\n * once both `clean: null`, which made a broken config score as a routine skip\n * and disappear from the outage count. Carried through from `checkOneSite`,\n * never re-derived. */\n status: SiteCheckStatus;\n /** Does the repo match Prismic. Only meaningful when `status === \"checked\"`. */\n clean: boolean | null;\n detail: string;\n};\n\n/**\n * Sites that claim the same Prismic repository as another site.\n *\n * The fleet-level twin of `assertNoDuplicateIds`: there, two files inside one\n * repo claiming one model id; here, two repos claiming one Prismic repository.\n * It is possible today — `the-tower` and `the-tower-burbank` both declare\n * `repositoryName: \"the-tower-burbank\"`, benign only because `the-tower` is\n * archived and so is not in the fleet inventory.\n *\n * Why this DETECTS instead of throwing, unlike `assertNoDuplicateIds`: that one\n * aborts the read of a single broken repo, which is proportionate. Aborting here\n * would take the drift alarm offline for every site in the fleet because two\n * repos have a config problem — and the sweep is read-only, so nothing is being\n * corrupted at the moment of detection. The real damage happens later and\n * elsewhere: each repo's own CI pushes to the shared Prismic repository on\n * merge, and each overwrites the other. Neither repo can ever see it, because\n * each one's local-vs-remote comparison is internally consistent; the conflict\n * exists only BETWEEN them.\n *\n * That makes the fleet sweep the only vantage point in the whole system from\n * which this is visible — so it must report it loudly and go non-zero, and it\n * must name both sites, because only a human can say which repo is the owner.\n *\n * Sites with no Prismic config (`null`) never collide with each other.\n *\n * A site whose check FAILED is a different matter, and an earlier version of\n * this docblock got it backwards: it claimed a failed site has no\n * repositoryName. Most of them DO. `inputFailure` carries `cfg.repositoryName`\n * through on the token-missing, local-models-unreadable and remote-unreadable\n * paths, so those rows reach this detector with a name — and that is right, not\n * a leak: a repo that DECLARES a Prismic repository claims it whether or not we\n * managed to read its models, and its own CI will push to it on merge either\n * way. Only the failures that never got as far as a usable config (an unreadable\n * or working-tree-less checkout, a present-but-broken config, a repositoryName\n * that derives no env var) carry `null`, and those genuinely cannot collide with\n * anything — nobody knows what they claim.\n */\nexport type RepositoryCollision = { repositoryName: string; sites: string[] };\n\n/**\n * One row per SITE IDENTITY, first wins.\n *\n * Both detectors below group by name, and a name group with more than one member\n * is their alarm — so an inventory that lists ONE site twice makes that site look\n * like two sites fighting over a repository. The remediation text then sends the\n * operator to fix a `slicemachine.config.json` that is not wrong, in a live\n * client repo, for a conflict that does not exist. False positives in a safety\n * alarm are how the alarm gets ignored, and this one has to survive being right\n * rarely.\n *\n * Identity is the row's site label — `siteLabel`, i.e. the inventory `name` or\n * the path — because that is the only identity this layer has. The duplication\n * itself is not thrown away: {@link findDuplicateSiteRows} reports it as what it\n * is, an inventory wart.\n */\nfunction uniqueBySite<T extends { site: string }>(rows: readonly T[]): T[] {\n const seen = new Set<string>();\n return rows.filter((r) => (seen.has(r.site) ? false : (seen.add(r.site), true)));\n}\n\nexport function findRepositoryCollisions(\n rows: ReadonlyArray<{ site: string; repositoryName: string | null }>,\n): RepositoryCollision[] {\n const byRepository = new Map<string, string[]>();\n // DEDUPED INSIDE the detector, not at the call site: a caller that forgets is\n // a caller that ships the false alarm.\n for (const r of uniqueBySite(rows)) {\n if (r.repositoryName === null) continue;\n const list = byRepository.get(r.repositoryName) ?? [];\n list.push(r.site);\n byRepository.set(r.repositoryName, list);\n }\n return (\n [...byRepository.entries()]\n .filter(([, sites]) => sites.length > 1)\n .map(([repositoryName, sites]) => ({ repositoryName, sites: [...sites].sort() }))\n // Sorted so the block an operator diffs run to run does not reorder itself\n // when the inventory does.\n .sort((a, b) => a.repositoryName.localeCompare(b.repositoryName))\n );\n}\n\n/**\n * Two DIFFERENT Prismic repositories whose token env vars collapse onto one name.\n *\n * The second axis of the same conflict, and the more dangerous one, because\n * nothing else in the system can see it. `findRepositoryCollisions` groups by\n * `repositoryName`; the CREDENTIAL is keyed by `prismicTokenEnvName`, which\n * upper-snakes the name and collapses every run of non-alphanumerics to one `_`.\n * So `the-pointe`, `the.pointe`, `the_pointe` and `The Pointe` are four distinct\n * Prismic repositories that all derive `PRISMIC_TOKEN_THE_POINTE` — no\n * repositoryName collision, one secret.\n *\n * What follows from that is the worst outcome fleet mode can produce: the sweep\n * sends ONE client's write token to ANOTHER client's repository. Read-only today\n * that is mostly a 401 the operator reads as an expired token — and the natural\n * fix, re-minting `PRISMIC_TOKEN_THE_POINTE` from the other repository, just\n * moves the breakage to the site that was working. It is exactly the\n * cross-wiring `allowGeneric: false` exists to prevent, reintroduced through the\n * naming rule instead of through the environment.\n *\n * Reported SEPARATELY from a repositoryName collision because the operator's fix\n * is different: that one is \"rename a repository in one repo's\n * slicemachine.config.json\"; this one cannot be fixed in either repo, because\n * both configs are correct — the derivation rule is what collapses them.\n *\n * Verified 2026-08-13 across the 15 in-scope repos, each read from its own\n * `origin/HEAD` via `git ls-tree` + `git show` (never a working tree): 15 distinct\n * repositoryNames, 15 distinct derived env vars, no collision on either axis. So\n * this ships dark — which is the only state in which a safety alarm can be\n * trusted when it does fire.\n */\nexport type TokenEnvCollision = {\n envName: string;\n entries: { site: string; repositoryName: string }[];\n};\n\nexport function findTokenEnvCollisions(\n rows: ReadonlyArray<{ site: string; repositoryName: string | null }>,\n): TokenEnvCollision[] {\n const byEnv = new Map<string, { site: string; repositoryName: string }[]>();\n for (const r of uniqueBySite(rows)) {\n if (r.repositoryName === null) continue;\n let envName: string;\n try {\n envName = prismicTokenEnvName(r.repositoryName);\n } catch {\n // A repositoryName with no alphanumeric characters derives NO env var, so\n // it cannot collide with anything — and it is already a `failed` row that\n // names itself (see `readSiteInputs`). Swallowing the throw here is not a\n // silent skip: the site is reported, just not by this detector.\n continue;\n }\n const list = byEnv.get(envName) ?? [];\n list.push({ site: r.site, repositoryName: r.repositoryName });\n byEnv.set(envName, list);\n }\n return (\n [...byEnv.entries()]\n // MORE THAN ONE REPOSITORY, not more than one row. Two sites declaring the\n // SAME repositoryName derive the same env var by definition and are the\n // other detector's finding; reporting them here too would print two alarms\n // with two different fixes for one problem.\n .filter(([, entries]) => new Set(entries.map((e) => e.repositoryName)).size > 1)\n .map(([envName, entries]) => ({\n envName,\n entries: [...entries].sort((a, b) => a.site.localeCompare(b.site)),\n }))\n .sort((a, b) => a.envName.localeCompare(b.envName))\n );\n}\n\n/** The report block for a derived-env-var collision. Separate from\n * {@link describeCollisions} because the fix is different — see\n * {@link TokenEnvCollision}. */\nexport function describeTokenEnvCollisions(collisions: readonly TokenEnvCollision[]): string {\n if (collisions.length === 0) return \"\";\n return [\n \"## ⛔ One token secret derived by more than one Prismic repository\",\n \"\",\n ...collisions.map(\n (c) =>\n `- **${c.envName}** is derived by ${c.entries\n .map((e) => `${e.site} (${e.repositoryName})`)\n .join(\" and \")}. These are DIFFERENT Prismic repositories reading ONE secret, so ` +\n `whichever token is set is sent to both — one client's credential at another ` +\n `client's repository. Neither repo's config is wrong, so do not \"fix\" one of them: ` +\n `the env var NAME is derived from the repository name and collapses them. Rename one ` +\n `Prismic repository, or give the derivation a disambiguator. Until then, treat both ` +\n `sites' verdicts as unproven.`,\n ),\n ].join(\"\\n\");\n}\n\n/** A site listed more than once in the inventory. */\nexport type DuplicateSiteRow = { site: string; count: number; repositoryNames: string[] };\n\n/**\n * Sites the inventory named more than once.\n *\n * A NOTE, not an alarm, and deliberately not an exit code: every one of those\n * rows was actually swept, nothing was mis-compared, and no client repo needs\n * changing — the inventory does. Making it non-zero would redden a nightly for a\n * duplicated Airtable row, which is how an operator learns to ignore the red.\n *\n * It still has to be SAID, because the detectors above now drop the duplicates\n * (see {@link uniqueBySite}) and a fact nobody prints is a fact nobody fixes. The\n * distinct repositoryNames are listed because a repeated label carrying two\n * DIFFERENT names is a sharper problem than a repeated row: one of the two rows\n * is being reported under a label that does not describe it.\n */\nexport function findDuplicateSiteRows(\n rows: ReadonlyArray<{ site: string; repositoryName: string | null }>,\n): DuplicateSiteRow[] {\n const bySite = new Map<string, (string | null)[]>();\n for (const r of rows) {\n const list = bySite.get(r.site) ?? [];\n list.push(r.repositoryName);\n bySite.set(r.site, list);\n }\n return [...bySite.entries()]\n .filter(([, names]) => names.length > 1)\n .map(([site, names]) => ({\n site,\n count: names.length,\n repositoryNames: [...new Set(names.filter((n): n is string => n !== null))].sort(),\n }))\n .sort((a, b) => a.site.localeCompare(b.site));\n}\n\nexport function describeDuplicateSiteRows(duplicates: readonly DuplicateSiteRow[]): string {\n if (duplicates.length === 0) return \"\";\n return [\n \"## ⚠ A site is listed more than once in the inventory\",\n \"\",\n ...duplicates.map((d) => {\n const names =\n d.repositoryNames.length === 0\n ? \"no repositoryName was established for it\"\n : `repositoryName ${d.repositoryNames.join(\" and \")}`;\n return (\n `- **${d.site}** appears ${d.count}× (${names}). It was swept ${d.count}×, so the ` +\n `counts below are inflated by ${d.count - 1}. This is an INVENTORY problem, not a ` +\n `conflict between two sites — do not change a slicemachine.config.json for it.`\n );\n }),\n ].join(\"\\n\");\n}\n\n/** The report block for a collision. Deliberately shouty, and deliberately at\n * the TOP of the sweep output: this is the one finding in the sweep that no\n * per-repo CI run can ever surface. */\nexport function describeCollisions(collisions: readonly RepositoryCollision[]): string {\n if (collisions.length === 0) return \"\";\n return [\n \"## ⛔ Prismic repository claimed by more than one site\",\n \"\",\n ...collisions.map(\n (c) =>\n `- **${c.repositoryName}** is claimed by ${c.sites.join(\" and \")}. ` +\n `Each repo's CI pushes to it on merge, so they overwrite each other — ` +\n `and neither repo can detect this on its own. Fix the ` +\n `slicemachine.config.json in whichever repo is not the owner.`,\n ),\n ].join(\"\\n\");\n}\n\n/**\n * The three buckets, counted ONCE.\n *\n * The line an operator reads and the exit code that wakes them up are derived\n * from this one function on purpose. Counted separately they are two questions,\n * and a later edit to one of them produces a report that says \"2 failed\" above an\n * exit 0 — the same self-contradiction `checkOneSite` refuses when it drives its\n * verdict and its prose from one fact.\n *\n * Every row lands in exactly one bucket, so a site can never fall out of the\n * total — the same rule `renderTokenDoctor`'s summary follows, and for the same\n * reason: a summary that counted only the outcomes an operator likes is how\n * unreadable sites disappear from a checklist.\n */\nexport function countSweep(rows: readonly SweepRow[]): {\n checked: number;\n failed: number;\n skipped: number;\n} {\n return {\n checked: rows.filter((r) => r.status === \"checked\").length,\n failed: rows.filter((r) => r.status === \"failed\").length,\n skipped: rows.filter((r) => r.status === \"skipped\").length,\n };\n}\n\n/**\n * The count line, and the warning that keeps it honest.\n *\n * `failed` and `skipped` are adjacent here, which is precisely why the line\n * spells out in words that they are opposite facts.\n */\nexport function summariseSweep(rows: readonly SweepRow[]): string {\n const { checked, failed, skipped } = countSweep(rows);\n const lines: string[] = [];\n if (failed > 0) {\n lines.push(\n `⚠ ${failed} site(s) could not be read at all. Those are NOT sites without Prismic —` +\n ` nothing was established about their models, so do not read this sweep as a complete` +\n ` picture of the fleet.`,\n );\n }\n lines.push(\n `${checked} checked, ${failed} failed, ${skipped} skipped (no Prismic config),` +\n ` of ${rows.length} site(s).`,\n );\n // WHAT WAS COMPARED, in the same breath as how many. The sweep reuses whatever\n // checkout is already in the workdir and refreshes nothing (see\n // {@link resolveCheckoutCommit}), so \"espada matches Prismic\" is a statement\n // about a commit, not about that repo's default branch. Printed unconditionally\n // rather than only when something looks stale: a caveat that appears only when\n // the tool notices a problem is a caveat nobody has read by the time it matters.\n lines.push(\n `Each site was compared AS ITS CHECKOUT STANDS ON DISK. This sweep never fetches,` +\n ` pulls or resets a checkout, so the commit marked \"@\" beside each site above — not` +\n ` that repo's default branch — is what its verdict describes.`,\n );\n return lines.join(\"\\n\");\n}\n\n/**\n * The refusal for a sweep in which NOT ONE site was actually checked.\n *\n * Same reasoning as the zero-sites refusal, one step later in the pipeline: a run\n * that compared no repository against Prismic learned nothing, and\n * `prismicSweepExitCode` cannot express it, because 0 checked / 0 failed is\n * indistinguishable from a fleet that legitimately holds no Prismic sites.\n *\n * An all-skipped fleet is precisely the shape a systemic checkout problem takes.\n * \"Skipped\" is derived from the ABSENCE of a config file, so anything that empties\n * the working trees — a workdir wiped mid-run, a workdir of interrupted clones, a\n * clone target that landed one directory deeper than the inventory says — turns\n * every site in the fleet into a confident \"not a Prismic site\", exit 0, night\n * after night. The `.git`-only guard in `readSiteInputs` catches the shape we know\n * about; this catches the shape of the ones we do not.\n */\nexport function describeNothingChecked(rows: readonly SweepRow[]): string {\n // The zero-sites refusal in `runFleetSweep` already covers an empty inventory\n // with a message that names the actual cause; two refusals for one fact would\n // print a contradiction.\n if (rows.length === 0) return \"\";\n if (countSweep(rows).checked > 0) return \"\";\n return (\n `⛔ NOT ONE SITE WAS CHECKED. All ${rows.length} site(s) in this inventory came back` +\n ` skipped or unreadable, so nothing was compared against Prismic and this run learned` +\n ` nothing about the fleet's models. That is not a clean fleet: a skip is inferred from a` +\n ` MISSING config file, so anything that empties the checkouts makes every site in the` +\n ` fleet look like a repo that simply has no Prismic in it, and exit 0.` +\n ` Do NOT read this exit as a result.`\n );\n}\n\n/** One fleet site, with the same failure isolation `runRecipeOverSites` gives a\n * recipe: a throw from anywhere in the comparison becomes a NAMED failed row for\n * THAT site instead of ending the sweep of the other fourteen.\n *\n * `checkOneSite` returns rather than throws for every failure it anticipates, so\n * this catch is defence in depth — it covers whatever the pure comparison and\n * the renderer might throw on a model shape nobody has seen yet, and the\n * INJECTED `remoteModels`, which this module can promise nothing about. It does\n * not collapse anything: the row says `failed`, which counts toward the outage\n * rule and writes nothing to Airtable. */\n/**\n * The commit a checkout currently stands at, read out of the checkout itself.\n *\n * WHY THE SWEEP REPORTS THE COMMIT INSTEAD OF REFRESHING THE CHECKOUT.\n * `cloneIfNeeded` clones only into an EMPTY directory; for one that already\n * exists it returns the site untouched, and nothing anywhere pulls. So a sweep\n * can report \"espada matches Prismic\" from a working tree that was cloned days\n * ago while that repo's main has moved — and the nightly's entire purpose is\n * comparing the CURRENT repo against Prismic.\n *\n * Refreshing was the other option and it is the wrong one here, for three\n * reasons that compound:\n *\n * - The fleet workdir is SHARED — `fleetWorkdir()` is one directory\n * (`~/.reddoor-maint/sites`) and thirteen commands clone into it. The recipe\n * commands do not merely read those checkouts: `_with-recipe.ts` runs\n * `createBranch(site.path, …)` and commits into them, and `self-updating`\n * documents its own failure mode as \"strands the checkout on the maint branch\n * with an unpushed commit\". So a checkout may legitimately be sitting on\n * another command's branch, and a read-only nightly that fetched-and-reset\n * would silently destroy that work. A fetch WITHOUT a reset would change\n * nothing about what is compared, since the comparison reads the working tree\n * — which is also why naming the commit is worth more here than it looks: it\n * is the only way the report can say it compared a recipe branch.\n * - `prepareFleetSites` is shared by nine commands, so a refresh there is a\n * fleet-wide behaviour change that belongs in its own reviewed task — the same\n * boundary that keeps this file out of `cloneIfNeeded`.\n * - The sweep must never spawn a process (it is read-only BY CONSTRUCTION, and\n * that is asserted). A pull is a network write path with its own failure\n * modes — merge conflicts, detached heads, credentials — in the one command\n * whose job is to raise an alarm, not to need one.\n *\n * So the verdict is made legible instead of made fresh: every row names the commit\n * it describes, and `summariseSweep` says in words that no checkout was refreshed.\n * A stale answer stays possible, but it stops being invisible — and an operator\n * who wants a fresh one deletes the workdir, which is the cheap, obvious lever.\n *\n * Filesystem only: `.git/HEAD` plus the ref it names, then `packed-refs`. Every\n * failure returns a REASON rather than nothing, because \"this checkout is at no\n * commit\" and \"we could not read which commit\" are the two facts this file exists\n * to keep apart.\n */\nexport async function resolveCheckoutCommit(repoRoot: string): Promise<CheckoutCommit> {\n const isSha = (s: string): boolean => /^[0-9a-f]{7,64}$/i.test(s);\n try {\n const dotGit = join(repoRoot, \".git\");\n // A `.git` FILE (a worktree or a submodule) points at the real git dir.\n const s = await stat(dotGit);\n let gitDir = dotGit;\n if (!s.isDirectory()) {\n const pointer = /^gitdir:\\s*(.+)$/m.exec(await readFile(dotGit, \"utf-8\"))?.[1]?.trim();\n if (!pointer) return { unresolved: `.git is a file that names no gitdir` };\n gitDir = resolve(repoRoot, pointer);\n }\n const head = (await readFile(join(gitDir, \"HEAD\"), \"utf-8\")).trim();\n if (isSha(head)) return { resolved: head }; // detached HEAD\n const ref = /^ref:\\s*(.+)$/.exec(head)?.[1]?.trim();\n if (!ref) return { unresolved: `HEAD is neither a commit id nor a ref (${head.slice(0, 60)})` };\n try {\n const loose = (await readFile(join(gitDir, ref), \"utf-8\")).trim();\n return isSha(loose) ? { resolved: loose } : { unresolved: `${ref} holds no commit id` };\n } catch {\n // No loose ref file: the sha is in `packed-refs` (which is where a fresh\n // clone puts it, so this is the COMMON path, not the exotic one).\n const packed = await readFile(join(gitDir, \"packed-refs\"), \"utf-8\");\n for (const line of packed.split(\"\\n\")) {\n const [sha, name] = line.trim().split(/\\s+/);\n if (name === ref && sha !== undefined && isSha(sha)) return { resolved: sha };\n }\n return { unresolved: `${ref} is in neither this checkout's loose refs nor packed-refs` };\n }\n } catch (e) {\n return { unresolved: describeThrown(e) };\n }\n}\n\n/** The `@ …` marker printed beside a site in the sweep body. Empty for a site\n * with no checkout at all, which its row already says in words. */\nexport function describeCheckoutCommit(commit: CheckoutCommit | null): string {\n if (commit === null) return \"\";\n return \"resolved\" in commit\n ? ` @ ${commit.resolved.slice(0, 12)}`\n : ` @ COMMIT NOT RESOLVED (${commit.unresolved})`;\n}\n\nasync function sweepOneSite(s: Site, deps: PrismicModelsDeps): Promise<SweepRow> {\n const label = siteLabel(s);\n // BEFORE the comparison and outside the try: this says which tree was read, so\n // it has to be attached to a failed row as firmly as to a clean one. It never\n // throws — every failure comes back as a named `unresolved`.\n const commit = await resolveCheckoutCommit(s.path);\n try {\n const r = await checkOneSite(s.path, deps, {\n apply: false,\n // Fleet mode forbids the generic token: one PRISMIC_WRITE_TOKEN in the\n // environment while iterating every fleet repository would attach the wrong\n // credential to every site after the first. In-repo mode is the opposite,\n // and that asymmetry is the whole point of the flag.\n allowGenericToken: false,\n });\n return {\n site: label,\n repositoryName: r.repositoryName ?? null,\n commit,\n // Carry `status` THROUGH — never re-derive a verdict here.\n status: r.status,\n clean: r.clean,\n detail: r.output,\n };\n } catch (e) {\n return {\n site: label,\n repositoryName: null,\n commit,\n status: \"failed\",\n clean: null,\n detail:\n `unexpected error while checking this site: ${describeThrown(e)}.` +\n ` Nothing was compared for it — this is NOT \"no Prismic in this repo\".`,\n };\n }\n}\n\n/**\n * The inventory, resolved and checked out — the ONE step both fleet modes open\n * with.\n *\n * Shared rather than repeated because the sweep and the token doctor have to\n * cover the same set of sites by construction: a doctor that resolved the fleet\n * even slightly differently would tell an operator to mint secrets for a\n * different list of sites than the nightly then sweeps, and the gap between the\n * two lists is exactly a site whose drift alarm is dark for want of a credential.\n *\n * `resolved` is the number of sites the INVENTORY named, which is not\n * `prep.prepared.length` and not the row count either — a site that could not be\n * cloned is resolved but unprepared. Both callers need it for the same refusal:\n * an inventory that named nobody is not a clean fleet.\n *\n * `resolveSites` is allowed to THROW here (a positional site alongside `--fleet`,\n * an unsupported inventory extension, an Airtable read that failed). Those are\n * \"the fleet itself could not be established\", which has no per-site row to live\n * in; `bin.ts` prints the message and exits with the error's own `exitCode`.\n */\nasync function prepareFleet(\n site: string | undefined,\n opts: PrismicModelsCommandOptions,\n cwd: string,\n): Promise<{ prep: FleetPrepResult; resolved: number }> {\n const sites = await resolveSites({\n // Passed through so `resolveSites` can refuse `prismic-models espada --fleet\n // inv.json` (exit 2). Dropping it would silently sweep the whole fleet for\n // an operator who named one site.\n ...(site !== undefined ? { site } : {}),\n ...(opts.fleet !== undefined ? { fleet: opts.fleet } : {}),\n ...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),\n cwd,\n });\n const workdir = opts.workdir ?? fleetWorkdir();\n return { prep: await prepareFleetSites(sites, { workdir }), resolved: sites.length };\n}\n\n/** Fleet mode: prepare every site, compare each against its Prismic repository,\n * never push. Read-only by construction — `apply` is not plumbed through here,\n * because a fleet-wide model push outside CI is 🔴 under AUTONOMY.md. A caller\n * asking for `--apply` is refused by {@link modeConflict} rather than quietly\n * given a read-only sweep.\n *\n * `resolveSites` is allowed to THROW (a positional site alongside `--fleet`,\n * an unsupported inventory extension, an Airtable read that failed). Those are\n * \"the fleet itself could not be established\", which has no per-site row to\n * live in and must not be reported as a sweep of zero sites; `bin.ts` prints the\n * message and exits with the error's own `exitCode`. */\nexport async function sweepFleet(\n site: string | undefined,\n opts: PrismicModelsCommandOptions,\n deps: PrismicModelsDeps,\n cwd: string,\n): Promise<{ rows: SweepRow[]; skipped: SkippedSite[]; resolved: number }> {\n const { prep, resolved } = await prepareFleet(site, opts, cwd);\n\n // SEQUENTIAL. Each site is one repository's worth of Types API reads, and the\n // report is read top to bottom by a human — a parallel sweep would interleave\n // nothing (the rows are collected, not streamed) but would multiply the load\n // on Prismic by the size of the fleet for no gain.\n const rows: SweepRow[] = [];\n for (const s of prep.prepared) rows.push(await sweepOneSite(s, deps));\n\n // A SITE THAT COULD NOT BE PREPARED IS A SITE NOBODY READ, and it gets a row.\n //\n // `prepareFleetSites` isolates a clone failure into `skipped` so one bad\n // inventory row cannot abort the fleet — that isolation is right, and it is\n // also exactly how a site disappears: a sweep that walked `prepared` alone\n // counted 0 checked and 0 failed when NOTHING could be cloned, and\n // `prismicSweepExitCode(0, 0)` is 0. A fleet-wide clone outage — an expired\n // App token, GitHub down, a full disk — then reported a clean night, with the\n // real reason sitting in a warning line nothing gates on.\n //\n // The word \"skipped\" in `formatSkippedNotice` is about PREPARATION and is the\n // opposite of `status: \"skipped\"`, which is a readable repo with no Prismic in\n // it. The notice stays; the row is what makes the site countable.\n //\n // An earlier version of this comment said the nightly greps that notice's token\n // to raise a `::warning::`. It does not, because there is no Prismic nightly:\n // measured 2026-08-13, `.github/workflows/` holds no workflow that mentions\n // prismic at all — Task 23 builds it. What is true today is that FOUR other\n // fleet nightlies grep this exact token (fleet-lighthouse, fleet-smoke,\n // fleet-security, fleet-form-e2e), which is why the wording is worth keeping\n // stable and why Task 23 gets the grep for free. Until then the notice is for a\n // human reading the output, and the COUNT is the only thing any machine reads.\n for (const s of prep.skipped) {\n rows.push({\n site: s.site,\n repositoryName: null,\n // No checkout was established, so there is no commit to name. `null` here\n // is \"there is no tree\", NOT \"we could not read one\" — that is the\n // `unresolved` case, and they must not render alike.\n commit: null,\n status: \"failed\",\n clean: null,\n detail:\n `could not prepare this checkout: ${s.reason}.` +\n ` Nothing was compared for it — this is NOT \"no Prismic in this repo\".`,\n });\n }\n\n return { rows, skipped: prep.skipped, resolved };\n}\n\n/**\n * The fleet's record, as this command needs it — and the ONE seam through which\n * `--write-airtable` can touch Airtable.\n *\n * Injected (see {@link PrismicModelsDeps.openVerdictSink}) so the whole write\n * path is exercised with no base, no credential and no network.\n *\n * `websites` is the WHOLE table rather than a per-site lookup, because the join\n * below has to be able to see that TWO rows claim one site name. A query that\n * fetched one row per site would answer \"here is your row\" for a table that\n * holds two, and the verdict would land on whichever one Airtable returned\n * first — a client's row carrying another client's verdict.\n */\nexport type PrismicVerdictSink = {\n websites: ReadonlyArray<{ id: string; name: string }>;\n update: (recordId: string, models: PrismicModelsWriteback) => Promise<void>;\n};\n\n/**\n * The banner on a stored detail for a site whose CHECK FAILED.\n *\n * The column is read in a dashboard next to sites whose detail is real model\n * drift, and the operator's next move is completely different: a dead token or a\n * broken checkout is not a schema change. The verdict field already says\n * `unknown`; this makes the text say it too, in the first line, which is what a\n * feed or a digest title will quote.\n */\nconst NO_VERDICT_BANNER =\n \"⚠ NO VERDICT — this site's check FAILED, so nothing was established about its models:\";\n\n/**\n * ONE sweep row, as the fleet's record should hold it.\n *\n * This is the function the whole task turns on, so it is separate, pure and\n * exported: what a row that COULD NOT BE CHECKED writes down.\n *\n * The plan said: nothing — \"leave the row alone, it keeps the last true value\n * and the freshness gate ages it out\". Both halves are wrong. The last value\n * stopped being true the moment the check started failing; and nothing ages a\n * stale `pass` out, because the digest's freshness gate only ever examines\n * FAILURES (`prismicModels !== \"fail\"` → skip). So a site whose token died on\n * Monday would sit in the cockpit as a green tick, indefinitely, while its check\n * failed every night — \"I could not read X\" rendered exactly as \"X is fine\", in\n * the record an operator trusts.\n *\n * So every row writes, every night, and the three sweep outcomes stay three\n * distinct things:\n *\n * - `checked` → `pass`/`fail`. A real verdict; `pass` clears the old detail.\n * - `failed` → `unknown`. Nothing was established. NOT `fail`, which would\n * report a dead token as model drift and send the operator\n * hunting a schema change that never happened.\n * - `skipped` → blank. There is no verdict to hold: this repo has no Prismic\n * in it. Blanking is also what CLEARS a `pass` from back when\n * the site did have Prismic — a config that was deleted must\n * not leave its last verdict standing forever.\n *\n * `status === \"checked\"` with `clean === null` is not reachable by design —\n * `checkOneSite` always returns a boolean there — but `status` and `clean` are\n * independent fields, so a future bug CAN produce it. It falls to `unknown`\n * rather than to `pass`: the one thing this function must never do is invent a\n * clean verdict.\n */\nexport function sweepRowWriteback(row: SweepRow, checkedAt: string): PrismicModelsWriteback {\n if (row.status === \"skipped\") return { verdict: null, checkedAt, detail: row.detail };\n if (row.status === \"failed\" || row.clean === null) {\n return { verdict: \"unknown\", checkedAt, detail: `${NO_VERDICT_BANNER}\\n${row.detail}` };\n }\n return {\n verdict: row.clean ? \"pass\" : \"fail\",\n checkedAt,\n // A clean site clears its detail: leaving yesterday's drift text under a\n // `pass` is a finding that reads as current and is not.\n detail: row.clean ? null : row.detail,\n };\n}\n\n/**\n * Persist a fleet sweep to Airtable, one row at a time.\n *\n * SERIAL, like every other fleet writer here. The base returned by `openBase`\n * throttles its own HTTP calls (≤4.5 req/s), so this is belt-and-braces rather\n * than the only guard — but a `Promise.all` fan-out across the fleet would still\n * queue every request at once for no gain, and the failures are easier to read in\n * inventory order.\n *\n * EVERY ROW LANDS IN EXACTLY ONE BUCKET — written or failed. A row that fell out\n * of both would be a site the operator believes this sweep covered and it did\n * not, which is the same class of hole as a site missing from the sweep itself.\n *\n * The join is by SLUG, matching `writeAuditsToAirtable` and every other fleet\n * writer, so \"Espada\" in the inventory and \"espada\" in Airtable are one site. A\n * slug that matches TWO records is refused rather than resolved: a verdict\n * written to the wrong client's row is worse than a verdict not written, and only\n * a human can say which row is the real one.\n *\n * Nothing here throws. A per-row failure — no matching record, an ambiguous\n * match, an UNKNOWN_FIELD_NAME from columns the operator has not added yet — is\n * COLLECTED, because one unwritable row must not cost the other fourteen their\n * verdicts.\n */\nexport async function writeSweepToAirtable(\n rows: readonly SweepRow[],\n websites: ReadonlyArray<{ id: string; name: string }>,\n update: PrismicVerdictSink[\"update\"],\n checkedAt: string,\n): Promise<FleetWriteResult> {\n const bySlug = new Map<string, Array<{ id: string; name: string }>>();\n for (const w of websites) {\n const slug = siteSlug(w.name);\n const list = bySlug.get(slug) ?? [];\n list.push(w);\n bySlug.set(slug, list);\n }\n\n const result: FleetWriteResult = { written: [], failed: [] };\n for (const row of rows) {\n const slug = siteSlug(row.site);\n const matches = bySlug.get(slug) ?? [];\n const target = matches.length === 1 ? matches[0] : undefined;\n if (!target) {\n result.failed.push({\n slug,\n error:\n matches.length === 0\n ? `no Websites row matched \"${row.site}\" — its verdict was NOT recorded anywhere,` +\n ` so the cockpit will keep whatever this site's row said before (or nothing).` +\n ` Check the inventory name against the Websites Name column.`\n : `${matches.length} Websites rows match \"${row.site}\"` +\n ` (${matches.map((m) => m.name).join(\", \")}) — refusing to guess which one owns` +\n ` this verdict, because writing it to the wrong row is worse than not writing it.`,\n });\n continue;\n }\n try {\n await update(target.id, sweepRowWriteback(row, checkedAt));\n result.written.push({\n siteName: target.name,\n writes: [{ audit: \"prismic-models\", counts: {} }],\n });\n } catch (e) {\n // `describeThrown`, not `(e as Error).message`: `update` is injected and a\n // thrown string would render as `undefined` through the cast — a write that\n // failed for a blank reason, in the summary line CI reads.\n result.failed.push({ slug, error: describeThrown(e) });\n }\n }\n return result;\n}\n\n/**\n * The write step of `--write-airtable`, and its two failure shapes.\n *\n * PER-ROW failures do not redden the sweep. The three columns are operator-added,\n * so until they exist every row fails with UNKNOWN_FIELD_NAME — the feature ships\n * dark, and reddening the nightly for a schema the operator has not made yet\n * teaches everyone to ignore the red. What a workflow gates on is the\n * `FLEET_WRITE_SUMMARY wrote=N failed=M total=T` line, which is exactly why that\n * formatter is shared rather than reinvented here.\n *\n * NOT BEING ABLE TO WRITE AT ALL is a different fact and does redden: the run was\n * asked to record its verdicts and recorded none, which is the silent no-op this\n * whole command refuses. The report is kept either way — it is the only thing\n * the run produced, and losing it to a credentials error would leave the operator\n * with a stack trace instead of a sweep.\n */\nasync function persistSweep(\n rows: readonly SweepRow[],\n deps: PrismicModelsDeps,\n): Promise<{ output: string; code: number }> {\n let sink: PrismicVerdictSink;\n let formatSummary: (result: FleetWriteResult) => string;\n try {\n // Loaded BEFORE the first write, so a formatter that cannot be imported fails\n // while \"nothing was written\" is still a true sentence. Dynamically, because\n // this module is also loaded by a consuming fleet site's own CI, where the\n // audit chain this import drags in is not installed.\n formatSummary = (await import(\"../../audits/write-audits-to-airtable.js\"))\n .formatFleetWriteSummary;\n sink = await deps.openVerdictSink();\n } catch (e) {\n return {\n output:\n `⛔ --write-airtable was asked for and NOTHING WAS WRITTEN: ${describeThrown(e)}.` +\n ` Every verdict above is unrecorded, so the cockpit and the digest keep whatever they` +\n ` held before — which for a site that has broken since is a green tick. Do NOT read` +\n ` the report above as having reached the fleet's record.`,\n code: 1,\n };\n }\n const result = await writeSweepToAirtable(\n rows,\n sink.websites,\n sink.update,\n new Date().toISOString(),\n );\n return { output: formatSummary(result), code: 0 };\n}\n\n/** `--fleet`: the read-only nightly sweep, and the exit-code rule that decides\n * whether it wakes anybody up. */\nasync function runFleetSweep(\n site: string | undefined,\n opts: PrismicModelsCommandOptions,\n deps: PrismicModelsDeps,\n cwd: string,\n): Promise<{ output: string; code: number }> {\n const { rows, skipped, resolved } = await sweepFleet(site, opts, deps, cwd);\n\n // NOTHING RESOLVED IS NOT A CLEAN FLEET. The inventory was read and named no\n // sites, so nothing was compared and this run learned nothing — and the\n // Airtable inventory is view-filtered, so one filter change empties it with no\n // error anywhere. Exiting 0 here is a green tick that silently retires the\n // whole drift alarm; `prismicSweepExitCode(0, 0)` cannot express it, because\n // for a fleet that legitimately has no Prismic sites 0/0 is correct.\n if (resolved === 0) {\n return {\n output:\n `the inventory resolved NO SITES, so no sites were swept and nothing was compared.` +\n ` This is not a clean fleet — check the inventory (an Airtable view filter, an empty` +\n ` JSON file, a dynamic inventory returning []). Do NOT read this exit as a result.`,\n code: 1,\n };\n }\n\n // Count on `status`, NOT on `clean === null && repositoryName !== null`.\n //\n // That derivation was wrong in exactly the case the sweep exists to catch: a\n // config that is PRESENT AND BROKEN never yields a `repositoryName`, so it\n // scored identically to a genuine \"not a Prismic site\" skip and vanished from\n // the failure count. The absent-vs-unreadable collapse, reappearing inside the\n // rule meant to detect outages.\n //\n // `checkOneSite` returns an explicit `status`, so the fleet layer reads a fact\n // instead of inferring one from two nulls. Never reconstruct this from\n // `clean`: `clean` answers \"does this site match Prismic\", which is only\n // meaningful once `status === \"checked\"`.\n //\n // THE SAME COUNT the summary below prints — see {@link countSweep}.\n const { checked, failed } = countSweep(rows);\n const body = rows\n .map((r) => `[${r.site}]${describeCheckoutCommit(r.commit)} ${r.detail}`)\n .join(\"\\n\\n\");\n\n // A collision forces non-zero regardless of the majority rule. It is not a\n // \"site failed to read\" — every site read fine — so `failed` cannot express\n // it, and a fleet where two repos overwrite each other's models must not\n // report success just because 15 of 15 sites were individually readable.\n //\n // TWO AXES, reported separately and BOTH fatal: two sites claiming one Prismic\n // repository, and two DIFFERENT repositories deriving one token secret. They\n // are one conflict seen from the repo side and from the credential side, they\n // have different fixes, and the fleet sweep is the only vantage point in the\n // system from which either is visible.\n const collisions = findRepositoryCollisions(rows);\n const envCollisions = findTokenEnvCollisions(rows);\n // A NOTE and not part of the exit code — see {@link findDuplicateSiteRows}.\n const duplicates = findDuplicateSiteRows(rows);\n // Derived from the SAME string that gets printed, so the refusal and the\n // reason can never disagree.\n const nothingChecked = describeNothingChecked(rows);\n const output = appendSkipNotice(\n [\n describeCollisions(collisions),\n describeTokenEnvCollisions(envCollisions),\n describeDuplicateSiteRows(duplicates),\n body,\n summariseSweep(rows),\n nothingChecked,\n ]\n .filter((s) => s !== \"\")\n .join(\"\\n\\n\"),\n skipped,\n );\n const alarms = collisions.length + envCollisions.length;\n const code = alarms > 0 || nothingChecked !== \"\" ? 1 : prismicSweepExitCode(checked, failed);\n\n if (!requested(opts.writeAirtable)) return { output, code };\n\n // THE WRITE IS APPENDED TO THE SWEEP, never substituted for it.\n //\n // The obvious shape — an early `return` from the write branch with\n // `prismicSweepExitCode(checked, failed)` — silently drops every OTHER reason\n // this sweep goes non-zero: a repository collision, a derived-token collision,\n // and the not-one-site-was-checked refusal. All three are conditions no\n // per-repo CI run can ever see, and all three would have exited 0 from the\n // moment anybody added `--write-airtable` to the nightly. The sweep's own\n // verdict therefore WINS over the write's; the write can only redden a run\n // that was otherwise green.\n const persisted = await persistSweep(rows, deps);\n return {\n output: `${output}\\n\\n${persisted.output}`,\n code: code !== 0 ? code : persisted.code,\n };\n}\n\n/**\n * `--tokens --fleet`: the token doctor across the whole inventory.\n *\n * This is the command that gates the rollout — the operator reads it to decide\n * which `PRISMIC_TOKEN_*` secrets to mint, and a secret nobody mints is a site\n * whose model delivery never works and whose drift alarm never fires. So the\n * failure that matters here is not a wrong verdict, it is a MISSING ROW: a site\n * that quietly does not appear on the checklist is a site the operator believes\n * they have covered.\n *\n * Three things follow, and each is the same rule in a different place:\n *\n * - an inventory that resolved nobody is refused, not printed as an empty\n * checklist (the Airtable inventory is view-filtered — one filter change\n * empties it with no error anywhere);\n * - a site that could not be PREPARED gets a row saying nothing was\n * established about it, never a skip;\n * - a run in which not one site's requirement was established is refused\n * outright, because \"every site says it has no Prismic config\" is what an\n * emptied set of checkouts looks like and it prints as a confident \"this\n * fleet needs no secrets at all\".\n *\n * NOTHING HERE TOUCHES PRISMIC. Every row is `PRESENT (not verified)` at best —\n * see {@link TokenProbe}. The doctor reads the environment for emptiness and\n * holds no value, so no row, no renderer and no future edit can put a live write\n * credential for FIFTEEN client repositories into one CI log.\n */\nasync function runFleetTokenDoctor(\n site: string | undefined,\n opts: PrismicModelsCommandOptions,\n deps: PrismicModelsDeps,\n cwd: string,\n): Promise<{ output: string; code: number }> {\n const { prep, resolved } = await prepareFleet(site, opts, cwd);\n\n // NOTHING RESOLVED IS NOT A FLEET WITH NO SECRETS TO MINT — the same refusal\n // `runFleetSweep` opens with, and it has to be here too: the table below would\n // render its header, its naming-rule explanation and \"0 ok, 0 missing\" over no\n // rows at all, which is indistinguishable from a checklist an operator has\n // finished working through.\n if (resolved === 0) {\n return {\n output:\n `the inventory resolved NO SITES, so no site's token requirement was established.` +\n ` This is not a fleet that needs no secrets — check the inventory (an Airtable view` +\n ` filter, an empty JSON file, a dynamic inventory returning []). Do NOT read this` +\n ` exit as a checklist.`,\n code: 1,\n };\n }\n\n // SEQUENTIAL, in inventory order. Each probe is a handful of filesystem reads\n // with no network in it at all, and the output is a checklist a human works\n // down — there is nothing here that parallelism would buy.\n const probes: TokenProbe[] = [];\n for (const s of prep.prepared) probes.push(await probeSiteToken(s.path, deps.env, siteLabel(s)));\n\n // A SITE THAT COULD NOT BE PREPARED IS A SITE WHOSE SECRET NOBODY ESTABLISHED,\n // and it gets a row — the bug Task 17 fixed in the sweep, in the doctor's own\n // shape. `prepareFleetSites` isolates a clone failure into `skipped` so one bad\n // inventory row cannot abort the fleet; a doctor built from `prep.prepared`\n // alone therefore prints a SHORTER checklist with no gap in it, and a\n // fleet-wide clone outage — an expired App token, GitHub down, a full disk —\n // comes out as \"no secrets needed\", exit 0.\n //\n // `status: \"failed\"`, never `\"skipped\"`: the renderer's skip row says in words\n // \"this site needs no token\", which is a claim about a repo somebody read.\n for (const s of prep.skipped) {\n probes.push({\n site: s.site,\n status: \"failed\",\n repositoryName: null,\n expectedEnv: null,\n present: false,\n reads: null,\n error: `could not prepare this checkout: ${s.reason}`,\n });\n }\n\n // THE COLLISION THE OPERATOR MUST SEE BEFORE MINTING, not after. Two DIFFERENT\n // Prismic repositories whose names upper-snake onto one `PRISMIC_TOKEN_*`\n // share one credential, so whichever token is minted is sent to both — one\n // client's write token at another client's repository. The doctor is where it\n // is cheapest to notice, because minting is the step it would otherwise be\n // baked into. It cannot be fixed in either repo (both configs are correct), so\n // it is reported with the sweep's wording and its own remedy.\n //\n // The other axis — two SITES claiming one repositoryName — is deliberately not\n // repeated here: those two sites share one repository and therefore genuinely\n // want one secret, so it is not a minting error, and the nightly sweep already\n // alarms on it with the fix that actually applies (edit one repo's config).\n const envCollisions = findTokenEnvCollisions(probes);\n\n // A NOTE and not part of the exit code — {@link findDuplicateSiteRows}, in the\n // doctor's output, for the reason the sweep prints it: the detector above\n // DEDUPES a repeated site (correctly — one site listed twice derives one env\n // var and is not a collision), so without this the only trace a duplicate\n // leaves is a checklist whose counts are silently inflated and whose row\n // appears twice. Same finding, same wording, same non-fatal treatment: the\n // rows were all really probed and the thing to change is the inventory.\n const duplicates = findDuplicateSiteRows(probes);\n\n // THE SHAPE OF THE FAILURES NOBODY HAS THOUGHT OF YET, and the doctor's twin\n // of {@link describeNothingChecked}. A \"skipped\" row is inferred from the\n // ABSENCE of a config file, so anything that empties the working trees turns\n // every site in the fleet into a confident \"needs no token\" and exits 0. The\n // `.git`-only guard in `probeSiteToken` catches the shape we know about; this\n // catches the rest by refusing to call a run in which NOTHING was established\n // a result.\n const nothingProbed =\n probes.some((p) => p.status === \"checked\") || probes.length === 0\n ? \"\"\n : `⛔ NOT ONE SITE'S TOKEN REQUIREMENT WAS ESTABLISHED. All ${probes.length} site(s) in` +\n ` this inventory came back unreadable or without a Prismic config, so this run learnt` +\n ` nothing about which secrets the fleet needs. That is not a fleet that is already` +\n ` provisioned: a skip is inferred from a MISSING config file, so anything that empties` +\n ` the checkouts makes every site look like a repo with no Prismic in it.` +\n ` Do NOT read this exit as a result.`;\n\n const output = appendSkipNotice(\n [\n describeTokenEnvCollisions(envCollisions),\n describeDuplicateSiteRows(duplicates),\n renderTokenDoctor(probes),\n nothingProbed,\n ]\n .filter((s) => s !== \"\")\n .join(\"\\n\\n\"),\n prep.skipped,\n );\n\n // The SAME finding rule the in-repo doctor uses, plus the two facts only a\n // fleet run can see. A checklist with an unminted secret on it, a site nobody\n // read, or a shared credential must not exit 0: this exit is what gates the\n // rollout step that follows it.\n return {\n output,\n code: tokenDoctorActionable(probes) || envCollisions.length > 0 || nothingProbed !== \"\" ? 1 : 0,\n };\n}\n\n/** Was a mode flag actually asked for? `undefined` is absent and `false` is what\n * a boolean flag parser leaves behind for a flag nobody typed; anything else —\n * `true`, a string, an empty string — is a request. */\nconst requested = (v: unknown): boolean => v !== undefined && v !== false;\n\n/**\n * Flag combinations that contradict each other, refused BEFORE any work.\n *\n * The alternative is not \"pick a sensible order\" — it is a silent no-op, which\n * is the same class of failure as the unimplemented-mode guard below and the\n * reason that guard exists. `--tokens --apply` runs a read-only doctor, prints a\n * table and exits 0, and the operator who typed `--apply` reads that exit as\n * \"the models were pushed\". `--tokens --comment-file ci.txt` leaves a workflow\n * waiting on a file that is never written.\n *\n * Exit 2, not 1: this is a malformed invocation, not a finding about a site. A\n * caller that treats 1 as \"drift\" and 2 as \"you asked for something impossible\"\n * can then tell them apart.\n */\nfunction modeConflict(opts: PrismicModelsCommandOptions): string | null {\n if (requested(opts.pull) && requested(opts.tokens)) {\n return (\n \"cannot combine --pull with --tokens. They are two different modes: one writes\" +\n \" remote-only models into this working tree, the other prints which secret each\" +\n \" site needs. Nothing was pulled, written or read — ask for one of them.\"\n );\n }\n if (requested(opts.pull)) {\n if (opts.apply === true) {\n return (\n \"cannot combine --pull with --apply — pull, review, then push. Running both in one\" +\n \" invocation would push this repo's models and adopt Prismic's in the same breath,\" +\n \" with no review in between. Nothing was pulled, written or pushed.\"\n );\n }\n if (requested(opts.fleet)) {\n return (\n \"cannot combine --pull with --fleet: --pull is single-repo only, because it WRITES\" +\n \" INTO A WORKING TREE and the result is meant to land as one reviewed PR.\" +\n \" Nothing was pulled or written.\"\n );\n }\n if (opts.commentFile !== undefined) {\n return (\n \"cannot combine --pull with --comment-file. The comment file is the in-repo model\" +\n \" check's review artifact; a pull-down's review artifact is the diff it leaves in\" +\n \" the working tree. Nothing was pulled or written.\"\n );\n }\n }\n if (requested(opts.tokens)) {\n if (opts.apply === true) {\n return (\n \"cannot combine --tokens with --apply. --tokens is a read-only checklist of which\" +\n \" secret each site needs; it pushes nothing. Nothing was compared, pushed or\" +\n \" written — drop one of the two flags.\"\n );\n }\n if (opts.commentFile !== undefined) {\n return (\n \"cannot combine --tokens with --comment-file. The comment file is the in-repo\" +\n \" model check's review artifact; the token doctor writes no report for a PR.\" +\n \" Nothing was written — redirect the output instead.\"\n );\n }\n // NEEDED FROM TASK 17b ON. `--write-airtable` persists a fleet SWEEP's\n // per-site MODEL verdicts, and the doctor produces none — it never compares a\n // model or calls Prismic at all. Until `--tokens --fleet` existed this pair\n // was caught on its way past by the unimplemented-combination guard; now the\n // tokens branch runs first and the alternative is the silent no-op this whole\n // guard exists for: a checklist printed, nothing written, exit 0, for an\n // operator who asked for a write-back. Listed INSIDE the tokens block so it\n // also catches the pair with --fleet, which the \"--write-airtable needs\n // --fleet\" rule at the end cannot see.\n if (requested(opts.writeAirtable)) {\n return (\n \"cannot combine --tokens with --write-airtable. --write-airtable persists a fleet\" +\n \" sweep's per-site MODEL verdicts to the Websites table, and the token doctor produces\" +\n \" none — it compares no models and calls Prismic not at all, so there is nothing for it\" +\n \" to write. Accepting it would print a checklist, write nothing and exit 0. Nothing was\" +\n \" read or written — run the sweep for verdicts, the doctor for secrets.\"\n );\n }\n }\n if (requested(opts.fleet)) {\n if (opts.apply === true) {\n return (\n \"cannot combine --fleet with --apply. The fleet sweep is READ-ONLY BY CONSTRUCTION —\" +\n \" `apply` is not plumbed through it at all, because a fleet-wide model push outside\" +\n \" CI is 🔴 under AUTONOMY.md. Accepting the flag and sweeping read-only anyway would\" +\n \" exit 0 having pushed nothing, which reads as a successful push. Nothing was\" +\n \" compared, pushed or written — push per repo, through that repo's own CI.\"\n );\n }\n if (opts.commentFile !== undefined) {\n return (\n \"cannot combine --fleet with --comment-file. The comment file is ONE repo's review\" +\n \" artifact, posted on ONE pull request; a fleet sweep has no pull request to post to.\" +\n \" Accepting it and writing nothing would leave a workflow waiting on a file that\" +\n \" never appears. Nothing was swept or written — redirect the output instead.\"\n );\n }\n }\n // LAST, so a more specific conflict gets to explain itself first.\n //\n // `--write-airtable` persists a FLEET SWEEP's per-site verdicts, and outside\n // fleet mode there is no sweep to persist. Accepting it and running the in-repo\n // check anyway is the exact silent no-op the unimplemented-mode guard used to\n // stand in front of: an operator asks for a write, the run exits 0, and nothing\n // was written. That guard was this flag's last entry and Task 20 removed it, so\n // this rule is what replaces it.\n if (requested(opts.writeAirtable) && !requested(opts.fleet)) {\n return (\n \"--write-airtable needs --fleet. It persists a fleet sweep's per-site verdicts to the\" +\n \" Websites table, and the in-repo check has no sweep to write — its review artifact is\" +\n \" the PR comment (--comment-file). Accepting the flag and comparing this one repo\" +\n \" instead would exit 0 having written nothing. Nothing was compared, pushed or written.\"\n );\n }\n return null;\n}\n\n/**\n * NOTHING IS UNBUILT ANY MORE — and that is why there is no guard here.\n *\n * There were two layers of unimplemented-mode machinery: a per-FLAG table\n * (`UNIMPLEMENTED_MODES`), whose last entry was `--write-airtable` until Task 20\n * built it, and a COMBINATION guard, whose last entry was `--tokens --fleet`\n * until Task 17b built it. Both are gone, and both were deleted for the same\n * stated reason rather than left in place returning null: a guard that lists\n * nothing protects nothing while still reading like protection to the next\n * person, who then adds a mode believing something stands in front of it.\n *\n * What did NOT go with them is {@link modeConflict}. A gap and a contradiction\n * are different facts with different exit codes — 1 for \"that mode does not\n * exist yet\", 2 for \"you asked for two things that cannot both be true\" — and\n * every conflict is still refused, including the `--tokens --write-airtable`\n * pair the combination guard used to catch on its way past.\n */\n\n/**\n * Write the comment file so a reader can never see a half-written or a stale one.\n *\n * Temp file + rename, because `rename(2)` on the same filesystem is atomic: the\n * workflow's `cat prismic-models.txt` either sees the whole report or no file at\n * all, never the first half of one that a crash interrupted. The temp file is a\n * SIBLING of the target so the rename cannot cross a filesystem and fall back to\n * a copy; a failed attempt takes its temp file with it rather than leaving\n * litter next to the artifact.\n */\nasync function writeCommentFileAtomically(path: string, body: string): Promise<void> {\n const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;\n try {\n await writeFile(tmp, body, \"utf-8\");\n await rename(tmp, path);\n } catch (e) {\n await rm(tmp, { force: true }).catch(() => undefined);\n throw e;\n }\n}\n\n/**\n * WHOSE run wrote this file.\n *\n * A comment file with no identity cannot be told apart from last run's. The\n * command writes one on its skip and error paths too — deliberately, so a failed\n * run still posts a reason instead of leaving the previous run's output in place\n * to be posted as this one's — but \"leaving it in place\" is precisely what\n * happens if the CLI dies before it writes at all, and on a re-used workspace\n * the stale file then reads as a fresh, passing report.\n *\n * So the file says which run made it. In CI that is the run id, the attempt (a\n * re-run of the same job keeps the id and increments this) and the commit;\n * locally it is the pid. The timestamp is the part a human actually checks.\n */\nconst runStamp = (\n env: Record<string, string | undefined>,\n repositoryName: string | undefined,\n): string => {\n const bits = [\"reddoor-maint prismic-models\", repositoryName ?? \"no Prismic repository resolved\"];\n if (env.GITHUB_RUN_ID) bits.push(`run ${env.GITHUB_RUN_ID}`);\n if (env.GITHUB_RUN_ATTEMPT) bits.push(`attempt ${env.GITHUB_RUN_ATTEMPT}`);\n if (env.GITHUB_SHA) bits.push(`commit ${env.GITHUB_SHA}`);\n if (!env.GITHUB_RUN_ID) bits.push(`local pid ${process.pid}`);\n bits.push(new Date().toISOString());\n return bits.join(\" · \");\n};\n\nexport async function runPrismicModelsCommand(\n site: string | undefined,\n opts: PrismicModelsCommandOptions,\n deps: PrismicModelsDeps = defaultDeps(),\n): Promise<{ output: string; code: number }> {\n // FIRST, and now the only mode gate: a contradiction must read as \"these two\n // cannot be asked for together\" rather than as a run that does something\n // OTHER than what was asked and calls it success. The unimplemented-mode guard\n // that used to sit under this line is gone — every mode is built.\n const conflict = modeConflict(opts);\n if (conflict) return { output: conflict, code: 2 };\n\n // Validated BEFORE any work, and by TYPE as well as by value.\n //\n // `--comment-file \"\"` — what an unset workflow variable expands to — is falsy,\n // so the old `if (opts.commentFile)` skipped the write in silence and exited 0:\n // a green check on a model PR with no review comment on it at all. And a flag\n // parser hands back `true` for `--comment-file` with no value, which\n // `resolve()` throws on; that throw sat outside the try below and took the\n // whole command down, losing the report with it.\n const commentFile: unknown = opts.commentFile;\n if (commentFile !== undefined && (typeof commentFile !== \"string\" || !commentFile.trim())) {\n return {\n output:\n `--comment-file was given ${typeof commentFile === \"string\" ? \"an empty value\" : `a ${typeof commentFile}`},` +\n ` which is not a path. Nothing was compared and nothing was pushed. An unset` +\n ` workflow variable expands to an empty string — pass a real path, or drop the` +\n ` flag. Do NOT read this exit as a result.`,\n code: 1,\n };\n }\n\n // Validated by TYPE as well as by value, exactly like `--comment-file` above\n // and for the same reason, one layer more dangerous.\n //\n // `--fleet \"\"` is what an unset workflow variable expands to, and EVERY\n // truthiness test in the chain lets it through as \"no fleet\": `resolveSites`\n // reads an empty `fleet` as absent and returns the current directory, so the\n // nightly would sweep the maintenance repo, find no Prismic config there,\n // report \"not a Prismic site — skipped\" and exit 0. The fleet-wide drift\n // alarm, silently retired, with a green tick every night.\n const fleetInventory: unknown = opts.fleet;\n if (requested(fleetInventory) && (typeof fleetInventory !== \"string\" || !fleetInventory.trim())) {\n return {\n output:\n `--fleet was given ${typeof fleetInventory === \"string\" ? \"an empty value\" : `a ${typeof fleetInventory}`},` +\n ` which is not an inventory. Nothing was swept and nothing was compared. An unset` +\n ` workflow variable expands to an empty string, and an empty --fleet reads as \"no` +\n ` fleet\" — one directory swept and reported as the whole fleet. Pass an inventory` +\n ` path or \"airtable\". Do NOT read this exit as a result.`,\n code: 2,\n };\n }\n\n const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();\n const repoRoot = site ? resolve(cwd, site) : cwd;\n\n // MODES, not modifiers: each reads what it needs and returns its own report.\n // Everything below this line is the in-repo model comparison.\n //\n // `--tokens` comes in two: one checkout, or the whole inventory. They are the\n // same question asked of a different set of sites, so they share the probe, the\n // renderer and the finding rule — and they must NEVER share a row count, which\n // is what the fleet branch is here to prevent. Without it, `--tokens --fleet`\n // falls through to the single-repo doctor and prints ONE row — this\n // directory's — under a heading that claims to cover the fleet.\n if (requested(opts.tokens)) {\n return requested(opts.fleet)\n ? runFleetTokenDoctor(site, opts, deps, cwd)\n : runTokenDoctor(repoRoot, deps);\n }\n if (requested(opts.pull)) return pullRemoteOnly(repoRoot, deps);\n // Fleet mode takes `site` rather than `repoRoot`: a positional site alongside\n // `--fleet` is a contradiction `resolveSites` already refuses, and resolving it\n // to a path here would hide that from it.\n if (requested(opts.fleet)) return runFleetSweep(site, opts, deps, cwd);\n\n const result = await checkOneSite(repoRoot, deps, {\n // `=== true` rather than truthiness, so the ONE place that decides whether\n // anything goes on the wire cannot be flipped by a stray string from a flag\n // parser. Dry is the default and the only default.\n apply: opts.apply === true,\n // TRUE here, and false in fleet mode — the modes are opposites and this is\n // the right side of it. An in-repo CI run has exactly one Prismic repository\n // in scope, and `PRISMIC_WRITE_TOKEN` is the name the reusable workflow puts\n // that repository's own Actions secret into (Task 24), rolled out per repo by\n // Task 26. It is NOT a name sites already read: measured 2026-08-13 across\n // the 15 in-scope repos from each one's origin/HEAD (`git grep`, never a\n // working tree), 10 never mention it at all and the 5 that do only in\n // one-shot migration scripts and docs — none in application code. A fleet run\n // iterates every repository against one environment, where a generic token\n // would attach the wrong credential to every site after the first.\n allowGenericToken: true,\n });\n\n let { output, code } = result;\n if (commentFile !== undefined) {\n // Inside the try along with everything else: `resolve` throws on a path\n // containing a NUL byte, and the report is the thing worth keeping.\n let path = commentFile;\n try {\n path = resolve(cwd, commentFile);\n const stamped = `${runStamp(deps.env, result.repositoryName)}\\n\\n${output}`;\n await writeCommentFileAtomically(path, forComment(stamped));\n } catch (e) {\n // The comment IS the review artifact. A dry run that failed to write it\n // and still exited 0 would leave a green check on a model PR whose delta\n // nobody ever saw — the same \"approved something they never read\" failure\n // as a silently shortened comment, reached from the other end. The report\n // is kept alongside the warning rather than replaced by it.\n output =\n `${output}\\n\\n⚠ COULD NOT WRITE THE COMMENT FILE (${path}): ${describeThrown(e)}.` +\n ` No comment will be posted, so do not read a passing check as \"reviewed\".`;\n code = code === 0 ? 1 : code;\n }\n }\n return { output, code };\n}\n\n/**\n * GitHub rejects an issue comment body over 65,536 characters, so whatever posts\n * this has to shorten it — and a report that was shortened without saying so is\n * this pipeline's governing failure in its purest form: the reviewer sees a\n * complete-looking comment and approves a model change whose destructive lines\n * were the ones cut.\n *\n * A first-ever push is the realistic trigger, not an edge case. The fleet holds\n * 68 custom types and 132 slices across 15 repos (measured 2026-08-12 from each\n * repo's origin/HEAD via `git ls-tree`, never a working tree), and an empty\n * Prismic repository sorts EVERY local model into `toCreate` with a field-level\n * line each.\n *\n * So truncate deliberately, keep the HEAD (the renderer puts the repository name,\n * the INCONSISTENT notice, the verdict and the DESTRUCTIVE warning there\n * precisely so they survive), and make the cut itself loud enough that nobody\n * mistakes the remainder for the whole.\n *\n * `limit` is this body's budget, and it is NOT the GitHub cap. 65,536 is the cap\n * on the ENTIRE comment, and what posts this wraps it — Task 24's workflow puts\n * a heading and a fenced block around it, and the file itself carries a run\n * stamp. Every one of those characters is spent out of the same 65,536, so a\n * body truncated to exactly the cap OVERFLOWS once wrapped, GitHub answers 422,\n * and NO COMMENT IS POSTED AT ALL. That is worse than a shortened review gate:\n * it is no review gate, on a PR that changes models on a live client's site, and\n * it fails in the direction where the CI step can still go green. So the default\n * is the cap MINUS headroom, and a caller who wraps more heavily passes less.\n */\nconst GITHUB_COMMENT_LIMIT = 65_536;\n\n/**\n * Characters held back from the cap for whatever wraps this body.\n *\n * Task 24's wrapper — `### Prismic model delta`, a blank line, and a fenced\n * block — is 34 characters. 2 KiB is sixty times that: room for a workflow to\n * add a `<details>` block, a heading naming the site, or a footer, without\n * anyone having to remember that the number here and the wrapper there are one\n * budget. It costs 3% of the report and buys the failure mode that produces no\n * comment at all.\n */\nconst COMMENT_WRAPPER_HEADROOM = 2_048;\n\nexport const DEFAULT_COMMENT_LIMIT = GITHUB_COMMENT_LIMIT - COMMENT_WRAPPER_HEADROOM;\n\nexport function forComment(body: string, limit = DEFAULT_COMMENT_LIMIT): string {\n // At the HEAD as well as the tail. Everything else in this report was ordered\n // so the facts that stop a merge survive GitHub collapsing a long comment from\n // the top — and the tail notice, the one line that says \"you are not reading\n // the whole thing\", sat in the least-read position of the longest possible\n // comment. Terse, because it is paid for out of the report's own budget.\n const headMarker = `⚠ TRUNCATED — this is not the whole report; the cut is explained at the end.\\n\\n`;\n const notice =\n `\\n\\n⚠ TRUNCATED — this report is ${body.length} characters and the budget for this` +\n ` comment body is ${limit} (GitHub caps the whole comment, wrapper included, at` +\n ` ${GITHUB_COMMENT_LIMIT}). Everything below the cut is missing, including any` +\n ` further destructive lines. Run \\`reddoor-maint prismic-models\\` locally — dry is` +\n ` the default — for the whole report before approving.\\n`;\n\n // A HARD ERROR, and checked before the early return so a caller finds out on\n // the first run rather than on the first long report — which is the run that\n // can least afford a surprise. Silently emitting notice-plus-nothing would\n // overrun the very budget the caller asked to be kept, and there is no honest\n // shortening at this size: the only fix is a bigger budget.\n if (limit < headMarker.length + notice.length) {\n throw new Error(\n `forComment: limit ${limit} is smaller than the truncation warning itself` +\n ` (${headMarker.length + notice.length} characters), so a shortened report could not` +\n ` say that it was shortened. Raise the limit; do not drop the warning.`,\n );\n }\n if (body.length <= limit) return body;\n\n let head = body.slice(0, Math.max(0, limit - notice.length - headMarker.length));\n // The cut is by code unit, so it can land between the halves of an astral\n // character and leave a lone surrogate in the file the workflow posts. Drop the\n // orphan rather than emit invalid text.\n const last = head.charCodeAt(head.length - 1);\n if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1);\n return headMarker + head + notice;\n}\n","// The one renderer, deliberately. This string is used VERBATIM as the PR comment\n// body (inside a fenced block) and as CLI output, so what CI shows a reviewer is\n// character for character what an operator sees locally — two renderers is two\n// verdicts that can drift apart, and the whole point of commenting the delta on a\n// PR is that the reviewer and the pipeline agree about what is being merged.\n//\n// PURE. No IO, no clock, no environment. Everything it can say comes from the\n// arguments, which is what makes the reconciliation below cheap enough to run on\n// every path.\n//\n// THE RULE THIS FILE IS WRITTEN AGAINST, restated for a renderer: a report that\n// OMITS a fact must not look identical to a report where the fact was absent. The\n// upstream form (\"I could not read X\" must never produce the same result as \"X\n// does not exist\") has been found five times in this pipeline. Rendering has its\n// own version of it, and `remoteOnly` is the instance that matters: it is the\n// never-delete report, and if it renders as nothing then a checkout that has lost\n// every slice in the repo reads exactly like a clean run. Every branch below is\n// written to be distinguishable from the healthy case.\nimport {\n describeDiff,\n type ModelDiff,\n type ModelKind,\n type PushReport,\n} from \"../../prismic/models/index.js\";\n\n/** No divergence of any kind between the repo and Prismic. `remoteOnly` counts:\n * it is either an out-of-band cloud edit or a model the repo has forgotten, and\n * both are drift the operator needs to see. */\nexport function isClean(diff: ModelDiff): boolean {\n return diff.toCreate.length === 0 && diff.toUpdate.length === 0 && diff.remoteOnly.length === 0;\n}\n\n/** `report` is the whole `PushReport` when a push actually ran, and ABSENT on a\n * dry run — absent and empty are different facts, so do not default it to an\n * empty report. `failed` remains for callers that have only the failure list.\n *\n * Passing BOTH is supported and is the better call when a caller has both: it\n * is what makes the reconciliation below able to run at all. It is NOT a way to\n * override the report — a `failed` that disagrees with `report.failed` is\n * reported as an inconsistency, not silently preferred. */\nexport type ReportOptions = {\n apply: boolean;\n failed?: PushReport[\"failed\"];\n report?: PushReport;\n};\n\ntype Identity = { kind: ModelKind; id: string };\n\n/** Identity ACROSS collections, matching `diff.ts`: a custom type and a slice\n * can share an id, so `kind` is part of the key. Keying on id alone would make\n * a slice and a custom type of the same name reconcile as one model and hide a\n * real disagreement. */\nconst key = (e: Identity): string => `${e.kind}:${e.id}`;\nconst keysOf = (xs: readonly Identity[]): Set<string> => new Set(xs.map(key));\nconst minus = (a: Set<string>, b: Set<string>): string[] => [...a].filter((k) => !b.has(k)).sort();\nconst intersect = (a: Set<string>, b: Set<string>): string[] =>\n [...a].filter((k) => b.has(k)).sort();\n\n/**\n * Reconcile every fact this renderer is handed TWICE.\n *\n * Three pairs, all the same defect shape. `ModelDiff.remoteOnly` vs\n * `PushReport.remoteOnlyReported`; `opts.apply` vs `PushReport.mode`;\n * `opts.failed` vs `PushReport.failed`. Each pair is one fact with two sources,\n * and each pair agrees BY CONSTRUCTION at the current caller — which is exactly\n * the argument that held for `remoteOnly` right up until Task 10 gave it a\n * second source. Agreement by construction is a property of today's caller, not\n * of this function's inputs.\n *\n * A disagreement means something upstream is broken — a diff computed against a\n * different remote read than the push ran against, a report built from a stale\n * diff, or a caller pairing a report with the wrong diff — and every other\n * figure in the report came from those same inputs, so none of them can be\n * trusted either. That is why this reconciles rather than picking a winner, and\n * why the output says \"do not act on this\" instead of quietly preferring one\n * side. The quiet version is a report that looks authoritative and is not.\n *\n * The fourth check is Task 10's push invariant rather than a duplicated fact:\n * `pushModels` builds its work list from `toCreate` + `toUpdate` and drops every\n * item into exactly one of `sent`/`failed`. The summary line's arithmetic\n * depends on that being true, so it is checked here rather than assumed — a\n * model in NEITHER bucket vanished silently, and a model in BOTH double-counts.\n *\n * Returns the disagreement lines, EMPTY when everything agrees or when there is\n * nothing to reconcile against. A dry run never builds a `PushReport`, so with\n * no report the report-derived checks are SKIPPED, not read as \"the report said\n * none\" — absent and empty are different facts, the rule this pipeline is built\n * around. (The one check that needs no report still runs: a caller-supplied\n * `failed` naming a model this diff is not pushing is wrong on its own terms.)\n *\n * EXPORTED, and that is the point of it being a named function rather than an\n * inline block inside the renderer. The report's head says \"do not act on this\"\n * in PROSE, and the caller has to turn that into a machine-readable verdict —\n * `clean`, and an exit code — for Airtable and the cockpit. The only other way\n * to get the answer out of here is to grep the rendered string for the warning\n * text, which makes the wording of a warning load-bearing: reword the sentence\n * and the verdict silently flips to \"clean\" with nothing red. So the fact is\n * published as DATA and the prose is rendered FROM it, never the reverse.\n *\n * Pure and cheap, so calling it once for the verdict and once inside the\n * renderer costs nothing — but pass the SAME `ReportOptions` object to both, or\n * the two answers are about different questions.\n */\nexport function reconcileReport(diff: ModelDiff, opts: ReportOptions): string[] {\n const out: string[] = [];\n const work = keysOf([...diff.toCreate, ...diff.toUpdate.map((u) => u.local)]);\n\n // Needs no report: `n - failed.length` in the summary assumes every failure\n // names a model this diff was actually pushing. When it does not, the old\n // arithmetic printed a numerator that was merely wrong — or negative — with no\n // hint of why.\n if (opts.failed !== undefined) {\n for (const k of minus(keysOf(opts.failed), work))\n out.push(` reported as failed, but not a model this diff is pushing: ${k}`);\n }\n\n const report = opts.report;\n if (report === undefined) return out;\n\n // The headline. `mode` is the push run's own record of whether anything went\n // on the wire; `opts.apply` is what the caller believes. A disagreement makes\n // the verdict line a lie in whichever direction is worse: \"3/3 model(s)\n // pushed\" for a run that sent nothing, or \"DRY RUN — nothing was sent\" for a\n // run that rewrote three models in a live repository.\n if ((report.mode === \"apply\") !== opts.apply) {\n out.push(\n ` mode: rendering as ${opts.apply ? \"an APPLY\" : \"a DRY RUN\"}, but the push run` +\n ` recorded mode \"${report.mode}\"`,\n );\n }\n\n const diffRemote = keysOf(diff.remoteOnly);\n const reportRemote = keysOf(report.remoteOnlyReported);\n for (const k of minus(diffRemote, reportRemote))\n out.push(` remote-only per the comparison, absent from the push report: ${k}`);\n for (const k of minus(reportRemote, diffRemote))\n out.push(` remote-only per the push report, absent from the comparison: ${k}`);\n\n if (opts.failed !== undefined) {\n // Compared by IDENTITY, not by the `error` text. Which models failed is what\n // the summary arithmetic counts and what an operator acts on; the message is\n // prose from an injected sender and two truthful renderings of the same\n // rejection can word it differently.\n const callerFailed = keysOf(opts.failed);\n const reportFailed = keysOf(report.failed);\n for (const k of minus(callerFailed, reportFailed))\n out.push(` failed per the caller's list, absent from the push report: ${k}`);\n for (const k of minus(reportFailed, callerFailed))\n out.push(` failed per the push report, absent from the caller's list: ${k}`);\n }\n\n const sent = keysOf(report.sent);\n const failed = keysOf(report.failed);\n const accounted = new Set([...sent, ...failed]);\n for (const k of minus(work, accounted))\n out.push(` in this diff's work list, but the push run neither sent nor failed it: ${k}`);\n for (const k of minus(accounted, work))\n out.push(` sent or failed by the push run, but not in this diff's work list: ${k}`);\n for (const k of intersect(sent, failed))\n out.push(` recorded BOTH sent and failed by the push run: ${k}`);\n\n return out;\n}\n\n/** A `describeDiff` line whose push would DESTROY something on the live model.\n *\n * Two forms, both from diff.ts: a `- ` line (a field, variation, or tab present\n * in Prismic and absent from the repo — pushing removes it) and the `repeatable`\n * flip, which diff.ts's own header calls \"destructive, not cosmetic\" and which\n * touches neither field zone, so it carries no `-`.\n *\n * This CLASSIFIES ANOTHER MODULE'S OUTPUT FORMAT, which is a coupling worth\n * naming: if diff.ts ever changes those prefixes, the count here would silently\n * fall to zero — a warning that disappears is the exact failure this file is\n * written against. It is pinned instead of trusted: the destructive tests in\n * tests/cli/prismic-models-report.test.ts drive real model pairs through\n * `describeDiff` and assert on both the raw line and the warning, so a format\n * change reddens them rather than going quiet. */\nconst isDestructive = (line: string): boolean =>\n line.startsWith(\"- \") || line === \"~ (model) repeatable\";\n\n/** One site's model delta, plain text. Used verbatim as the PR comment body\n * (inside a fenced block) and as CLI output.\n *\n * ORDER IS PART OF THE DESIGN. GitHub collapses a long comment from the top and\n * a reviewer reads downward, so the head carries, in this order: what\n * repository this is (a wrong-repo run is otherwise invisible), whether the\n * report can be trusted at all, the verdict, and whether merging destroys\n * anything. The itemised detail follows. A reviewer who reads only the first\n * four lines still learns everything that would stop a merge. */\nexport function renderModelReport(\n repositoryName: string,\n diff: ModelDiff,\n opts: ReportOptions,\n): string {\n const head: string[] = [`Prismic models — repository: ${repositoryName}`];\n\n // Reconcile FIRST, and render it even on an otherwise-clean diff. A mismatch\n // means the inputs disagree, so \"clean\" is exactly the verdict that must not\n // be printed unqualified — a report that says \"nothing to push\" while its own\n // two sources disagree is the most dangerous output this renderer can produce,\n // and the clean early return below is precisely the path that would skip the\n // check.\n const disagreements = reconcileReport(diff, opts);\n if (disagreements.length > 0) {\n head.push(\n `⚠ INCONSISTENT — the inputs to this report disagree with each other, so every` +\n ` figure in it is unreliable: they all came from the same inputs. Something` +\n ` upstream is broken (a diff and a push report from different runs, a stale` +\n ` diff, or a mismatched pair). Re-run; do not act on this.`,\n ...disagreements,\n );\n }\n\n if (isClean(diff) && disagreements.length === 0) {\n head.push(`${diff.unchanged.length} model(s) match Prismic — nothing to push.`);\n // Zero on BOTH sides is not a clean run, it is a misconfiguration wearing\n // one. Every in-scope fleet repository holds models in the repo and in\n // Prismic, so this state means a wrong `repositoryName`, a token pointed at\n // the wrong repository, or a slice library path that no longer resolves —\n // and \"0 model(s) match Prismic — nothing to push.\" on its own is worded\n // like a success.\n if (diff.unchanged.length === 0) {\n head.push(\n `⚠ NOTHING WAS FOUND ON EITHER SIDE — the repo declared no models and Prismic` +\n ` holds none. For a live site that is a misconfiguration (a wrong` +\n ` repositoryName, or a slice library path that no longer resolves), not a` +\n ` clean run.`,\n );\n }\n return head.join(\"\\n\");\n }\n\n const body: string[] = [];\n let destructive = 0;\n\n for (const entry of diff.toCreate) {\n body.push(`NEW ${entry.kind} ${entry.id} (${entry.path})`);\n }\n for (const { local, remote } of diff.toUpdate) {\n body.push(`CHANGED ${local.kind} ${local.id} (${local.path})`);\n for (const l of describeDiff(local.model, remote.model)) {\n if (isDestructive(l)) destructive += 1;\n body.push(` ${l}`);\n }\n }\n\n // The alamo-anatomy shape, and the one this whole file's header is about. A\n // slice-library path that stops resolving answers zero local models ON PURPOSE\n // (`subdirs` in local.ts, for a proven-ENOENT library), so EVERY model Prismic\n // holds sorts into `remoteOnly` and no other bucket is populated. The\n // REMOTE-ONLY block alone renders that as a routine \"you have some cloud-only\n // models\" note — which is how a checkout that has lost the entire repo's model\n // set comes to read as ordinary drift. It is not ordinary, so it goes in the\n // HEAD next to the verdict rather than underneath a heading further down: the\n // verdict on this run (\"0 model(s) would be pushed; 0 already match\") is the\n // one that most needs the qualification.\n const matchedNothing =\n diff.remoteOnly.length > 0 &&\n diff.toCreate.length + diff.toUpdate.length + diff.unchanged.length === 0;\n\n if (diff.remoteOnly.length > 0) {\n if (body.length > 0) body.push(\"\");\n body.push(\n `REMOTE-ONLY — present in Prismic, absent from this repo. These are reported and` +\n ` never deleted by CI; pull them down with \\`reddoor-maint prismic-models --pull\\`` +\n ` or delete them in the Prismic dashboard.`,\n );\n for (const entry of diff.remoteOnly) body.push(` ${entry.kind} ${entry.id}`);\n }\n\n // Precedence is moot rather than authoritative: when both are supplied and\n // they disagree, `reconcile` above has already said so at the top of the\n // report, so which one is rendered here cannot quietly decide anything.\n const failures = opts.failed ?? opts.report?.failed ?? [];\n if (failures.length > 0) {\n if (body.length > 0) body.push(\"\");\n for (const f of failures) body.push(`FAILED ${f.kind} ${f.id}: ${f.error}`);\n // The whole reason `PushReport.failed` carries `status`: a 401/403 is a dead\n // or wrong write token (fix the secret) and a 422 is a rejected model (fix\n // the model). Those are different jobs for different people, and a dead\n // token produces N identical FAILED lines — noisy, honest, and impossible to\n // triage from the message text alone. Prismic's token expiry is\n // undocumented, so this is not a rare branch.\n const auth = failures.filter((f) => f.status === 401 || f.status === 403).length;\n if (auth > 0) {\n body.push(\n `⚠ ${auth} failure(s) came back 401/403 — that is the WRITE TOKEN, not the` +\n ` models. Fix the secret and re-run; a model Prismic actually rejected` +\n ` would be a 422.`,\n );\n }\n }\n\n const n = diff.toCreate.length + diff.toUpdate.length;\n // MEASURED, not derived, wherever the measurement exists. `n - failures.length`\n // assumes Task 10's invariant (every worked model lands in exactly one of\n // `sent`/`failed`) instead of reading the answer that is right there — and the\n // two differ precisely when the invariant broke: a model that vanished from\n // both buckets is COUNTED AS PUSHED by the arithmetic and is not by\n // `sent.length`. Claiming a model reached Prismic when nothing was sent for it\n // is the one direction this number must never be wrong in. `reconcile` above\n // reports the underlying breakage; this keeps the headline honest meanwhile.\n // The fallback is the arithmetic, for callers that supply no report — the same\n // assumption, made visibly and only where there is nothing better.\n const pushed =\n opts.report !== undefined && opts.report.mode === \"apply\"\n ? opts.report.sent.length\n : n - failures.length;\n const verdict = opts.apply\n ? `${pushed}/${n} model(s) pushed. ${diff.unchanged.length} already matched.`\n : `DRY RUN — nothing was sent. ${n} model(s) would be pushed; ${diff.unchanged.length} already match.`;\n\n // Both qualify the verdict, so both sit immediately under it. \"This checkout\n // matched nothing\" goes first: it says the run itself is not to be believed,\n // which outranks anything about what the run would do.\n const warnings: string[] = [];\n if (matchedNothing) {\n warnings.push(\n `⚠ THIS CHECKOUT MATCHED NOTHING — every model below is remote-only and the repo` +\n ` declared none at all. That is what a broken slice-library path looks like (a` +\n ` renamed directory, a typo in \\`libraries\\`, a partial checkout), not a repo` +\n ` that legitimately has no models.`,\n );\n }\n if (destructive > 0) {\n warnings.push(\n `⚠ DESTRUCTIVE — ${destructive} line(s) below remove a field, variation, or tab` +\n ` from a live model, or flip \\`repeatable\\`. Prismic drops the document data` +\n ` for a field the model no longer declares, with HTTP 200 and no warning.` +\n ` Read every \\`-\\` line before merging.`,\n );\n }\n\n return [...head, verdict, ...warnings, ...(body.length > 0 ? [\"\", ...body] : [])].join(\"\\n\");\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateNextDueDates,\n type WebsiteRow,\n} from \"../../reports/airtable/websites.js\";\nimport { listAllReports, type ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\nimport type { ScheduleMirror } from \"../../audits/health-mirror.js\";\nimport { operatorEmail } from \"../../util/operator.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n enrich?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n return runDigest({ baseUrl: dashboardBaseUrl() });\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n return sendApprovedReports();\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, {\n previewOnly: Boolean(opts.preview),\n enrich: Boolean(opts.enrich),\n reportType,\n });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--enrich] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n // Phase 3 dual-write (#539): mirror real next-due writes into site_schedule.\n // Null when libSQL creds are absent — the Airtable path is unchanged.\n const { makeScheduleMirrorBestEffort } = await import(\"../../audits/health-mirror.js\");\n const result = await draftDueReports(base, new Date(), await makeScheduleMirrorBestEffort());\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = operatorEmail();\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n *\n * The diff-guard (#539 Phase 3): a site whose computed dates equal what its row\n * already holds is SKIPPED — before it, every one of the 44 sites got a nightly\n * write while only the handful whose schedule actually moved needed one (a\n * never-maintained site re-wrote null over null forever). This also scopes the\n * write to maintained sites by construction: no schedule → computed null →\n * equal to the stored null → skipped. Each real write dual-writes through\n * `scheduleMirror` into site_schedule (null mirror = Turso creds absent; the\n * hourly sync converges either way). The NEXT_DUE_WRITE line is observability,\n * not a CI gate — nothing greps it yet.\n */\nexport async function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n let wrote = 0;\n let skipped = 0;\n let failed = 0;\n let mirrored = 0;\n let mirrorFailed = 0;\n let mirrorMissed = 0;\n for (const site of websites) {\n // The whole per-site body sits in ONE try — compute included — so a bad\n // row can only cost its own write, exactly the pre-diff-guard blast radius.\n try {\n const maintenanceAt = ymd(nextDueDate(site, reports, \"Maintenance\", today));\n const testingAt = ymd(nextDueDate(site, reports, \"Testing\", today));\n if (maintenanceAt === site.nextMaintenanceAt && testingAt === site.nextTestingAt) {\n skipped++;\n continue;\n }\n const fields = await updateNextDueDates(base, site.id, { maintenanceAt, testingAt });\n wrote++;\n if (scheduleMirror) {\n try {\n // false = the UPDATE matched no site_schedule row (site created in\n // Airtable since the last hourly import) — missed, not mirrored.\n if (await scheduleMirror(site.id, fields, today.toISOString())) mirrored++;\n else mirrorMissed++;\n } catch (e) {\n mirrorFailed++;\n console.warn(`⚠ [schedule-mirror] ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n failed++;\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n // failed= keeps a write outage visible: without it wrote+skipped silently\n // undercounts, and a FULL outage prints wrote=0 skipped=0 — indistinguishable\n // from an empty fleet.\n // A null mirror writes nothing and throws nothing, so its only trace used to\n // be an ABSENT suffix — which reads exactly like a healthy run. `mirror=absent`\n // states it instead. Counters stay off in that case deliberately: `mirrored=0`\n // would claim a write that returned nothing, not one that was never attempted.\n const mirrorNote = scheduleMirror\n ? ` mirrored=${mirrored} mirror_failed=${mirrorFailed} mirror_missed=${mirrorMissed}`\n : \" mirror=absent\";\n console.log(`NEXT_DUE_WRITE wrote=${wrote} skipped=${skipped} failed=${failed}${mirrorNote}`);\n}\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const websites = await listWebsites(base);\n // ONE unfiltered fetch for the whole fleet. Per-site queries can't be pushed to\n // Airtable anyway (linked-record fields aren't formula-filterable by record id),\n // and findDueReports + the period guard below match on siteId in memory.\n const reports = await listAllReports(base);\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today, scheduleMirror);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, { period });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; enrich: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n // Only forced on. Left undefined, draftReportForSite keeps its default\n // (enrich iff there is a base), so the real drafting path is untouched.\n ...(opts.enrich ? { enrich: true } : {}),\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
File without changes