@reddoorla/maintenance 0.88.3 → 0.90.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 (131) hide show
  1. package/dist/{announce-GKD5YJNB.js → announce-NDTLRQCN.js} +6 -6
  2. package/dist/{blux-EATHOHKK.js → blux-YGBGS24U.js} +5 -5
  3. package/dist/blux-YGBGS24U.js.map +1 -0
  4. package/dist/{chunk-XKDCR4UE.js → chunk-5PWB3JHJ.js} +2 -3
  5. package/dist/chunk-5PWB3JHJ.js.map +1 -0
  6. package/dist/{chunk-7FFUBF4I.js → chunk-AX22DH6S.js} +2 -2
  7. package/dist/{chunk-XSOINOPY.js → chunk-DCDF5A7N.js} +3 -3
  8. package/dist/{chunk-B23FFWU4.js → chunk-DFLN2KO3.js} +99 -22
  9. package/dist/{chunk-B23FFWU4.js.map → chunk-DFLN2KO3.js.map} +1 -1
  10. package/dist/{chunk-CEW5PEHZ.js → chunk-DXKF552B.js} +260 -5
  11. package/dist/chunk-DXKF552B.js.map +1 -0
  12. package/dist/{chunk-7UAYMAKR.js → chunk-DXWLWR4Q.js} +19 -6
  13. package/dist/chunk-DXWLWR4Q.js.map +1 -0
  14. package/dist/{chunk-62CMMGEO.js → chunk-GSWTT2J6.js} +20 -5
  15. package/dist/chunk-GSWTT2J6.js.map +1 -0
  16. package/dist/{chunk-6I6L3CMX.js → chunk-KGUKL3CV.js} +2 -2
  17. package/dist/{chunk-VNB5XBQ4.js → chunk-L5M6G65K.js} +1 -1
  18. package/dist/chunk-L5M6G65K.js.map +1 -0
  19. package/dist/{chunk-73UFRZKY.js → chunk-MLT4QY5X.js} +105 -2
  20. package/dist/chunk-MLT4QY5X.js.map +1 -0
  21. package/dist/{chunk-ULHPALI5.js → chunk-NDCOOI3V.js} +2 -2
  22. package/dist/{chunk-GQDDWYU2.js → chunk-QBAAZY7W.js} +1 -1
  23. package/dist/{chunk-GQDDWYU2.js.map → chunk-QBAAZY7W.js.map} +1 -1
  24. package/dist/{chunk-6UCVWYDA.js → chunk-QLIUDTY6.js} +4 -4
  25. package/dist/{chunk-XP5WM2LV.js → chunk-SKI23ZZH.js} +4 -4
  26. package/dist/chunk-SVAAONYB.js +21 -0
  27. package/dist/chunk-SVAAONYB.js.map +1 -0
  28. package/dist/{chunk-DGZWFI2G.js → chunk-VBEKL445.js} +17 -8
  29. package/dist/chunk-VBEKL445.js.map +1 -0
  30. package/dist/{chunk-WPL5GERE.js → chunk-ZOCJBJQV.js} +2 -2
  31. package/dist/{chunk-J3WEZGIN.js → chunk-ZRI5VRPG.js} +4 -4
  32. package/dist/cli/bin.js +25 -19
  33. package/dist/cli/bin.js.map +1 -1
  34. package/dist/cli/commands/audit.js +7 -7
  35. package/dist/{db-DXUUAMXT.js → db-LOSJ74ZJ.js} +50 -9
  36. package/dist/db-LOSJ74ZJ.js.map +1 -0
  37. package/dist/{digest-NBLECFM7.js → digest-CS6KLCZB.js} +12 -12
  38. package/dist/{digest-collectors-NM7WUWQP.js → digest-collectors-NI2M3UTC.js} +7 -7
  39. package/dist/{dump-EPL7AZFR.js → dump-QK7LNOGC.js} +14 -1
  40. package/dist/{dump-EPL7AZFR.js.map → dump-QK7LNOGC.js.map} +1 -1
  41. package/dist/{email-FMMEUPX6.js → email-LJQT5J7F.js} +6 -7
  42. package/dist/{email-FMMEUPX6.js.map → email-LJQT5J7F.js.map} +1 -1
  43. package/dist/{ensure-site-FU6527OU.js → ensure-site-FFDUMY6E.js} +2 -2
  44. package/dist/{fleet-state-R5LU7IC3.js → fleet-state-3LQUQHYD.js} +2 -2
  45. package/dist/{forms-notify-target-SGDGXXK5.js → forms-notify-target-IHJX7NWA.js} +3 -3
  46. package/dist/freeze-A6MUCZU4.js +9 -0
  47. package/dist/{github-signals-DB25UWY4.js → github-signals-FCLNHKMF.js} +4 -4
  48. package/dist/{health-mirror-GCDN7BJL.js → health-mirror-GMSF3Z5R.js} +5 -5
  49. package/dist/images/index.d.ts +55 -0
  50. package/dist/images/index.js +16 -0
  51. package/dist/images/index.js.map +1 -0
  52. package/dist/index.d.ts +16 -4
  53. package/dist/index.js +24 -24
  54. package/dist/{init-32AWPS7A.js → init-S4MJ3Y6W.js} +7 -7
  55. package/dist/{launch-52WUE4TZ.js → launch-7FT4EYEO.js} +9 -9
  56. package/dist/notify-4HP4GZ2H.js +1 -1
  57. package/dist/{orchestrate-JNKCWU5K.js → orchestrate-6HROYTM6.js} +5 -5
  58. package/dist/{parity-NBYZUBFU.js → parity-623WNFZG.js} +3 -3
  59. package/dist/{pipeline-YIBIQ7XK.js → pipeline-MQJD4CKN.js} +6 -2
  60. package/dist/{preflight-IMTYDDD4.js → preflight-LIN5NCYU.js} +6 -6
  61. package/dist/{prismic-ci-MY2U6NN7.js → prismic-ci-6YGRIOHT.js} +2 -2
  62. package/dist/{prismic-models-IJWZW2EO.js → prismic-models-VNGIKS2B.js} +4 -4
  63. package/dist/prospect/types.d.ts +8 -0
  64. package/dist/{prospect-audit-WJQI7V5V.js → prospect-audit-JCWTKJTS.js} +8 -6
  65. package/dist/prospect-audit-JCWTKJTS.js.map +1 -0
  66. package/dist/recipes/sync-configs.d.ts +8 -1
  67. package/dist/recipes/sync-configs.js +4 -2
  68. package/dist/{render-CVKE7IWC.js → render-P3SBUFVB.js} +3 -3
  69. package/dist/{render-from-row-DOYJDO2L.js → render-from-row-P5CWM3MQ.js} +3 -3
  70. package/dist/{report-2LM2SX67.js → report-AAY2HZZR.js} +30 -14
  71. package/dist/report-AAY2HZZR.js.map +1 -0
  72. package/dist/{report-mirror-AAK3MXHY.js → report-mirror-GMNSH5HF.js} +5 -5
  73. package/dist/{self-updating-NTCCWJYZ.js → self-updating-VQIW24RZ.js} +3 -3
  74. package/dist/{selftest-DIZ5JD2X.js → selftest-CUX2FIQD.js} +7 -7
  75. package/dist/{site-mirror-Q763PLGG.js → site-mirror-GCFLBBWK.js} +5 -5
  76. package/dist/{submissions-TEHPBSZJ.js → submissions-7LGJJSDL.js} +1 -1
  77. package/dist/{sync-GBFV45WX.js → sync-ESP5ZQUU.js} +3 -3
  78. package/dist/{sync-configs-34JIA6KZ.js → sync-configs-ZYLTMUNX.js} +7 -13
  79. package/dist/sync-configs-ZYLTMUNX.js.map +1 -0
  80. package/dist/usage-UHKU76VK.js +147 -0
  81. package/dist/usage-UHKU76VK.js.map +1 -0
  82. package/dist/{write-audits-to-airtable-OPBJXGS4.js → write-audits-to-airtable-K352LZQY.js} +3 -3
  83. package/dist/write-audits-to-airtable-K352LZQY.js.map +1 -0
  84. package/package.json +5 -1
  85. package/dist/blux-EATHOHKK.js.map +0 -1
  86. package/dist/chunk-62CMMGEO.js.map +0 -1
  87. package/dist/chunk-73UFRZKY.js.map +0 -1
  88. package/dist/chunk-7UAYMAKR.js.map +0 -1
  89. package/dist/chunk-CEW5PEHZ.js.map +0 -1
  90. package/dist/chunk-DGZWFI2G.js.map +0 -1
  91. package/dist/chunk-RUPPWAD6.js +0 -7
  92. package/dist/chunk-RUPPWAD6.js.map +0 -1
  93. package/dist/chunk-VNB5XBQ4.js.map +0 -1
  94. package/dist/chunk-XKDCR4UE.js.map +0 -1
  95. package/dist/db-DXUUAMXT.js.map +0 -1
  96. package/dist/prospect-audit-WJQI7V5V.js.map +0 -1
  97. package/dist/report-2LM2SX67.js.map +0 -1
  98. package/dist/sync-configs-34JIA6KZ.js.map +0 -1
  99. /package/dist/{announce-GKD5YJNB.js.map → announce-NDTLRQCN.js.map} +0 -0
  100. /package/dist/{chunk-7FFUBF4I.js.map → chunk-AX22DH6S.js.map} +0 -0
  101. /package/dist/{chunk-XSOINOPY.js.map → chunk-DCDF5A7N.js.map} +0 -0
  102. /package/dist/{chunk-6I6L3CMX.js.map → chunk-KGUKL3CV.js.map} +0 -0
  103. /package/dist/{chunk-ULHPALI5.js.map → chunk-NDCOOI3V.js.map} +0 -0
  104. /package/dist/{chunk-6UCVWYDA.js.map → chunk-QLIUDTY6.js.map} +0 -0
  105. /package/dist/{chunk-XP5WM2LV.js.map → chunk-SKI23ZZH.js.map} +0 -0
  106. /package/dist/{chunk-WPL5GERE.js.map → chunk-ZOCJBJQV.js.map} +0 -0
  107. /package/dist/{chunk-J3WEZGIN.js.map → chunk-ZRI5VRPG.js.map} +0 -0
  108. /package/dist/{digest-NBLECFM7.js.map → digest-CS6KLCZB.js.map} +0 -0
  109. /package/dist/{digest-collectors-NM7WUWQP.js.map → digest-collectors-NI2M3UTC.js.map} +0 -0
  110. /package/dist/{ensure-site-FU6527OU.js.map → ensure-site-FFDUMY6E.js.map} +0 -0
  111. /package/dist/{fleet-state-R5LU7IC3.js.map → fleet-state-3LQUQHYD.js.map} +0 -0
  112. /package/dist/{forms-notify-target-SGDGXXK5.js.map → forms-notify-target-IHJX7NWA.js.map} +0 -0
  113. /package/dist/{orchestrate-JNKCWU5K.js.map → freeze-A6MUCZU4.js.map} +0 -0
  114. /package/dist/{github-signals-DB25UWY4.js.map → github-signals-FCLNHKMF.js.map} +0 -0
  115. /package/dist/{health-mirror-GCDN7BJL.js.map → health-mirror-GMSF3Z5R.js.map} +0 -0
  116. /package/dist/{init-32AWPS7A.js.map → init-S4MJ3Y6W.js.map} +0 -0
  117. /package/dist/{launch-52WUE4TZ.js.map → launch-7FT4EYEO.js.map} +0 -0
  118. /package/dist/{parity-NBYZUBFU.js.map → orchestrate-6HROYTM6.js.map} +0 -0
  119. /package/dist/{pipeline-YIBIQ7XK.js.map → parity-623WNFZG.js.map} +0 -0
  120. /package/dist/{render-CVKE7IWC.js.map → pipeline-MQJD4CKN.js.map} +0 -0
  121. /package/dist/{preflight-IMTYDDD4.js.map → preflight-LIN5NCYU.js.map} +0 -0
  122. /package/dist/{prismic-ci-MY2U6NN7.js.map → prismic-ci-6YGRIOHT.js.map} +0 -0
  123. /package/dist/{prismic-models-IJWZW2EO.js.map → prismic-models-VNGIKS2B.js.map} +0 -0
  124. /package/dist/{render-from-row-DOYJDO2L.js.map → render-P3SBUFVB.js.map} +0 -0
  125. /package/dist/{write-audits-to-airtable-OPBJXGS4.js.map → render-from-row-P5CWM3MQ.js.map} +0 -0
  126. /package/dist/{report-mirror-AAK3MXHY.js.map → report-mirror-GMNSH5HF.js.map} +0 -0
  127. /package/dist/{self-updating-NTCCWJYZ.js.map → self-updating-VQIW24RZ.js.map} +0 -0
  128. /package/dist/{selftest-DIZ5JD2X.js.map → selftest-CUX2FIQD.js.map} +0 -0
  129. /package/dist/{site-mirror-Q763PLGG.js.map → site-mirror-GCFLBBWK.js.map} +0 -0
  130. /package/dist/{submissions-TEHPBSZJ.js.map → submissions-7LGJJSDL.js.map} +0 -0
  131. /package/dist/{sync-GBFV45WX.js.map → sync-ESP5ZQUU.js.map} +0 -0
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-DGZWFI2G.js";
8
+ } from "./chunk-VBEKL445.js";
9
9
  import {
10
10
  listAllReports
11
11
  } from "./chunk-F3JWOPTG.js";
@@ -341,4 +341,4 @@ export {
341
341
  preflight,
342
342
  approveBlockers
343
343
  };
344
- //# sourceMappingURL=chunk-6I6L3CMX.js.map
344
+ //# sourceMappingURL=chunk-KGUKL3CV.js.map
@@ -696,4 +696,4 @@ export {
696
696
  writeModelFile,
697
697
  noWorkingTreeFailure
698
698
  };
699
- //# sourceMappingURL=chunk-VNB5XBQ4.js.map
699
+ //# sourceMappingURL=chunk-L5M6G65K.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/prismic/models/canon.ts","../src/prismic/models/diff.ts","../src/prismic/models/config.ts","../src/prismic/models/token.ts","../src/prismic/models/local.ts","../src/prismic/models/remote.ts","../src/prismic/models/push.ts","../src/prismic/models/write.ts","../src/util/working-tree.ts"],"sourcesContent":["// Canonical form for comparing a model on disk against the copy REGISTERED in\n// Prismic. Prismic hands its copy back through its own serializer, so it differs\n// from the file on disk in ways that mean NOTHING: key order, an explicit\n// `\"select\": null` added to Link fields, `imageUrl` (the slice preview\n// screenshot — Prismic owns it, so its value is meaningless for comparison; on\n// disk it is usually `\"\"` but sometimes a stale URL: nine fleet repos'\n// RichText/model.json carry a real `https://…` value — caltex-landing,\n// data-dynamiq, erp-industrial, espada, gallerysonder, hedloc,\n// medical-solutions-of-texas, reddoor-website, vineyard-custom-homes), and —\n// the one beachfront's original did not handle — EMPTY STRINGS.\n//\n// The empty-string rule is load-bearing, not cosmetic — but a thumbnail\n// height:\"\" is the RARE trigger, not the common one. Fleet-wide (grep\n// '\"height\": *\"\"' across every *.json, not just model.json — see the warning\n// below) it occurs exactly ONCE: hedloc/customtypes/home/index.json, a\n// thumbnail {\"name\":\"desktop\",\"width\":1200,\"height\":\"\"}. The other four real\n// thumbnail entries on disk (caltex-landing/src/lib/slices/Hero/model.json ×2,\n// hedloc/src/lib/slices/Hero/model.json ×2) already carry \"height\":null, not\n// \"\". What makes the rule load-bearing is its reach far beyond thumbnails:\n// plain \"\" shows up in ordinary field configs constantly — \"placeholder\":\"\"\n// alone appears 801 times fleet-wide. Any one of those is the same trap in\n// miniature if Prismic ever normalises it away on ingest. The proof for the\n// specific hedloc case is a round-trip through the Types API on the-pinnacle:\n// sent height:\"\", read back height:null — confirming Prismic really does\n// coerce \"\" to null (and drop the key) rather than store it verbatim, so\n// keeping \"\" while dropping null would have made hedloc's copies unequal\n// FOREVER: a push sends \"\", Prismic stores null, the next scan diffs again.\n//\n// WARNING for the next person grepping to check a claim like this one: half\n// of what canon() compares are custom types, not slice models, and they live\n// at a different path — customtypes/<id>/index.json, not\n// src/lib/slices/<Name>/model.json. A model.json-only search misses them\n// entirely, which is exactly how the hedloc occurrence above stays invisible\n// to a scan that only looks at slice models.\n//\n// Safe because the only comparison it collapses is `\"\" vs absent`, which IS the\n// non-difference. `\"\" vs \"something\"` still differs (one side keeps the key),\n// and `0`/`false` are untouched — they are not `\"\"`.\n//\n// `imageUrl` is dropped by key name alone, at ANY depth — not just on\n// variations. That's a chosen tradeoff, not an oversight: narrowing it to\n// \"variation-shaped objects\" would trade this hole for a shape heuristic that\n// could itself be wrong. The risk it accepts: a Prismic field whose literal API\n// ID is `imageUrl` would go invisible to comparison on both sides — the exact\n// silent-drop failure class this module exists to prevent. Not currently live:\n// fleet field API IDs are snake_case (`background_image`, `meta_image`, …),\n// and all 180 current occurrences of the key sit at `variations[].imageUrl`\n// (measured 2026-08-12 by walking every model this pipeline loads, at every\n// depth, across the 15 in-scope fleet repos on their DEFAULT branches — the\n// starters carry a sentinel repositoryName and are never read, so their models\n// are not counted; zero occurrences were found anywhere but on a variation).\n\n/** Recursively normalise a model for comparison. Pure. */\nexport function canon(v: unknown): unknown {\n // Elements are NOT filtered here — only object keys are, below. Dropping a\n // null/\"\"/undefined element would shift array indices, so [1, null] would\n // wrongly canon-equal [1]. An empty array stays `[]`, not dropped: e.g.\n // `thumbnails: []` round-trips through Prismic unchanged, so \"empty\" has to\n // stay distinguishable from \"absent\".\n if (Array.isArray(v)) return v.map(canon);\n if (v !== null && typeof v === \"object\") {\n return Object.fromEntries(\n Object.entries(v as Record<string, unknown>)\n // Only the VALUE of a noise key is filtered out — an object left with\n // zero keys collapses to `{}`, which is exactly what `constraint: {}`\n // already is on disk. Nothing here treats \"now empty\" as \"absent\".\n .filter(([k, x]) => x !== null && x !== undefined && x !== \"\" && k !== \"imageUrl\")\n .sort(([a], [b]) => (a < b ? -1 : 1))\n .map(([k, x]) => [k, canon(x)]),\n );\n }\n return v;\n}\n\n/** True when two models are the same MODEL, ignoring serializer noise.\n * Precondition: callers pass models (plain objects/arrays read from JSON),\n * never `undefined` — this performs no defensive check for that case. */\nexport function sameModel(a: unknown, b: unknown): boolean {\n return JSON.stringify(canon(a)) === JSON.stringify(canon(b));\n}\n","import { sameModel } from \"./canon.js\";\nimport type { LocalEntry, ModelDiff, ModelKind, PrismicModel, RemoteEntry } from \"./types.js\";\n\n/** Identity of a model ACROSS collections. A custom type and a slice can share\n * an id (they are different Types API resources), so `kind` is part of the key —\n * keying on id alone pairs them and reports a phantom update whose push would\n * go to the wrong endpoint. */\nconst key = (e: { kind: ModelKind; id: string }): string => `${e.kind}:${e.id}`;\n\n/** Sort local + remote models into the four buckets. Pure — no IO, no ordering\n * assumptions. `remoteOnly` is reported only; nothing downstream may delete it. */\nexport function diffModels(local: LocalEntry[], remote: RemoteEntry[]): ModelDiff {\n const remoteByKey = new Map(remote.map((e) => [key(e), e]));\n const localKeys = new Set(local.map(key));\n const diff: ModelDiff = { toCreate: [], toUpdate: [], unchanged: [], remoteOnly: [] };\n for (const l of local) {\n const r = remoteByKey.get(key(l));\n if (!r) diff.toCreate.push(l);\n else if (sameModel(l.model, r.model)) diff.unchanged.push(l);\n else diff.toUpdate.push({ local: l, remote: r });\n }\n for (const r of remote) if (!localKeys.has(key(r))) diff.remoteOnly.push(r);\n return diff;\n}\n\ntype Zone = Record<string, unknown>;\nconst asZone = (v: unknown): Zone => (v !== null && typeof v === \"object\" ? (v as Zone) : {});\n\ntype Verb = \"added\" | \"removed\" | \"changed\";\n\n/** Real fleet JSON nests at most 13 levels deep, and this only recurses at\n * Group/Slices boundaries (Prismic does not nest those more than one level\n * in practice) — so this bound sits comfortably above anything real data\n * will hit. It exists purely to stop a malformed/pathological model from\n * recursing without limit, not to clip genuine fleet depth. */\nconst MAX_FIELD_DEPTH = 20;\n\n/** CAVEAT that applies to every add/remove pass in this file (`compareMeta`\n * here AND `compareZone` below): they test raw key PRESENCE via\n * `Object.hasOwn`, not `sameModel`. Only the CHANGED pass runs values\n * through `sameModel`/`canon()`. So a key whose VALUE `canon` drops as\n * noise (`null`/`\"\"`) but that exists on only ONE side could, in\n * principle, render an add/remove line for what is really serializer\n * noise, not a real difference.\n *\n * Bounded and currently harmless: 0 of the 1,167 real `\"\"`/`null` keys in\n * the fleet sit at a raw-presence level here — all are inside field\n * configs, one level down, where the CHANGED pass's `sameModel` call does\n * guard them — and a pair whose ONLY difference is canon-noise is\n * classified `unchanged` by `diffModels` and never reaches `describeDiff`\n * at all.\n *\n * BUT that second bound is a property of the CALLER, not of this\n * function. If any future caller ever runs `describeDiff` on a pair\n * `diffModels` sorted into `unchanged` — a verbose mode, a debug flag —\n * that bound disappears and this becomes a false-alarm source: crying\n * \"changed\" on pure serializer noise is the INVERTED failure from the one\n * this whole file exists to prevent, and the nightly drift sweep must\n * never produce it. */\n\n/** Flat key-diff for a METADATA object — the model's own top-level keys, or\n * a variation's own top-level keys. Deliberately NOT recursive: metadata\n * values (`label`, `name`, `repeatable`, …) are leaves, never Group/Slices\n * containers, so there is nothing here for `compareZone` to descend into.\n * `format` renders each line so metadata reads distinctly from the\n * dot-notation field-path lines `compareZone` emits — `~ (model) label`\n * must never be mistaken for a field path. */\nconst compareMeta = (\n a: Zone,\n b: Zone,\n excluded: readonly string[],\n format: (k: string, verb: Verb) => string,\n): string[] => {\n const skip = new Set<string>(excluded);\n const out: string[] = [];\n for (const k of Object.keys(a)) {\n if (skip.has(k)) continue;\n if (!Object.hasOwn(b, k)) out.push(format(k, \"added\"));\n }\n for (const k of Object.keys(b)) {\n if (skip.has(k)) continue;\n if (!Object.hasOwn(a, k)) out.push(format(k, \"removed\"));\n }\n for (const k of Object.keys(a)) {\n if (skip.has(k) || !Object.hasOwn(b, k)) continue;\n if (!sameModel(a[k], b[k])) out.push(format(k, \"changed\"));\n }\n return out;\n};\n\nconst modelMetaLine = (k: string, verb: Verb): string =>\n verb === \"added\"\n ? `+ (model) ${k}`\n : verb === \"removed\"\n ? `- (model) ${k} (only in Prismic — pushing DELETES it)`\n : `~ (model) ${k}`;\n\nconst variationMetaLine = (id: string, k: string, verb: Verb): string =>\n verb === \"added\"\n ? `+ variation ${id}: ${k}`\n : verb === \"removed\"\n ? `- variation ${id}: ${k} (only in Prismic — pushing DELETES it)`\n : `~ variation ${id}: ${k}`;\n\n/** Top-level keys that hold FIELD structure, not metadata — excluded from\n * the model-level metadata diff because each has its own dedicated pass. */\nconst MODEL_FIELD_KEYS = [\"variations\", \"json\"] as const;\n\n/** A variation's own field zones. */\nconst VARIATION_FIELD_ZONES = [\"primary\", \"items\"] as const;\n\n/** `imageUrl` is the slice-preview screenshot URL — Prismic-owned, and\n * `canon()` (see canon.ts) already treats it as meaningless noise at ANY\n * depth, dropping the key outright before comparing. A per-key metadata\n * diff bypasses that whole-object canonicalization, so without this\n * exclusion a stale on-disk `imageUrl` (nine real fleet repos carry one,\n * per canon.ts) would surface as a spurious\n * `~ variation X: imageUrl (changed)` line riding alongside — or instead\n * of — a real change. */\nconst VARIATION_META_EXCLUDE = [...VARIATION_FIELD_ZONES, \"imageUrl\"] as const;\n\n/** A field's nested comparison zone, if it has one. `Group` fields keep\n * their member fields at `config.fields`; `Slices` fields keep their\n * allowed slice choices at `config.choices`. Anything else has no children\n * to recurse into. Returns `undefined` (not `{}`) for \"not a container\", so\n * the caller can tell \"no container\" apart from \"container with zero\n * fields\". */\nconst containerChildren = (field: unknown): Zone | undefined => {\n const f = asZone(field);\n if (f.type === \"Group\") return asZone(asZone(f.config).fields);\n if (f.type === \"Slices\") return asZone(asZone(f.config).choices);\n return undefined;\n};\n\n/** A container field's own definition with its CHILDREN key excluded —\n * `fields` for Group, `choices` for Slices. Isolates \"did the container's\n * own config change\" (a Group's `repeat` flag, a Slices field's `fieldset`\n * label) from \"did a child change\", so `compareZone` can report the two\n * independently instead of one hiding the other. */\nconst withoutChildren = (field: unknown, childrenKey: \"fields\" | \"choices\"): unknown => {\n const f = asZone(field);\n const cfg = asZone(f.config);\n const { [childrenKey]: _children, ...restCfg } = cfg;\n return { ...f, config: restCfg };\n};\n\n/** Field-zone diff — used for `primary`/`items`, custom-type tabs, and\n * (recursively) the inside of Group/Slices containers. A field present on\n * both sides but different is, by default, one `~ label.field (changed)`\n * line. But when BOTH sides agree the field is the SAME container type\n * (Group or Slices) and recursing into its children finds the actual\n * difference, that specific child is reported instead of the opaque parent\n * line — e.g. `+ Main.slices.lead_text` rather than `~ Main.slices\n * (changed)`, which used to be the only signal a reviewer got for \"someone\n * removed a slice-zone choice\" (137 real Group/Slices containers in the\n * fleet, some hiding up to 28 children).\n *\n * The container's OWN config (everything except its children) is ALSO\n * compared whenever a child difference is found, not only when it isn't:\n * reporting the child line alone was itself a metadata blind spot one\n * level down from the one `describeDiff` fixes at the model/variation\n * level — mutating a Group's `repeat` flag alongside a field addition\n * rendered as a lone `+ Main.sections.new_field`, with the destructive\n * half of the change invisible (155/155 real fleet containers, mutating\n * both at once). If recursion finds nothing at all — the container's own\n * config changed with byte-identical children — the generic changed line\n * is still emitted, so the change never goes silent either way. */\nconst compareZone = (label: string, lz: unknown, rz: unknown, depth: number): string[] => {\n const a = asZone(lz);\n const b = asZone(rz);\n const out: string[] = [];\n for (const k of Object.keys(a)) if (!Object.hasOwn(b, k)) out.push(`+ ${label}.${k}`);\n for (const k of Object.keys(b))\n if (!Object.hasOwn(a, k)) out.push(`- ${label}.${k} (only in Prismic — pushing DELETES it)`);\n for (const k of Object.keys(a)) {\n if (!Object.hasOwn(b, k) || sameModel(a[k], b[k])) continue;\n const childPath = `${label}.${k}`;\n if (depth < MAX_FIELD_DEPTH) {\n const av = asZone(a[k]);\n const bv = asZone(b[k]);\n if (av.type === bv.type && (av.type === \"Group\" || av.type === \"Slices\")) {\n const childrenKey = av.type === \"Group\" ? \"fields\" : \"choices\";\n const nested = compareZone(\n childPath,\n containerChildren(a[k]),\n containerChildren(b[k]),\n depth + 1,\n );\n if (nested.length > 0) {\n if (!sameModel(withoutChildren(a[k], childrenKey), withoutChildren(b[k], childrenKey)))\n out.push(`~ ${childPath} (changed)`);\n out.push(...nested);\n continue;\n }\n }\n }\n out.push(`~ ${childPath} (changed)`);\n }\n return out;\n};\n\n/** Field-level lines for ONE model pair, used in PR comments, CLI output,\n * and the nightly drift sweep. `remote` is optional so a first-ever push\n * (nothing registered in Prismic yet) can still be described — everything\n * local renders as new instead of throwing.\n *\n * Three passes, not one: `primary`/`items`/`json`-tab field diffs are\n * necessary but not sufficient. A model-level rename (`label`, `format`,\n * `status`, `repeatable`) or a variation-level rename (`name`,\n * `description`) touches NEITHER zone, so without the other two passes\n * those changes rendered as zero lines while `diffModels` still sorted the\n * pair into `toUpdate` — a PR comment reading \"CHANGED customtype page\"\n * with nothing underneath it, and a reviewer approving a change they never\n * actually saw. Verified against the real fleet: mutating slice `name` or\n * `description` alone (174/174 real variations) and customtype `label`,\n * `status`, `format`, or `repeatable` alone (77/77 real custom types) both\n * rendered blank before this fix.\n *\n * Those two counts are an EXPERIMENT RECORD, not a live inventory, and they\n * deliberately do not match the 132 slice models / 68 custom types cited\n * elsewhere in this directory. The run (2026-08-12) covered every model file\n * ON DISK, including the two starter templates whose sentinel\n * `repositoryName` keeps them out of this pipeline — a strict SUPERSET of\n * what `readPrismicConfig` admits. That makes the evidence stronger than the\n * in-scope figure would suggest: the blind spot reproduced across all 174 and\n * all 77, the in-scope models among them. Do not \"reconcile\" these numbers\n * down to the in-scope count — that would claim an experiment that was never\n * run. `repeatable` flipping is destructive, not\n * cosmetic — and this is also precisely what the nightly drift sweep exists\n * to catch, since an out-of-band dashboard edit (someone renaming a type,\n * say) IS a metadata-only edit by definition. */\nexport function describeDiff(local: PrismicModel, remote: PrismicModel | undefined): string[] {\n const l = asZone(local);\n const r = asZone(remote);\n const lines: string[] = [];\n\n // On an insert (no remote yet) EVERY key is new, so itemizing each one is\n // pure noise once the structural `+ tab X (new)` / `+ variation X (new)`\n // lines already say \"this is all new\" — verified against the real fleet:\n // the metadata pass alone produced 1,111 `+ (model) k` lines against 368\n // structural ones on inserts (75% noise), a custom type opening with five\n // `+ (model) …` lines before the first `+ tab Main (new)`.\n if (remote !== undefined) lines.push(...compareMeta(l, r, MODEL_FIELD_KEYS, modelMetaLine));\n\n // Slice shape.\n if (Array.isArray(l.variations) || Array.isArray(r.variations)) {\n const variationEntries = (arr: unknown): Array<[string, Zone]> =>\n Array.isArray(arr)\n ? arr.map((v, i) => {\n const z = asZone(v);\n // A cast (`z.id as string`) would LIE when `id` is absent: it\n // would compile-time-assert a string that isn't there at\n // runtime, and every id-less variation on a side would key to\n // the literal string \"undefined\" and collide with every OTHER\n // id-less variation on that SAME side — silently dropping all\n // but the last one's diff. 0 of the 180 in-scope fleet\n // variations lack an id (measured 2026-08-12 on each repo's\n // DEFAULT branch across the 15 repos this pipeline loads; the\n // starters carry a sentinel repositoryName and are never read),\n // so this is defensive, but the index fallback is unique per\n // side and its label says exactly what it is.\n const id = typeof z.id === \"string\" ? z.id : `variations[${i}]`;\n return [id, z] as [string, Zone];\n })\n : [];\n const lv = new Map(variationEntries(l.variations));\n const rv = new Map(variationEntries(r.variations));\n for (const [id, v] of lv) {\n const rr = rv.get(id);\n if (!rr) {\n lines.push(`+ variation ${id} (new)`);\n continue;\n }\n for (const zone of VARIATION_FIELD_ZONES)\n lines.push(...compareZone(`${id}.${zone}`, v[zone], rr[zone], 0));\n lines.push(\n ...compareMeta(v, rr, VARIATION_META_EXCLUDE, (k, verb) => variationMetaLine(id, k, verb)),\n );\n }\n for (const id of rv.keys())\n if (!lv.has(id)) lines.push(`- variation ${id} (only in Prismic — pushing DELETES it)`);\n }\n\n // Custom-type shape.\n if (l.json !== undefined || r.json !== undefined) {\n const lt = asZone(l.json);\n const rt = asZone(r.json);\n for (const tab of Object.keys(lt)) {\n if (!Object.hasOwn(rt, tab)) {\n lines.push(`+ tab ${tab} (new)`);\n continue;\n }\n lines.push(...compareZone(tab, lt[tab], rt[tab], 0));\n }\n for (const tab of Object.keys(rt))\n if (!Object.hasOwn(lt, tab))\n lines.push(`- tab ${tab} (only in Prismic — pushing DELETES it)`);\n }\n\n // A blank \"CHANGED <model>\" is the failure this function exists to prevent, and\n // three separate blind spots have now produced one (metadata, containers, zone\n // presence). This is the backstop: anything `sameModel` calls different MUST\n // render at least one line. It closes the known zone-presence class and\n // degrades any FUTURE blind spot to an honest \"changed, no detail\" instead of a\n // silent blank. It also turns the property test below from a sample into a\n // guarantee.\n if (lines.length === 0 && remote !== undefined && !sameModel(local, remote))\n lines.push(\"~ (model) changed (no field-level detail)\");\n\n return lines;\n}\n\n/** A push REPLACES the whole model, and Prismic — not the repo — owns the\n * slice-preview screenshot at `variations[].imageUrl`: it is `\"\"` on disk\n * for most models and a stale URL on nine real fleet RichText models (see\n * canon.ts). Sending the local value would blank or rot every slice\n * preview in the editor as a side effect of pushing one unrelated field\n * change. This is the deliberate asymmetry with `canon()`: comparison\n * DROPS `imageUrl` entirely (it must never cause a diff), but a push still\n * has to put a real value on the wire, and the only trustworthy one is\n * whatever is currently live on the remote.\n *\n * PRESENCE, not truthiness — the remote's value wins whenever the MATCHED\n * remote variation HAS an `imageUrl` KEY, including when its value is\n * `\"\"`. A truthy check (`shot ? … : v`) looks equivalent and is tempting\n * to \"simplify\" back to, but it is not: it conflates \"no remote data for\n * this id\" (nothing is known, so keeping local is correct) with \"remote\n * explicitly reports `\"\"`\" (Prismic currently has NO screenshot for this\n * variation — that IS the live truth). Falling through to local on a\n * remote `\"\"` doesn't preserve a preview, it WRITES a stale URL onto a\n * variation that currently has none — the exact damage this function\n * exists to prevent, on exactly the nine RichText models that carry one.\n * The check has to be `Object.hasOwn(remoteVariation, \"imageUrl\")`, not\n * `shots.has(id)`: an id MATCH alone is not proof the matched remote\n * variation carries the key at all — without this, a matched remote\n * variation that genuinely has no `imageUrl` property would overwrite a\n * real local URL with `undefined`.\n *\n * A variation with no string `id` is left untouched rather than matched by\n * array index. `describeDiff` can fall back to an index label\n * (`variations[i]`) because a wrong pairing there only costs a mislabeled\n * comment line; here a wrong pairing WRITES the wrong screenshot onto a\n * live variation on push. Guessing identity is the wrong trade when being\n * wrong is destructive instead of cosmetic, so an id-less local variation\n * just passes through with whatever `imageUrl` it already has.\n *\n * On an INSERT (`remote === undefined`) there is no remote to defer to at\n * all, but the local value is still not safe to send verbatim — nine real\n * fleet repos carry a stale on-disk URL, and sending it on a fresh insert\n * is the exact damage this function exists to prevent, just on the one\n * path with no remote value to fall back on. Every variation's `imageUrl`\n * is normalised to `\"\"` instead (unconditionally — there is no matching\n * happening here, so the id-less-variation caveat above does not apply):\n * `\"\"` is what Slice Machine writes normally and what 171 of the 180\n * in-scope fleet variations already carry (measured 2026-08-12 on each\n * repo's DEFAULT branch across the 15 repos this pipeline loads; the other\n * 9 hold a real URL and NONE omit the key), so it is proven acceptable to\n * the Types API. The KEY stays present — never deleted —\n * because an absent `imageUrl` is unproven against the API, and a live\n * push is not the place to find out. */\nexport function withRemoteScreenshots(\n local: PrismicModel,\n remote: PrismicModel | undefined,\n): PrismicModel {\n if (!Array.isArray(local.variations)) return local;\n\n if (!remote) {\n return {\n ...local,\n variations: local.variations.map((v) => ({ ...asZone(v), imageUrl: \"\" })),\n };\n }\n\n const shots = new Map(\n (Array.isArray(remote.variations) ? remote.variations : [])\n .map((v) => asZone(v))\n .filter((o): o is Zone & { id: string } => typeof o.id === \"string\")\n .map((o) => [o.id, o] as const),\n );\n return {\n ...local,\n variations: local.variations.map((v) => {\n const o = asZone(v);\n if (typeof o.id !== \"string\") return v;\n const remoteVariation = shots.get(o.id);\n if (!remoteVariation || !Object.hasOwn(remoteVariation, \"imageUrl\")) return v;\n return { ...o, imageUrl: remoteVariation.imageUrl };\n }),\n };\n}\n","import { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\n/** What this pipeline needs from a site's Prismic config. */\nexport type PrismicConfig = {\n /** The Prismic repository name — the `repository` header on every Types API call. */\n repositoryName: string;\n /** Slice library directories, repo-relative. Fleet-uniform at [\"./src/lib/slices\"],\n * but read rather than assumed: alamo-anatomy points at a directory that does\n * not exist, and assuming the path would make its models invisible instead of\n * visibly empty. An ABSENT key defaults; a present-but-malformed one throws\n * (see {@link readPrismicConfig}). */\n libraries: string[];\n};\n\n/** Slice Machine's file, and the name the Prismic CLI renames it to, in\n * preference order. Both are read so an adopting repo does not go dark;\n * slicemachine.config.json is tried first because that is what every Prismic\n * repo in the fleet ships today — measured 2026-08-12 across the 30 local\n * checkouts: 17 carry a Prismic config and all 17 use the Slice Machine name;\n * none ships prismic.config.json yet. That makes prismic.config.json the\n * FORWARD path, which is exactly why a stale first file must not be allowed to\n * mask it — see the sentinel handling in {@link readPrismicConfig}. */\nconst CONFIG_FILES = [\"slicemachine.config.json\", \"prismic.config.json\"] as const;\n\n/**\n * Repository names that mean \"this repo is not pointed at a content model of\n * its own\" — a placeholder, never a client's Prismic repository.\n *\n * `your-prismic-repo-name` is the starter's placeholder, carried unreplaced by\n * four fleet repos: reddoor-starter, reddoor-starter-blux, canvas-starter, and\n * composition-hospitality. Only the first three are visible from a local sweep\n * (verified 2026-08-12; the third has no local checkout and was confirmed\n * through the GitHub API), so a measurement taken across `~/Documents/GitHub`\n * finds three and is not evidence that the third is gone.\n *\n * `reddoor-wireframer` is Reddoor's shared wireframe repository (operator\n * ruling 2026-08-14). Only data-dynamiq names it. It belongs here for a reason\n * the starter sentinel never had: IT RESOLVES. The repository genuinely exists\n * — HTTP 200, two starter documents last published 2024-03-12 — so unlike a\n * 404-ing placeholder, nothing about querying it will ever reveal that it is\n * one. Without this entry the site is a permanent `unknown` verdict and a\n * nightly `prismic-unknown:` cockpit warning that no credential can clear,\n * because the correct number of tokens to mint for it is zero.\n *\n * ADDING TO THIS LIST IS A DECISION ONLY AN OPERATOR CAN MAKE. A name here is\n * dropped from every sweep, so a real client repository added by mistake stops\n * being checked and reports as \"no Prismic here\" rather than as an error.\n *\n * A plain readonly array rather than a Set: this module's capability guard\n * rejects free ambient identifiers, and `ReadonlySet` is one. Two entries do\n * not need a hash lookup, and the guard is worth more than the micro-optimum.\n */\nconst PLACEHOLDER_REPOSITORY_NAMES: readonly string[] = [\n \"your-prismic-repo-name\",\n \"reddoor-wireframer\",\n];\n\n/**\n * Read a repo's Prismic config, or null when it is not a Prismic site.\n *\n * null means \"no Prismic here, skip this repo\" — no config file at all, or\n * every candidate file still carrying the starter sentinel. A PRESENT but\n * malformed or unreadable config THROWS: a repo that has Prismic and a broken\n * config must surface as an error, never as a silent skip. That distinction is\n * the whole point of the return type, and it is a bigger lever here than\n * anywhere else in this module — one unreadable model file loses one model, but\n * one unreadable config drops a LIVE SITE out of the fleet sweep entirely, with\n * the sweep still reporting success.\n *\n * A sentinel `repositoryName` means \"not configured YET\", so it moves to the\n * next candidate filename rather than ending the search. That matters because\n * the Prismic CLI's migration RENAMES slicemachine.config.json to\n * prismic.config.json, so a half-migrated repo can hold a stale sentinel in the\n * first file and its real configuration in the second. Returning null on the\n * first sight of a sentinel would drop that live site from the sweep on the\n * strength of a file nobody uses any more. Only when every candidate is\n * exhausted does a sentinel yield null.\n */\nexport async function readPrismicConfig(repoRoot: string): Promise<PrismicConfig | null> {\n for (const name of CONFIG_FILES) {\n let raw: string;\n try {\n raw = await readFile(join(repoRoot, name), \"utf-8\");\n } catch (e) {\n // ENOENT is the ONLY error that means \"this repo does not have this\n // file\". EACCES, EISDIR (a directory named slicemachine.config.json),\n // ELOOP and I/O errors all mean the file is THERE and we cannot read it —\n // and falling through on those would walk to the next candidate name and\n // then to `return null`, i.e. \"not a Prismic site\", silently removing a\n // live site from every sweep that calls this.\n if ((e as NodeJS.ErrnoException).code === \"ENOENT\") continue;\n throw new Error(`${name}: present but unreadable (${(e as Error).message})`, { cause: e });\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (e) {\n throw new Error(`${name}: invalid JSON (${(e as Error).message})`, { cause: e });\n }\n // A config file containing literal `null` (or `[]`, or a bare string) is\n // valid JSON, so it arrives here parsed. Reading `.repositoryName` off it\n // would throw a bare `Cannot read properties of null` naming neither the\n // file nor the repo — the same nameless-throw defect just fixed one layer\n // down in local.ts, and worse here because this runs per REPO in a fleet\n // sweep, where \"which one?\" is the operator's first question.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n const got = parsed === null ? \"null\" : Array.isArray(parsed) ? \"an array\" : typeof parsed;\n throw new Error(`${name}: not a JSON object (got ${got})`);\n }\n const cfg = parsed as { repositoryName?: unknown; libraries?: unknown };\n if (typeof cfg.repositoryName !== \"string\" || cfg.repositoryName.trim() === \"\") {\n throw new Error(`${name}: repositoryName is missing or not a string`);\n }\n // Trimmed on the way OUT, not just for validation. Untrimmed, \" espada \"\n // would go verbatim into the Types API `repository` header and 404 — which\n // presents to an operator as \"the Prismic repository does not exist\" rather\n // than \"your config has a stray space\", sending them to the wrong problem.\n const repositoryName = cfg.repositoryName.trim();\n\n // Placeholder = \"no content model of its own\" → try the next candidate\n // filename rather than declaring the repo non-Prismic. Checked before\n // `libraries` because a repo this pipeline never processes has no\n // configuration worth validating.\n //\n // `continue`, not `return null`, and that applies to EVERY placeholder: a\n // half-migrated repo can hold a stale placeholder in slicemachine.config.json\n // and its real configuration in prismic.config.json, so short-circuiting\n // here would drop a live site from the sweep on the strength of a file\n // nobody reads any more.\n if (PLACEHOLDER_REPOSITORY_NAMES.includes(repositoryName)) continue;\n\n // ABSENT defaults; PRESENT-but-unusable throws. Replacing a malformed value\n // with the fleet default is the same collapse this module refuses\n // everywhere else: if the site's real library is somewhere other than the\n // default, every slice it owns reads as missing, and the sweep reports\n // \"this site has no slices\" as fact rather than \"we could not tell\".\n //\n // `libraries: []` is deliberately LEGAL and is not the same thing — an\n // explicitly empty array is a statement (\"this site has no slice\n // libraries\"), not a malformation. Do not \"fix\" it into the default.\n if (\n cfg.libraries !== undefined &&\n !(Array.isArray(cfg.libraries) && cfg.libraries.every((l) => typeof l === \"string\"))\n ) {\n const got = Array.isArray(cfg.libraries)\n ? \"an array with a non-string entry\"\n : cfg.libraries === null\n ? \"null\"\n : typeof cfg.libraries;\n throw new Error(`${name}: libraries is present but is not an array of strings (got ${got})`);\n }\n const libraries =\n cfg.libraries === undefined ? [\"./src/lib/slices\"] : (cfg.libraries as string[]);\n return { repositoryName, libraries };\n }\n return null;\n}\n","/** Where a resolved token came from — printed by the tokens doctor so an\n * operator can see WHICH env var answered, never the value. */\nexport type ResolvedToken = { token: string; source: string };\n\n/**\n * The canonical env var holding a Prismic repository's write token:\n * `PRISMIC_TOKEN_<REPOSITORY_NAME>` upper-snaked.\n *\n * One rule, derivable from the repository name alone — no hand-maintained map\n * from site slug to env var. That matters because the two genuinely differ:\n * medical-solutions-of-texas's Prismic repository is `msot`, reddoor-website's\n * is `reddoor-la`, and beachfront-dentistry's is the hash `48bb12d1`. A draft\n * of the nightly workflow hardcoded PRISMIC_TOKEN_MEDICAL_SOLUTIONS_OF_TEXAS;\n * that secret would never have resolved, and the failure would have looked like\n * a credentials problem rather than a naming one.\n *\n * Verified across all 15 Prismic-configured fleet repos: every derived name is\n * a legal identifier and no two repositories collide onto one name.\n *\n * THROWS on a name with no alphanumeric characters. Such a name would collapse\n * to the bare prefix, so every malformed config would share ONE env var — two\n * sites silently reading one credential, which is precisely the cross-wiring\n * `allowGeneric: false` exists to prevent, reintroduced through the naming\n * rule. `readPrismicConfig` already rejects an empty repositoryName, so this is\n * unreachable through the normal path and is here to keep it that way.\n */\nexport function prismicTokenEnvName(repositoryName: string): string {\n const slug = repositoryName\n .toUpperCase()\n .replace(/[^A-Z0-9]+/g, \"_\")\n .replace(/^_+|_+$/g, \"\");\n if (slug === \"\") {\n throw new Error(\n `cannot derive a token env var from repositoryName ${JSON.stringify(repositoryName)}: ` +\n `it has no alphanumeric characters`,\n );\n }\n return `PRISMIC_TOKEN_${slug}`;\n}\n\n/**\n * Resolve the write token for one Prismic repository.\n *\n * `allowGeneric` is the mode switch and it matters. IN-REPO (CI) runs set it\n * true: the site's own Actions secret is `PRISMIC_WRITE_TOKEN`, the name every\n * site's code already reads, and there is exactly one Prismic repository in\n * scope. FLEET runs set it FALSE: a single generic token in the environment\n * while iterating 18 repositories would silently attach the wrong credential to\n * every site after the first.\n */\nexport function resolvePrismicToken(\n repositoryName: string,\n env: Record<string, string | undefined>,\n opts: { allowGeneric: boolean },\n): ResolvedToken | null {\n const canonical = prismicTokenEnvName(repositoryName);\n const names = opts.allowGeneric ? [canonical, \"PRISMIC_WRITE_TOKEN\"] : [canonical];\n for (const name of names) {\n const value = env[name]?.trim();\n if (value) return { token: value, source: name };\n }\n return null;\n}\n","import { lstat, readdir, readFile, stat } from \"node:fs/promises\";\nimport { join, posix, relative, sep } from \"node:path\";\nimport type { LocalEntry, ModelKind, PrismicModel } from \"./types.js\";\n\n/** Repo-relative, forward-slashed — the form used in PR comments and passed to\n * the target repo's prettier, both of which are POSIX-shaped even on Windows. */\nconst relPath = (repoRoot: string, full: string): string =>\n relative(repoRoot, full).split(sep).join(posix.sep);\n\n/**\n * Whether `full` is PROVEN not to exist — the ONLY condition under which this\n * reader is allowed to move on quietly.\n *\n * The polarity is the whole point: absence must be proved, never assumed. A\n * clean ENOENT is proof; every other outcome — a successful stat, EACCES on the\n * parent directory, ENOTDIR, an I/O error — answers \"not proven absent\", and\n * the caller then goes on to read the path and fails loudly with a real errno.\n * A helper that returned \"absent\" on ANY error (the obvious `try { … } catch {\n * return false }` shape) reintroduces exactly the failure this module exists to\n * prevent: an unreadable model becomes indistinguishable from a model that was\n * never there, CI reports \"in sync\", and the field is quietly missing in\n * Prismic — the same silence that has already cost live sites five fields.\n *\n * `lstat`, not `stat`, deliberately. `stat` follows symlinks, so a DANGLING\n * symlink answers ENOENT and would read as absent even though the repo plainly\n * contains an entry at that path. `lstat` sees the link itself, so a broken\n * link reads as present and the read that follows throws.\n */\nasync function isProvenAbsent(full: string): Promise<boolean> {\n try {\n await lstat(full);\n return false;\n } catch (e) {\n return (e as NodeJS.ErrnoException).code === \"ENOENT\";\n }\n}\n\n/**\n * Parse one model file that we have already established is NOT proven absent.\n *\n * Throws — never skips — on unreadable content, malformed JSON, or a missing\n * id: a model that cannot be read is INDISTINGUISHABLE from a model that does\n * not exist, and a silent skip would let CI report \"in sync\" while a field was\n * actually missing in Prismic — the five-field drop this pipeline exists to\n * prevent.\n *\n * That covers the \"present but not a regular file\" case by construction: a\n * DIRECTORY named `model.json` fails the read with EISDIR and throws here. That\n * is the intended outcome — it is a broken repo, not an absent model, and the\n * operator needs to see it rather than have the site's model silently vanish\n * from the comparison.\n */\nasync function readModel(repoRoot: string, full: string, kind: ModelKind): Promise<LocalEntry> {\n const rel = relPath(repoRoot, full);\n let raw: string;\n try {\n raw = await readFile(full, \"utf-8\");\n } catch (e) {\n throw new Error(`${rel}: cannot read model file (${(e as Error).message})`, { cause: e });\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (e) {\n throw new Error(`${rel}: invalid JSON (${(e as Error).message})`, { cause: e });\n }\n // `null`, `[]` and `\"a string\"` are all valid JSON, so they reach here having\n // parsed cleanly. Without this guard, a file containing literal `null` throws\n // a raw TypeError from the `.id` read below — the ONE throw in this file that\n // would name neither the file nor a cause, leaving an operator with\n // \"Cannot read properties of null\" and 200-odd model files to search.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n const got = parsed === null ? \"null\" : Array.isArray(parsed) ? \"an array\" : typeof parsed;\n throw new Error(`${rel}: model is not a JSON object (got ${got})`);\n }\n const model = parsed as Partial<PrismicModel>;\n if (typeof model.id !== \"string\" || model.id === \"\") {\n throw new Error(`${rel}: model has no string \"id\"`);\n }\n return { kind, id: model.id, model: model as PrismicModel, path: rel };\n}\n\n/**\n * The names of `dir`'s entries that are model DIRECTORIES, or [] when `dir`\n * itself is PROVEN absent.\n *\n * The two halves of that sentence are both load-bearing. A configured library\n * pointing nowhere (alamo-anatomy) must read as EMPTY — that is a config wart,\n * not a broken model, and failing the whole run over it would take a live site\n * out of the pipeline for a typo. But a directory that EXISTS and cannot be\n * listed must throw: reading it as empty would report every one of that site's\n * slices as absent, which downstream looks exactly like \"this site deleted all\n * its slices\". ENOTDIR is the concrete case worth surfacing — a library path\n * that points at a FILE is a config error, and answering [] would hide it.\n *\n * A SYMLINK to a directory counts as a directory. `readdir` reports it as\n * `isSymbolicLink()` and NOT `isDirectory()`, so the obvious\n * `.filter((d) => d.isDirectory())` drops a symlinked model directory with no\n * entry and no error — a present, perfectly readable model vanishing in\n * silence, which is the exact class this module exists to prevent. It would\n * also be internally inconsistent: at the FILE level this reader follows\n * symlinks happily (a symlinked `model.json` is read like any other file), so\n * refusing them one level up is an accident, not a policy.\n *\n * The polarity here is deliberately NOT the polarity of {@link isProvenAbsent},\n * and the difference is the difference between the two questions being asked:\n *\n * - At the FILE level we already know a model is supposed to be at that exact\n * path, so anything other than a clean ENOENT is an error.\n * - Here we are ENUMERATING candidates, and a non-directory entry is\n * legitimately not a candidate — a stray `README.md` in the slices folder\n * is not a broken model, and a symlink to a file is the same thing wearing\n * a hat. Both are skipped in silence.\n *\n * What we are never entitled to do is call an entry \"not a candidate\" when we\n * could not resolve it at all. A DANGLING symlink therefore throws rather than\n * skipping: it may well have been a model directory, and we cannot tell.\n *\n * Names are sorted so that read order — and therefore the file named \"first\" in\n * a duplicate-id error, and the order entries reach the PR comment — does not\n * depend on the filesystem's arbitrary directory ordering.\n */\nasync function subdirs(repoRoot: string, dir: string): Promise<string[]> {\n let entries;\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch (e) {\n if (await isProvenAbsent(dir)) return [];\n throw new Error(`${relPath(repoRoot, dir)}: cannot list directory (${(e as Error).message})`, {\n cause: e,\n });\n }\n\n const names: string[] = [];\n for (const d of entries) {\n if (d.isDirectory()) {\n names.push(d.name);\n continue;\n }\n // A plain file, socket, fifo — not a candidate, and not a problem.\n if (!d.isSymbolicLink()) continue;\n\n const full = join(dir, d.name);\n let target;\n try {\n // `stat`, which FOLLOWS the link — the one place in this file where\n // following is what we want, because the question is \"what is on the\n // other end\", not \"is something here\".\n target = await stat(full);\n } catch (e) {\n throw new Error(\n `${relPath(repoRoot, full)}: symlink cannot be resolved (${(e as Error).message}). ` +\n `A link we cannot follow may be a model directory — refusing to guess.`,\n { cause: e },\n );\n }\n if (target.isDirectory()) names.push(d.name);\n }\n return names.sort();\n}\n\n/**\n * Every model on disk: custom types from `customtypes/<id>/index.json`, slices\n * from `<library>/<Dir>/model.json` for each configured library.\n *\n * Slices are keyed by the model's own `id`, NOT the directory name — the two\n * differ across the fleet (gallerysonder's `ContentWidthMedia` holds\n * `video_block`), and keying on the directory would send Prismic ids it has\n * never seen.\n *\n * A subdirectory with no model file is skipped (a leftover component folder is\n * normal); anything present-but-unreadable throws. See {@link isProvenAbsent}\n * for why that line, and not \"any error means absent\", is where the skip stops.\n */\nexport async function localModels(repoRoot: string, libraries: string[]): Promise<LocalEntry[]> {\n const out: LocalEntry[] = [];\n\n const ctRoot = join(repoRoot, \"customtypes\");\n for (const d of await subdirs(repoRoot, ctRoot)) {\n const full = join(ctRoot, d, \"index.json\");\n if (await isProvenAbsent(full)) continue;\n out.push(await readModel(repoRoot, full, \"customtype\"));\n }\n\n // The same library listed twice — or listed as both \"./src/lib/slices\" and\n // \"src/lib/slices\" — is a config typo, not two libraries. Deduplicating on\n // the RESOLVED path keeps it a harmless wart (the same call as alamo-anatomy's\n // missing library) instead of a hard duplicate-id failure whose message would\n // nonsensically name one file as the collision partner of itself.\n for (const libRoot of new Set(libraries.map((lib) => join(repoRoot, lib)))) {\n for (const d of await subdirs(repoRoot, libRoot)) {\n const full = join(libRoot, d, \"model.json\");\n if (await isProvenAbsent(full)) continue;\n out.push(await readModel(repoRoot, full, \"slice\"));\n }\n }\n\n assertNoDuplicateIds(out);\n assertVariationsHaveIds(out);\n return out;\n}\n\n/**\n * Refuse a repo where two files of the SAME kind declare the same model id.\n *\n * This is possible on disk because a slice's directory name and its model `id`\n * are independent — gallerysonder's `ContentWidthMedia/` holds `video_block` —\n * so two directories can quietly claim one id. Undetected, it is a silent and\n * ARBITRARY push: `diffModels` processes both entries independently, so one can\n * land in `unchanged` while the other lands in `toUpdate` against the same\n * remote model, and the push sends whichever of the two directories sorts last.\n * (Sorting the listing made that stable, not correct — a stable coin flip is\n * still a coin flip, and nothing would tell the operator the other file was\n * ignored.) That is the silent-divergence class this module exists to prevent, so\n * it fails loudly here instead of being papered over in `diffModels` — the\n * collision is a real problem in the repo and only a human can say which file\n * is the intended one.\n *\n * Same id across DIFFERENT kinds is legal (custom types and slices are separate\n * Types API collections, and `diffModels` keys on kind+id), so the check is\n * scoped by kind.\n */\nfunction assertNoDuplicateIds(entries: LocalEntry[]): void {\n const seen = new Map<string, string>();\n for (const e of entries) {\n const k = `${e.kind}:${e.id}`;\n const first = seen.get(k);\n if (first !== undefined) {\n throw new Error(\n `duplicate ${e.kind} id \"${e.id}\": declared by both ${first} and ${e.path}. ` +\n `Two files cannot own one model — delete or rename one.`,\n );\n }\n seen.set(k, e.path);\n }\n}\n\n/**\n * Refuse a slice model whose variation lacks a string `id`.\n *\n * The Types API cannot accept such a model, so this is a load-time rejection of\n * something that would fail on push anyway — but WHERE it fails is the whole\n * value: here it names the file and the index, while on push Prismic answers\n * with an opaque 422 that names neither, against a repo the operator may not\n * have knowingly touched.\n *\n * It does NOT exist to protect `describeDiff`, which is safe on its own:\n * `diff.ts` keys an id-less variation by a positional `variations[i]` fallback\n * that is unique per side, precisely so two of them cannot collide there.\n *\n * Measured 2026-08-12: all 180 variations in the fleet's 132 slice models carry\n * an `id`, so this is defensive, not a live repair.\n *\n * That count — and every fleet count in this directory — is taken from each\n * repo's DEFAULT BRANCH (`origin/HEAD`) via git plumbing, never from the local\n * working tree. Six of the fifteen checkouts sit on feature branches at any\n * given moment, and one of them gained a commit mid-measurement, so a\n * working-tree count is not reproducible and drifted by 3 variations while\n * these very comments were being written. \"In scope\" excludes the starter\n * repos, whose sentinel `repositoryName` makes `readPrismicConfig` return null:\n * this pipeline never loads their models, so counting them would describe files\n * nothing reads.\n *\n * A model with no `variations` array is untouched — custom types have none at\n * all, so anything stricter here would reject every custom type in the fleet.\n */\nfunction assertVariationsHaveIds(entries: LocalEntry[]): void {\n for (const e of entries) {\n const variations = e.model.variations;\n if (!Array.isArray(variations)) continue;\n variations.forEach((v, i) => {\n const id = (v as { id?: unknown } | null)?.id;\n if (typeof id !== \"string\" || id === \"\") {\n throw new Error(\n `${e.path}: variation at index ${i} has no string \"id\". ` +\n `Prismic rejects such a model, and the diff cannot describe it.`,\n );\n }\n });\n }\n}\n","// The Prismic Custom Types API — the capability this whole design rests on, and\n// the one that was believed impossible. Proven 2026-08-12: GET /customtypes and\n// GET /slices returned 200 across five fleet repositories, POST\n// /customtypes/insert returned 201 on the-pinnacle, and the historical 403 that\n// produced the standing \"types can only push via Slice Machine\" rule reproduced\n// on exactly ONE stale token (gallerysonder / reddoor-la) — a property of that\n// credential, not of the API.\n//\n// There is deliberately NO delete function in this module. The API supports\n// DELETE /customtypes/{id} (verified 204, zero residue), but a stale local\n// checkout must never be able to remove a live content model, and the surest\n// way to guarantee that is for the capability not to exist in the code at all.\n// Deleting a model is an operator action taken in the Prismic dashboard.\n//\n// THE RULE FOR EVERY EDIT TO THIS FILE: no failure may become an empty array.\n// `remoteModels` returning [] means \"this Prismic repository has no models\",\n// which sends every local model to `toCreate`, empties `remoteOnly` so the\n// never-delete invariant guards nothing, and in --apply pushes the entire model\n// set at a repository that already holds good models. An empty result is a real\n// state (a brand-new repository) and must be reachable ONLY through a\n// successful call that genuinely returned nothing.\n//\n// Every `catch` below rethrows, with ONE deliberate exception: `errorBody`\n// returns a placeholder string when the response body cannot be read. It is\n// named here rather than left for a reader to discover, because this paragraph\n// is the rule future editors will apply and an unnamed exception reads as a\n// violation. It is safe for a reason that does not generalise: it runs only on\n// paths that are ALREADY throwing, so the worst it can do is degrade a message.\n// A catch that can influence what this module RETURNS has no such excuse.\nimport type { LocalEntry, ModelKind, PrismicModel, RemoteEntry } from \"./types.js\";\n\nexport const CUSTOM_TYPES_API = \"https://customtypes.prismic.io\";\n\n/** Types API collection path for each model kind. */\nconst COLLECTION: Record<ModelKind, string> = { customtype: \"customtypes\", slice: \"slices\" };\n\n/**\n * Per-request deadline. 15 s matches the two other JSON API clients here —\n * `src/audits/netlify-deploy.ts`'s `NETLIFY_FETCH_TIMEOUT_MS` and\n * `src/github/gh-rest.ts`'s `DEPENDABOT_FETCH_TIMEOUT_MS`. It is NOT a\n * fleet-wide constant: of the nine other `AbortSignal.timeout` call sites across\n * five files, seven use 10 s (`src/audits/browser.ts` ×5,\n * `src/audits/function-health.ts`, `src/audits/form-e2e.ts`) and only those two\n * named above use 15 s. Each is set for its own workload. 15 s is chosen here\n * because a model collection is small JSON, not because 15 is the house number.\n *\n * It is here because a hung connection is the ONLY failure mode with no error\n * to propagate, so it is the only one that would sit in a fleet sweep instead of\n * failing it. `fetch` takes no deadline of its own — `RequestInit` carries a\n * `signal` and nothing else — so the only bound on an unsignalled call is\n * undici's dispatcher-level `headersTimeout`, which defaults to 300 s. That is\n * a bound, but at fleet scale it is a sweep-killer rather than a safety net.\n *\n * A timeout on a WRITE is deliberately not set tight: it leaves an ambiguous\n * state, because Prismic may have applied the change before the deadline fired.\n * That ambiguity is recoverable — the next run re-diffs and sends an `update` —\n * but it is worth not provoking.\n */\nconst PRISMIC_TIMEOUT_MS = 15_000;\n\nconst authHeaders = (repo: string, token: string): Record<string, string> => ({\n repository: repo,\n Authorization: `Bearer ${token}`,\n});\n\n/** The name JSON gives the value, for an error message. `typeof null` is\n * \"object\", which would report a null body as an object and send an operator\n * looking for a key that was never there. Same shape as `local.ts` /\n * `config.ts` use for the identical question. */\nconst jsonTypeOf = (v: unknown): string =>\n v === null ? \"null\" : Array.isArray(v) ? \"an array\" : typeof v;\n\n/**\n * The response body as text, for an ERROR message only.\n *\n * This is the one `catch` in the file that returns a value instead of throwing,\n * and it is safe for a reason that must survive future edits: it runs only on\n * paths that are already throwing, so the worst it can do is degrade a message.\n * It exists because `await res.text()` inline in the `new Error(...)` argument\n * would, if the body stream itself failed, reject out of the throw expression —\n * discarding the status we went to the trouble of capturing and replacing a\n * \"403 on repository X\" with an opaque \"terminated\".\n */\nconst errorBody = async (res: Response, limit: number): Promise<string> =>\n res.text().then(\n (t) => t.slice(0, limit),\n (e: unknown) => `<response body unreadable: ${String(e)}>`,\n );\n\n/**\n * One fetch with a deadline attached, rethrowing with context.\n *\n * RETHROWS, never defaults — see the file header. The wrap exists because the\n * two errors that reach it name neither the repository nor the URL: undici's\n * network failure is the bare string \"fetch failed\", and an aborted request is\n * \"The operation was aborted due to timeout\". In a fleet sweep the operator's\n * first question is \"which site?\", and neither message answers it.\n */\nasync function request(\n url: string,\n init: RequestInit,\n repo: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<Response> {\n try {\n return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });\n } catch (e) {\n const what = `${init.method ?? \"GET\"} ${url} [repository: ${repo}]`;\n // `?.` and `instanceof`, not a bare `(e as Error).message`: a rejection is\n // not guaranteed to be an Error, and reading `.name` off a thrown `null`\n // would throw a second time FROM INSIDE this catch — replacing the real\n // failure with a bare TypeError naming nothing, which is the one outcome\n // this wrapper exists to prevent.\n const detail =\n (e as Error | null | undefined)?.name === \"TimeoutError\"\n ? `timed out after ${timeoutMs}ms`\n : `failed: ${e instanceof Error ? e.message : String(e)}`;\n throw new Error(`${what} ${detail}`, { cause: e });\n }\n}\n\nasync function getCollection(\n repo: string,\n token: string,\n kind: ModelKind,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<RemoteEntry[]> {\n const url = `${CUSTOM_TYPES_API}/${COLLECTION[kind]}`;\n const res = await request(url, { headers: authHeaders(repo, token) }, repo, fetchImpl, timeoutMs);\n if (!res.ok) {\n // EVERY non-2xx, 404 included. The plan's discrimination rule — \"404 is the\n // only status that means this model does not exist\" — is about the\n // SINGLE-model endpoint, which this module never calls. On a COLLECTION GET\n // a 404 means the repository or the route is not there, which is the\n // opposite of \"the repository has no models\": a typo'd repositoryName would\n // read as an empty Prismic and push every local model. Do not add a 404\n // branch here.\n //\n // Name the status AND the repository: the one failure mode seen in the wild\n // is a stale/wrong token, and \"403\" alone is what got generalised last time\n // into \"the API does not allow this\". The status is also attached to the\n // Error object itself (not just interpolated into the message) so a caller\n // can tell a dead token (401/403 — fix the secret) apart from a rejected\n // model (422 — fix the model) without parsing text. Same idiom used\n // elsewhere in this codebase: `Object.assign(new Error(...), { exitCode })`.\n throw Object.assign(\n new Error(`GET ${url} [repository: ${repo}] -> ${res.status} ${await errorBody(res, 200)}`),\n { status: res.status },\n );\n }\n // Read the text, then parse it — rather than `res.json()`, which conflates two\n // different failures under one message. A body that fails MID-STREAM is a\n // transport fault, not malformed JSON, and calling it a parse error sends the\n // operator to look at Prismic's response instead of the connection. Reading\n // once also means the body is never consumed twice: `res.text()` after a\n // failed `res.json()` throws on the second read and would replace the real\n // diagnosis with \"Body is unusable\". Same read-then-parse shape as\n // `local.ts`'s `readModel`.\n // EVERY throw from here down names the repository. The URL cannot identify\n // the site — it is the same string for all of them, because the repository\n // travels in a HEADER (see `authHeaders`) — so without this a fleet sweep\n // reports \"expected an array, got null\" with no way to tell which of the\n // fleet's sites produced it.\n const where = `GET ${url} [repository: ${repo}]`;\n let raw: string;\n try {\n raw = await res.text();\n } catch (e) {\n throw new Error(`${where}: ${res.status} but the body could not be read`, { cause: e });\n }\n // A 200 whose body is not JSON (an HTML error page from a proxy, a truncated\n // response) must not surface as an opaque SyntaxError with no context. Wrapped\n // with the first of the body, never caught-and-defaulted — see the\n // absent-vs-unreadable rule at the top of the plan.\n let body: unknown;\n try {\n body = JSON.parse(raw);\n } catch (e) {\n // `res.status`, not a hardcoded 200: this branch is reached on ANY 2xx, and\n // a message naming a status that was not received sends an operator looking\n // for a response that never existed.\n throw new Error(\n `${where}: ${res.status} but the body did not parse as JSON: ${raw.slice(0, 200)}`,\n { cause: e },\n );\n }\n if (!Array.isArray(body)) {\n throw new Error(`${where}: expected an array, got ${jsonTypeOf(body)}`);\n }\n return body.map((m, i) => {\n // THROW, never drop. Dropping an id-less remote entry is the absent-vs-\n // unreadable collapse pointed at the remote set, and it fails in the\n // destructive direction: the local counterpart then looks like `toCreate`\n // and --apply pushes over a model Prismic already holds, while an entry with\n // no local counterpart never reaches `remoteOnly` at all — invisible to the\n // one bucket that exists to report Prismic-only models.\n //\n // The trade is deliberate: a malformed entry fails this site's whole check\n // rather than silently corrupting it. If Prismic ever returns such an\n // entry, that is a real event an operator needs to see, not one to route\n // around.\n const id = (m as { id?: unknown } | null)?.id;\n if (typeof id !== \"string\" || id === \"\") {\n throw new Error(`${where}: entry at index ${i} has no string \"id\"`);\n }\n return { kind, id, model: m as PrismicModel };\n });\n}\n\n/** Every model registered in one Prismic repository, both collections.\n *\n * The two GETs are independent and run SEQUENTIALLY on purpose. `Promise.all`\n * would halve the per-repository wall clock, but at two small JSON requests per\n * site that is noise inside a nightly sweep that also clones and audits — and\n * the cost is not noise: when a failure hits BOTH collections (a dead token,\n * the case actually seen in the wild) the reported error becomes whichever\n * request lost the race, so the same broken credential reports a different\n * message run to run. Deterministic attribution is worth more here than the\n * seconds. Do not \"optimise\" this without a measurement showing the sweep is\n * actually bounded by it. */\nexport async function remoteModels(\n repo: string,\n token: string,\n fetchImpl: typeof fetch = fetch,\n timeoutMs: number = PRISMIC_TIMEOUT_MS,\n): Promise<RemoteEntry[]> {\n const customtypes = await getCollection(repo, token, \"customtype\", fetchImpl, timeoutMs);\n const slices = await getCollection(repo, token, \"slice\", fetchImpl, timeoutMs);\n return [...customtypes, ...slices];\n}\n\n/** Create or REPLACE one model in Prismic. `update` replaces the whole model —\n * which is why callers must send `withRemoteScreenshots(local, remote)` rather\n * than the raw file on disk.\n *\n * This function has no idea whether the model differs from what Prismic holds;\n * it sends whatever it is given. \"Never send an unchanged model\" is the\n * caller's invariant (`pushModels`), enforced by diffing first. */\nexport async function sendModel(\n repo: string,\n token: string,\n entry: Pick<LocalEntry, \"kind\" | \"id\" | \"model\">,\n action: \"insert\" | \"update\",\n fetchImpl: typeof fetch = fetch,\n timeoutMs: number = PRISMIC_TIMEOUT_MS,\n): Promise<void> {\n // Prismic keys the write on the id inside the BODY; `entry.id` only ever\n // reaches the error message and the push report. If the two disagreed, an\n // `update` would replace a different live model than every log line names.\n // `types.ts` says they cannot disagree — both constructors derive `id` from\n // the parsed model — but this is the point of no return, and the parameter\n // type is structural, so any caller can hand over a pair that was never built\n // by either constructor. Check it here rather than trust it.\n // `=== \"\"` is checked separately from the mismatch, and it is not redundant:\n // an entry with `id: \"\"` AND `model.id: \"\"` AGREES, so the mismatch test\n // alone waves it through and Prismic is sent a model with no id. `local.ts`\n // rejects an empty `model.id` on the way in for the same reason — but the\n // argument three lines up is that this parameter is structural and a caller\n // can hand-build a pair that never passed through `local.ts` at all, which\n // applies to the empty string exactly as much as to the mismatch.\n const bodyId = (entry.model as { id?: unknown }).id;\n if (typeof bodyId !== \"string\" || bodyId === \"\" || bodyId !== entry.id) {\n throw new Error(\n `refusing to ${action} ${entry.kind} \"${entry.id}\": the model body's id is ` +\n `${JSON.stringify(bodyId)}. Prismic writes the body's id, not the reported ` +\n `one, so this would modify the wrong model or none at all.`,\n );\n }\n const url = `${CUSTOM_TYPES_API}/${COLLECTION[entry.kind]}/${action}`;\n const res = await request(\n url,\n {\n method: \"POST\",\n headers: { ...authHeaders(repo, token), \"Content-Type\": \"application/json\" },\n body: JSON.stringify(entry.model),\n },\n repo,\n fetchImpl,\n timeoutMs,\n );\n if (!res.ok) {\n // Same reasoning as getCollection above: attach `status` to the Error so\n // pushModels can distinguish a dead write token from a bad model.\n throw Object.assign(\n new Error(\n `POST ${url} [${entry.kind} ${entry.id}] -> ${res.status} ${await errorBody(res, 300)}`,\n ),\n { status: res.status },\n );\n }\n}\n","// The one place a diff turns into writes, and therefore the one place the\n// design's safety properties have to be true rather than merely intended.\n//\n// TWO INVARIANTS, both proven by mutation in tests/prismic/models/push.test.ts\n// rather than asserted here:\n//\n// 1. NOTHING is ever sent for a `remoteOnly` model. A model Prismic holds and\n// the repo does not is REPORTED — onto `report.remoteOnlyReported`, by\n// identity only — and never acted on, because a stale local checkout must\n// never be able to remove a live content model. This module cannot delete\n// even if it wanted to — `remote.ts` has no delete function at all — but\n// \"no delete verb downstream\" is not the same claim as \"this module never\n// touches a remote-only model\", so it gets its own guard. Copying the ids\n// into the report is a READ of that bucket and does not weaken the\n// invariant, which is about what goes on the WIRE; the guard is stated\n// behaviourally (\"`send` was never called\") for exactly that reason.\n// 2. NOTHING is ever sent for an `unchanged` model. That is not an\n// optimisation. `withRemoteScreenshots` rewrites `variations[].imageUrl`\n// on models `canon()` calls identical, so routing `unchanged` through the\n// send path would rewrite every slice in the fleet on every run — a\n// permanent stream of no-op writes against live repositories.\n//\n// Both hold because `work` below is built from exactly two of the diff's four\n// buckets. The other two are absent by construction, not filtered out later.\nimport { withRemoteScreenshots } from \"./diff.js\";\nimport type { LocalEntry, ModelDiff, PrismicModel, PushReport } from \"./types.js\";\n\n/**\n * Sends one model. Injected so `pushModels` stays testable and so the fetch\n * layer, not this one, owns credentials — nothing here knows a repository name\n * or a token, which is also what keeps this module's only channel to Prismic\n * visible in its signature.\n *\n * `remote` is the model Prismic currently holds, `undefined` on an insert. It\n * arrives ALONGSIDE the already-merged entry rather than instead of it: the\n * entry's model has been through `withRemoteScreenshots` and is what goes on\n * the wire, while `remote` is the before-picture a sender can hand to\n * `describeDiff` for a log line. A sender must never put `remote` on the wire.\n */\nexport type SendFn = (\n entry: LocalEntry,\n remote: PrismicModel | undefined,\n action: \"insert\" | \"update\",\n) => Promise<void>;\n\nexport type PushOptions = {\n /** false = dry run: compute and report, send nothing. */\n apply: boolean;\n send: SendFn;\n /**\n * There is no `true`. The type is `false | undefined` on purpose: a stale\n * local checkout must never be able to remove a live content model, and the\n * compiler is a better guarantee of that than a comment. `remoteOnly` models\n * are reported for a human; deleting one is a dashboard action.\n */\n allowDelete?: false;\n};\n\n/**\n * The HTTP status carried by a rejection, or undefined when there is none.\n *\n * The property READ is inside the try, not merely the cast around it, because a\n * getter can throw: `Object.assign(new Error(), { get status() { throw … } })`\n * is a perfectly ordinary object to build. `sendModel` never produces one — it\n * throws an Error with a numeric `status` — but `send` is INJECTED, and the\n * population this module can promise nothing about (a retry wrapper, a batching\n * decorator, a test double) is precisely the population that hand-builds error\n * objects. Not hypothetical: before this existed, a throwing `status` getter\n * escaped `pushModels` entirely — pinned now by \"survives a thrown value whose\n * accessors throw\" in tests/prismic/models/push.test.ts.\n *\n * This is one of the two `catch`es in this file that SWALLOW rather than\n * rethrow, and they are named here rather than left to be discovered, on the\n * rule remote.ts states for `errorBody`: both run ONLY on a path that is already\n * reporting a failure, so the worst either can do is degrade a message. Throwing\n * from here would instead abandon the entire report — including the `sent` list\n * of models Prismic has ALREADY accepted — which is strictly worse than losing\n * one status field. A catch that can influence what this module returns for a\n * SUCCESSFUL model would have no such excuse; there is none.\n */\nconst statusOf = (e: unknown): number | undefined => {\n try {\n const s = (e as { status?: unknown } | null | undefined)?.status;\n return typeof s === \"number\" ? s : undefined;\n } catch {\n // A throwing getter tells us nothing about the status, which is exactly\n // what `undefined` means here.\n return undefined;\n }\n};\n\n/**\n * A rejection rendered for the report's `error` field, for ANY thrown value.\n *\n * Every obvious step can throw for a value that is neither null nor undefined,\n * and each shape below was run against the old one-line expression to confirm it\n * really escaped: `Object.create(null)` has no `toString` at all, so `String(e)`\n * throws \"Cannot convert object to primitive value\"; an object can define a\n * `toString` that throws; an Error can define a `message` getter that throws —\n * and `String(e)` on THAT one throws too, because `Error.prototype.toString`\n * reads `message`. Hence the placeholder at the end: it is reachable, not\n * decorative, and all four are pinned by the `it.each` in push.test.ts.\n *\n * See `statusOf` above for why swallowing is the right call in this one place.\n */\nconst messageOf = (e: unknown): string => {\n try {\n if (e instanceof Error) {\n const m = e.message;\n if (typeof m === \"string\") return m;\n }\n } catch {\n // A throwing `message` getter. Fall through — `String(e)` will very likely\n // throw as well, and the placeholder below is the honest answer.\n }\n try {\n return String(e);\n } catch {\n // No `toString`, or one that throws. Say so, rather than losing the report.\n return `<a thrown ${typeof e} that cannot be converted to a string>`;\n }\n};\n\n/**\n * Apply a diff to Prismic.\n *\n * ORDER MATTERS: every slice goes before every custom type. A custom type's\n * `Slices` field references slice ids by name, and Prismic rejects a type\n * referencing a slice it does not yet hold — the-tower-burbank's reconciliation\n * pushed 11 slices before 6 types for exactly this reason. It is not a\n * theoretical dependency: 23 of the fleet's 68 in-scope custom types declare a\n * non-empty slice zone, and every one of the 115 distinct slice ids they name\n * (counted per repo) resolves to a slice model in that SAME repo — so on a first\n * push, all 23 depend on slices landing first. Measured 2026-08-12 from each\n * repo's default branch via `git ls-tree`, never a working tree: eight of the\n * fifteen checkouts were on feature branches at the time, and they move.\n *\n * NO ORDER IS ATTEMPTED AMONG CUSTOM TYPES, and that is deliberate rather than\n * an oversight: 7 of the 68 carry a Link field constrained to a custom type, and\n * TWO of those point at THEMSELVES — reddoor-website's `project` and\n * vineyard-custom-homes' `project`, each through two separate fields. A\n * self-reference has no valid creation order, so no total order over custom\n * types exists in general — if Prismic ever does reject such an insert, no sort\n * could have saved it, and it surfaces as a `failed` entry naming the type\n * rather than as silence.\n *\n * The count is the less useful half of that. Both self-references are the\n * `project` model, which sites copy from one another, so the open question is\n * not \"will a second site ever do this\" — a second site already has — but what\n * this pipeline does the first time Prismic rejects one. (Re-measured 2026-08-12\n * under the same rule as the paragraph above, every repo's default branch\n * resolving to `origin/main`. An earlier revision of this comment said \"one\",\n * having counted reddoor-website and stopped.)\n *\n * FAILURE IS PER-MODEL: one rejected model is recorded and the run continues,\n * so a single bad model cannot silently strand the rest of a merge's changes\n * half-applied with no report of which half. A 401/403 is deliberately NOT\n * treated as fatal-for-the-run even though it certainly is one in practice: an\n * early exit would need a third bucket for \"not attempted\", and a `sent`/`failed`\n * pair that silently omits models is precisely the kind of report this pipeline\n * exists to stop producing. The cost is N identical 401 lines on a dead token —\n * noisy, honest, and `status` (below) says which of the two it is at a glance.\n */\nexport async function pushModels(diff: ModelDiff, opts: PushOptions): Promise<PushReport> {\n // Two buckets, never four. See invariants 1 and 2 in the file header — this\n // list IS the enforcement. The type system helps with one of them: a\n // `remoteOnly` entry is a `RemoteEntry` and has no `path`, so it cannot be\n // spread in here without someone fabricating one, which makes the mistake\n // conspicuous rather than impossible (the mutation that proves the guard fires\n // wrote `path: \"\"` and compiled). `unchanged` gets no help at all — it is a\n // `LocalEntry[]` and would slot straight in. Both are pinned by tests.\n const work: Array<{\n entry: LocalEntry;\n remote: PrismicModel | undefined;\n action: \"insert\" | \"update\";\n }> = [\n ...diff.toCreate.map((entry) => ({ entry, remote: undefined, action: \"insert\" as const })),\n ...diff.toUpdate.map(({ local, remote }) => ({\n entry: local,\n remote: remote.model,\n action: \"update\" as const,\n })),\n ];\n // Slices first — see the ORDER MATTERS note above. Returning 0 for a same-kind\n // pair leans on `sort` being stable (required since ES2019), which is what\n // keeps inserts ahead of updates within a kind and keeps the report's order\n // reproducible run to run.\n work.sort((a, b) => (a.entry.kind === b.entry.kind ? 0 : a.entry.kind === \"slice\" ? -1 : 1));\n\n // `mode` is the MODE, not the verdict. On a dry run `sent` is populated with\n // what WOULD go on the wire and nothing is sent, so no reader may treat `sent`\n // as \"Prismic accepted these\" without checking `mode` first.\n const report: PushReport = {\n mode: opts.apply ? \"apply\" : \"dry\",\n sent: [],\n failed: [],\n // Filled BEFORE the loop and identically in both modes, because it is a fact\n // about the COMPARISON rather than about what this run did — and because it\n // is the only thing that keeps an empty report from being ambiguous between\n // \"in sync\" and \"this checkout can no longer see its own slices\". See\n // `PushReport.remoteOnlyReported` for the failure it exists to name.\n remoteOnlyReported: diff.remoteOnly.map((e) => ({ kind: e.kind, id: e.id })),\n };\n\n for (const { entry, remote, action } of work) {\n if (!opts.apply) {\n report.sent.push({ kind: entry.kind, id: entry.id, action });\n continue;\n }\n try {\n // The merge is INSIDE the send path, not a caller's job: a push REPLACES\n // the whole model, and 9 of the fleet's 180 in-scope variations carry a\n // stale screenshot URL on disk (one in each of nine repos, measured\n // 2026-08-12). Sending the file verbatim would rot those previews in the\n // editor, and `canon()` drops `imageUrl` entirely — so the damage could\n // never show up as a diff on the next run either. Silent and permanent is\n // the combination this module is built to avoid.\n await opts.send(\n { ...entry, model: withRemoteScreenshots(entry.model, remote) },\n remote,\n action,\n );\n report.sent.push({ kind: entry.kind, id: entry.id, action });\n } catch (e) {\n // Carry the HTTP status (when there is one) so a caller can tell a dead\n // write token (401/403 — fix the secret) apart from a rejected model\n // (422 — fix the model). The nightly sweep must not conflate the two, and\n // Prismic's token expiry is undocumented, so 401 is not a rare branch.\n // `sendModel` attaches it to the Error object precisely so nobody has to\n // parse it back out of the message text.\n //\n // Read through `statusOf`/`messageOf`, never as `(e as Error).message`.\n // `send` is INJECTED, so this module can promise nothing about what it\n // throws — and EVERY step of the obvious version can itself throw, which\n // would escape `pushModels` and strand every model after this one with no\n // report at all, discarding even the `sent` list of models Prismic has\n // already accepted. That is far worse than the failure being reported. The\n // two helpers above enumerate the shapes and cite the verification.\n const status = statusOf(e);\n report.failed.push({\n kind: entry.kind,\n id: entry.id,\n error: messageOf(e),\n // Spread, so the key is ABSENT rather than present-and-undefined when\n // there was no status (a network failure has none). `PushReport` is\n // serialised into PR comments and Airtable; a blank `status` key reads\n // as \"there was one and it was empty\".\n ...(status === undefined ? {} : { status }),\n });\n }\n }\n return report;\n}\n","// The pull-down path: a model that exists ONLY in Prismic, brought into the repo\n// as a file. It is the safe answer to a `remoteOnly` model — the alternative\n// being deletion, which this design forbids outright — and it is the only module\n// here that writes into a LIVE CLIENT REPO. Human-invoked; never runs in CI.\n//\n// Which makes the failure to avoid the mirror image of the one the rest of this\n// module guards against. Everywhere else the question is \"did I really read\n// nothing?\"; here it is \"is this path really free?\" — and the two errors have\n// the same shape, because a write onto an occupied path destroys a file exactly\n// as silently as a failed read drops a model. Both answers must be PROVEN, never\n// assumed, and the proof is what `occupantId` below is.\nimport { mkdir, readFile, realpath, rename, writeFile } from \"node:fs/promises\";\nimport { dirname, join, posix, relative, sep } from \"node:path\";\nimport type { RemoteEntry } from \"./types.js\";\n\n/**\n * The ONLY capability this module takes for formatting — and it is deliberately\n * NOT a process spawner.\n *\n * `writeModelFile` used to take `SpawnFn`, which is an arbitrary-argv primitive:\n * with one in hand this module could run `rm -rf` over a live model, `git rm`\n * it, or `curl -X DELETE` at the Types API — none of which names a module, a\n * binding or an HTTP verb that a source guard can see. The guard's answer was a\n * sentinel counting direct calls to a callee spelled `spawn`, and a deny-list on\n * a SPELLING dies to `const run = spawn`, to a helper parameter, or to a\n * property; this file used the helper idiom three lines away.\n *\n * So the module holds the narrower thing instead: \"format these repo-relative\n * paths, in this repo, with this binary\". The argv is built by the caller's\n * binding — one line at the injection site — and this module never holds a\n * primitive that can run anything else. That removes the capability class rather\n * than narrowing the hole, which is why it was worth changing a signature for.\n *\n * Bind it with the recipe helper the fleet already uses:\n *\n * const format: FormatModelFile = (root, paths, opts) =>\n * formatWithPrettier(spawn, root, paths, opts);\n *\n * BEST-EFFORT BY CONTRACT, exactly like `formatWithPrettier`: resolve `false`,\n * never throw. `writeModelFile` defends against a binding that breaks that\n * anyway — the model is already on disk before this runs, and losing a pulled\n * model to a formatting failure is the worst outcome on offer.\n */\nexport type FormatModelFile = (\n repoRoot: string,\n relPaths: readonly string[],\n opts: { bin: string; timeoutMs: number },\n) => Promise<boolean>;\n\n/**\n * The characters a model id may contain, as an ALLOW-LIST.\n *\n * The id is the one value in this module that is not ours: it arrives from\n * Prismic and it is spliced straight into a filesystem path under a client\n * repo's root. A deny-list of the shapes that scare us (\"..\", \"/\") is the guard\n * that has failed three times in this project, defeated each time by a shape\n * nobody listed — so this enumerates what is permitted and refuses everything\n * else.\n *\n * Measured 2026-08-13 from every in-scope repo's `origin/main` via `git ls-tree`\n * (never a working tree — eight of the fifteen checkouts sit on feature branches\n * that move): all 200 model ids in the fleet — 68 custom types, 132 slices — use\n * only `[a-z_]`, with a single camelCase outlier (`contractorTestimonials`).\n * `-` and digits are permitted because Prismic accepts them and neither can\n * change what a path means; nothing else is.\n *\n * If Prismic ever accepts an id outside this set, this THROWS and the operator\n * creates the file by hand. That is a stop, not a silent misplacement, and a\n * stop is the correct outcome for \"I do not know where this belongs\".\n */\nconst ID_ALLOWED = /^[A-Za-z0-9_-]+$/;\n\n/**\n * A path segment we are willing to create inside a client repo, again as an\n * allow-list: it must START with a letter, a digit or an underscore.\n *\n * That one anchor is doing three separate jobs. It rejects `\"\"`, `.` and `..`\n * (so the returned path cannot climb out of the repo or address itself); it\n * rejects a leading `-`, which the target repo's prettier would read as a FLAG\n * rather than a filename; and it rejects a leading `.`, so a pull-down cannot\n * quietly populate a dot-directory nobody looks at in review.\n */\nconst SEGMENT_ALLOWED = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;\n\n/** `video_block` -> `VideoBlock`. Slice Machine's on-disk directory convention.\n * A model that exists only in Prismic has no local directory to reuse, so the\n * name has to be derived — and this is LOSSY (it drops every separator), which\n * is why {@link ID_ALLOWED} is applied to the raw id BEFORE this runs. Checking\n * only the output would wave `../../x` through as the innocuous `X`. */\nconst pascal = (id: string): string =>\n id\n .split(/[^A-Za-z0-9]+/)\n .filter(Boolean)\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(\"\");\n\n/**\n * Repo-relative path a pulled-down model should live at.\n *\n * Custom types are keyed by id on disk — all 68 in the fleet sit in a directory\n * named exactly `model.id` — so that half is a fact. The slice half is a GUESS:\n * the directory name and the model id are independent, and 6 of the fleet's 132\n * slice directories differ from `pascal(id)` today (caltex-landing and hedloc\n * hold `content_width_media` in `ContentWidth/`; gallerysonder holds\n * `video_block` in `ContentWidthMedia/`). The guess is unavoidable — a\n * remote-only model has no local directory — but it must never be allowed to\n * land on another model's file, which is `writeModelFile`'s job, not this one's.\n *\n * The returned string has TWO readers with different rules: `join(repoRoot, …)`\n * resolves it against the repo, and the target repo's prettier resolves it\n * against its own cwd as an argv. They agree only for a plain relative path —\n * an absolute or climbing path makes the write and the format touch DIFFERENT\n * files, with both reporting success. Hence the per-segment check at the end.\n *\n * That check is a STRING-SHAPE check and nothing more. It proves the path spells\n * a plain relative location; it cannot prove where that location resolves to,\n * because a symlinked component (`customtypes`, or the slice library) redirects\n * the whole subtree without changing a single character of this string. The\n * containment guarantee for the write itself is {@link assertResolvesInsideRepo}\n * in `writeModelFile`, which resolves it.\n */\nexport function modelFilePath(entry: Pick<RemoteEntry, \"kind\" | \"id\">, library: string): string {\n if (!ID_ALLOWED.test(entry.id)) {\n throw new Error(\n `refusing to derive a file path for ${entry.kind} id ${JSON.stringify(entry.id)}: ` +\n `a model id may contain only letters, digits, \"_\" and \"-\". ` +\n `Create the file by hand rather than letting this guess where it goes.`,\n );\n }\n\n let rel: string;\n if (entry.kind === \"customtype\") {\n rel = posix.join(\"customtypes\", entry.id, \"index.json\");\n } else {\n const lib = library.replace(/^\\.\\//, \"\").split(sep).join(posix.sep).trim();\n if (lib === \"\") {\n throw new Error(\n `refusing to place slice \"${entry.id}\": no slice library was given. ` +\n `An empty library is not the fleet default — config.ts treats \"libraries: []\" as ` +\n `\"this site has no slice library\" — so there is nowhere to put this model.`,\n );\n }\n const dir = pascal(entry.id);\n if (dir === \"\") {\n throw new Error(\n `refusing to place slice ${JSON.stringify(entry.id)}: it has no letters or digits, ` +\n `so it yields no directory name.`,\n );\n }\n rel = posix.join(lib, dir, \"model.json\");\n }\n\n const bad = rel.split(posix.sep).filter((s) => !SEGMENT_ALLOWED.test(s));\n if (bad.length > 0) {\n throw new Error(\n `refusing to write ${entry.kind} \"${entry.id}\" to ${JSON.stringify(rel)}: ` +\n `the path segment(s) ${JSON.stringify(bad)} are not a plain relative location inside ` +\n `the repo. Check the \"libraries\" entry in this site's Prismic config.`,\n );\n }\n return rel;\n}\n\nexport type WriteResult = { path: string; formatted: boolean };\n\n/**\n * The model id declared by whatever already occupies `full`, or `null` when that\n * path is PROVEN free.\n *\n * The polarity is the same one local.ts states and for the same reason, pointed\n * the other way: only a clean ENOENT proves a path is free. EACCES, ENOTDIR\n * (a file where a directory should be), EISDIR, ELOOP and I/O errors all mean\n * something is there, or that we cannot tell — and \"cannot tell\" must not be\n * spelled the same way as \"nothing there\" when the next statement overwrites it.\n *\n * A file that is there but does not parse, or parses without a string `id`,\n * throws rather than being treated as free: an unidentifiable file is precisely\n * the one we must not clobber. There is no reading of \"I could not understand\n * this file\" that justifies replacing it.\n */\nasync function occupantId(rel: string, full: string): Promise<string | null> {\n let raw: string;\n try {\n raw = await readFile(full, \"utf-8\");\n } catch (e) {\n if ((e as NodeJS.ErrnoException).code === \"ENOENT\") return null;\n throw new Error(\n `${rel}: cannot be read, so this pipeline cannot tell whether a model is already ` +\n `there (${(e as Error).message}). Refusing to write over what it cannot see.`,\n { cause: e },\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (e) {\n throw new Error(\n `${rel}: already exists and is not valid JSON (${(e as Error).message}). ` +\n `Refusing to overwrite a file this pipeline cannot identify.`,\n { cause: e },\n );\n }\n const id = (parsed as { id?: unknown } | null)?.id;\n if (typeof id !== \"string\" || id === \"\") {\n throw new Error(\n `${rel}: already exists but declares no string \"id\", so there is no way to tell ` +\n `whether it is the model being pulled down. Refusing to overwrite it.`,\n );\n }\n return id;\n}\n\n/**\n * `realpath` of the deepest ancestor of `p` that exists.\n *\n * Only ENOENT walks up — the same polarity `occupantId` uses, for the same\n * reason. EACCES, ELOOP (a symlink cycle) and ENOTDIR are \"I cannot tell where\n * this resolves\", and the answer to that must never be spelled the same way as\n * \"it resolves here\".\n */\nasync function realpathOfNearestExisting(p: string): Promise<string> {\n let cur = p;\n for (;;) {\n try {\n return await realpath(cur);\n } catch (e) {\n if ((e as NodeJS.ErrnoException).code !== \"ENOENT\") {\n throw new Error(`${cur}: cannot be resolved (${(e as Error).message})`, { cause: e });\n }\n const parent = dirname(cur);\n /* c8 ignore next 2 -- unreachable: repoRoot is an ancestor and realpaths. */\n if (parent === cur) throw new Error(`${p}: no ancestor of this path exists`, { cause: e });\n cur = parent;\n }\n }\n}\n\n/**\n * Refuse unless `dir` really resolves inside `repoRoot`.\n *\n * `modelFilePath`'s per-segment check proves the path is spelled as a plain\n * relative location. It cannot prove where it LANDS: make `customtypes` — or a\n * site's slice library — a symlink to a sibling directory and every segment\n * still reads as innocuous, `readFile` answers ENOENT at the leaf so the path\n * reads as PROVEN free, `mkdir -p` follows the link, and the exclusive create\n * succeeds. The model lands in the sibling, which in this fleet is another LIVE\n * CLIENT REPO, and the returned repo-relative path is a lie.\n *\n * This is deliberately ASYMMETRIC with local.ts, which reads models through\n * symlinks by design and must keep doing so. Reading through a symlink returns\n * a file the operator put there; writing through one puts a file somewhere the\n * operator never named. The two directions do not deserve the same rule, and\n * this rule is only ever applied to the write path.\n *\n * Nothing under `customtypes/` or a slice library is a symlink in any of the 15\n * in-scope repos (measured 2026-08-13 from each repo's `origin/main` via\n * `git ls-tree`: zero entries of mode 120000), so this is a latent hole rather\n * than a live one — which is exactly when it is cheap to close.\n */\nasync function assertResolvesInsideRepo(repoRoot: string, rel: string, dir: string): Promise<void> {\n const realRoot = await realpathOfNearestExisting(repoRoot);\n const real = await realpathOfNearestExisting(dir);\n if (real !== realRoot && !real.startsWith(realRoot + sep)) {\n throw new Error(\n `refusing to write ${rel}: it resolves to ${real}, which is outside this repo ` +\n `(${realRoot}). A symlinked path component redirects the write while every segment ` +\n `of the repo-relative path still looks ordinary — and every sibling of a repo in ` +\n `this fleet is another live client repo.`,\n );\n }\n}\n\n/**\n * Absolute path to the TARGET repo's OWN prettier, or `null` when it has none.\n *\n * This exists because the alternative — spawn and let resolution sort it out —\n * cannot tell the two answers apart, and the whole justification for formatting\n * through the target's binary instead of `--stdin-filepath` is that it is the\n * TARGET's. Measured 2026-08-13, `pnpm exec prettier` in a fixture repo with no\n * `node_modules`:\n *\n * - it first runs a dependency check and executes a full `pnpm install` in the\n * target — an unrequested mutation of a live client repo, and the thing that\n * would hang a pull-down on a slow or unreachable registry;\n * - in a repo that does not depend on prettier, it then falls through to the\n * CALLING repo's `node_modules/.bin` (which `pnpm reddoor-maint …` puts on\n * PATH), formats the client's file with a binary the client does not have,\n * and exits 0 — so `formatted: true` reports a format the target never did.\n *\n * `node_modules/.bin/prettier` under the repo root is the right probe rather\n * than a resolve-from-`repoRoot`: Node's algorithm walks UP, which would find\n * the maintenance repo's own prettier for any clone checked out beneath it —\n * the precise confusion this is here to end. All 15 in-scope repos declare\n * prettier as a root devDependency and pnpm materialises the shim there\n * (measured 2026-08-13).\n *\n * Every failure collapses to `null`, including EACCES: unlike a read on the\n * model path, \"I cannot tell\" here costs only `formatted: false`, which is the\n * conservative flag and the documented degraded path.\n */\nasync function targetPrettierBin(repoRoot: string): Promise<string | null> {\n try {\n // realpath doubles as the existence probe, so this module does not need a\n // `stat` capability on top of the one it already has — and it resolves the\n // shim, so a DANGLING `.bin/prettier` reads as absent rather than present.\n return await realpath(join(repoRoot, \"node_modules\", \".bin\", \"prettier\"));\n } catch {\n return null;\n }\n}\n\n/**\n * How long the target's prettier gets before the pull-down gives up on it.\n *\n * A stalled format wedges a human-invoked CLI with nothing printed and no\n * `formatted: false` — the one outcome worse than not formatting. 60s is this\n * repo's established budget for a CLI spawn that may touch the network\n * (`gh.ts` ×7, `deps-outdated`); it is deliberately far above remote.ts's 15s\n * HTTP budget, because this is process startup rather than one request, and far\n * below the 5-minute install budgets, because it formats ONE small JSON file.\n *\n * A timeout also makes the fleet's default spawn implementation detach the\n * child (`src/audits/util/spawn.ts` only sets `detached` when `timeoutMs` is\n * present), so the kill reaches prettier and not just a wrapper. This module\n * does not hold that spawn — see {@link FormatModelFile} — but the number it\n * passes is what decides that behaviour at the injection site.\n */\nconst PRETTIER_TIMEOUT_MS = 60_000;\n\n/**\n * Write one model into a repo and format it with THAT repo's own prettier.\n *\n * The formatting step is not cosmetic. The first pull-down PR of the 2026-08-12\n * reconciliation failed CI on `prettier --check` for\n * `customtypes/frozen_page/index.json` while `catalog_page/index.json` —\n * generated by the identical code path in the same run — passed. Prettier's\n * output for JSON is CONTENT-dependent, so there is no canonical\n * `JSON.stringify` shape that is safe fleet-wide; only the target repo's own\n * prettier knows the answer, and a pull-down PR that imposes OUR house style\n * instead buries the real change in a diff full of reformatting noise.\n *\n * Which is why the target's prettier is resolved POSITIVELY, by\n * {@link targetPrettierBin}, and run by absolute path. Spawning `pnpm exec\n * prettier` and reading the exit code cannot distinguish \"the target's prettier\n * formatted this\" from \"the target has none and the calling repo's prettier\n * guessed\" — both are 0 — and the second answer is the one this whole deviation\n * from `--stdin-filepath` exists to avoid.\n *\n * Formatting is best-effort by contract ({@link FormatModelFile} never throws,\n * and this refuses to take its word for it — see the call), so\n * the file is always written; `formatted: false` tells the caller to flag the PR\n * for a manual format check rather than losing the model. It is one bit and it\n * deliberately merges three causes — the target has no prettier, its prettier\n * exited non-zero, or it timed out — because the operator's next action is\n * identical for all three and the CLI prints the same `PRETTIER_FLAG_NOTE`. The\n * consequence is bounded and visible downstream: an unformatted file reds the\n * target repo's own `prettier --check` in CI, which is how the trap above was\n * found in the first place.\n *\n * One residual is worth naming rather than papering over: prettier's own\n * `--write` is not atomic, so a format killed by the timeout can in principle\n * leave the model half-rewritten. The window is prettier's, not ours, and it\n * opens only after the complete model is already on disk — the stall this\n * guards against (process startup) is before prettier writes anything.\n *\n * NOTHING else here is best-effort. Every way this can fail to write the RIGHT\n * file at the RIGHT path throws, because a pull-down that silently writes the\n * wrong file is indistinguishable from one that worked.\n */\nexport async function writeModelFile(\n format: FormatModelFile,\n repoRoot: string,\n entry: RemoteEntry,\n library: string,\n): Promise<WriteResult> {\n // `entry.id` picks the PATH; `entry.model.id` is what every later reader keys\n // on (local.ts reads the body, never the directory name). remote.ts derives\n // both from the same parsed model so they cannot disagree — and `sendModel`\n // re-checks it anyway at the other end of the pipeline, because the parameter\n // is structural and any caller can hand over a pair that no constructor built.\n // The same argument holds here, with a worse consequence: a mismatch files the\n // model under a name nothing reads, so the next run sees it as remote-only\n // again and pulls a SECOND copy — a loop that never converges, in silence.\n const bodyId = (entry.model as { id?: unknown }).id;\n if (typeof bodyId !== \"string\" || bodyId === \"\" || bodyId !== entry.id) {\n throw new Error(\n `refusing to write ${entry.kind} \"${entry.id}\": the model body's id is ` +\n `${JSON.stringify(bodyId)}. The path is derived from one and every reader of the ` +\n `file keys on the other, so this model would never be found again.`,\n );\n }\n\n const rel = modelFilePath(entry, library);\n const full = join(repoRoot, rel);\n const dir = dirname(full);\n\n // FIRST, before anything reads or creates: prove this path resolves inside the\n // repo. Ordering it here means a symlinked component is reported as what it is\n // rather than as whatever the sibling repo happens to hold — and it means the\n // pull-down never so much as reads a file outside the repo it was pointed at.\n await assertResolvesInsideRepo(repoRoot, rel, dir);\n\n // Proven free, or occupied by this exact model. Anything else — another\n // model's file, a file we cannot parse, a path we cannot read — throws above.\n const occupant = await occupantId(rel, full);\n if (occupant !== null && occupant !== entry.id) {\n throw new Error(\n `refusing to pull ${entry.kind} \"${entry.id}\" into ${rel}: that file already holds ` +\n `\"${occupant}\". A slice's directory name and its model id are independent — 6 of the ` +\n `fleet's 132 slice directories differ (measured 2026-08-13) — so the directory this ` +\n `derives can belong to a different model. Move or rename the existing model, or ` +\n `create this one by hand.`,\n );\n }\n\n // If the write below fails, this directory stays behind empty — and so, on the\n // refresh path, may one partial temp file. Both are deliberate and neither is\n // worth \"fixing\": undoing them needs a delete verb, and this module imports\n // none. The pull-down is the safe answer to a model CI may never delete, so it\n // does not get the ability to remove things in order to tidy up after itself.\n // Both leftovers are visible in `git status`, named in the error that produced\n // them, read by nothing, and BOUNDED — one empty directory and one `.tmp` per\n // model path, however many times it fails, with the next attempt overwriting\n // the temp and a successful one consuming it. A delete verb in this file would\n // be none of those things.\n try {\n await mkdir(dir, { recursive: true });\n } catch (e) {\n throw new Error(\n `${posix.dirname(rel)}: cannot create the directory for this model ` +\n `(${(e as Error).message})`,\n { cause: e },\n );\n }\n\n // Again, now that the directory exists. The check above resolved the deepest\n // ancestor that existed AT THE TIME — `mkdir -p` follows symlinks, so that\n // check is what stops a link from putting a directory inside a sibling repo;\n // this one resolves the leaf that will actually be written into. `mkdir` only\n // ever creates real directories, so the two agree unless something changed\n // underneath us, and this is the answer that counts because it is the last one\n // before the write.\n await assertResolvesInsideRepo(repoRoot, rel, dir);\n\n // Two spaces + a trailing newline is a BASELINE, not an opinion: prettier\n // below is the authority, and this only decides what a file looks like when\n // prettier could not run. Measured 2026-08-13 from each of the 15 in-scope\n // repos' `origin/main` via `git ls-tree`: 180 of the 200 committed model\n // files are 2-space indented and only 20 are tab-indented (erp-industrial and\n // gallerysonder, the only two repos that set `useTabs: true`; the other 13\n // either set no `useTabs` or ship no prettier config at all, and prettier's\n // default is spaces). A tab baseline reds `prettier --check` in 13 of 15\n // repos on exactly the degraded path this baseline is for.\n const body = JSON.stringify(entry.model, null, 2) + \"\\n\";\n\n if (occupant === null) {\n try {\n // `wx` — an exclusive create, so the kernel rechecks atomically what\n // `occupantId` could only observe a moment ago. That closes two holes at\n // once, and the second is not hypothetical: a DANGLING symlink answers\n // ENOENT on read (local.ts documents the same lie from the reading side),\n // so without `wx` this would happily write the model through the link to\n // wherever it points — outside the repo, if the link says so.\n //\n // This path deliberately does NOT go through the temp+rename below.\n // `rename` overwrites unconditionally; there is no exclusive variant of\n // it, so routing the free path through a rename would trade a proven\n // guarantee (nothing is destroyed, ever) for a lesser one (a new file is\n // never partial). `link()` would give both — it fails EEXIST if the name\n // is taken — but only at the cost of an `unlink` to clear the temp, and a\n // delete verb in this module is precisely what the module-wide capability\n // guard in index.test.ts exists to forbid. The free path has nothing to destroy,\n // so it does not need to buy the protection twice.\n await writeFile(full, body, { encoding: \"utf-8\", flag: \"wx\" });\n } catch (e) {\n if ((e as NodeJS.ErrnoException).code === \"EEXIST\") {\n throw new Error(\n `${rel}: nothing readable was at this path a moment ago and something is there now — ` +\n `a dangling symlink, or a concurrent write. Refusing to overwrite it.`,\n { cause: e },\n );\n }\n throw e;\n }\n } else {\n // The sanctioned same-id refresh — the ONE path in this module that may\n // legitimately destroy bytes in a live client repo, which is why it is the\n // one path that must never destroy them by accident.\n //\n // A plain `w` opens with O_TRUNC: any failure between the truncate and the\n // last byte — ENOSPC, EDQUOT, EIO, or the operator interrupting this\n // human-invoked CLI — leaves the live model as a fragment that is not valid\n // JSON. That wreckage is self-latching, because the fragment is exactly\n // what `occupantId` reads on the retry, so the retry refuses too; and it is\n // indistinguishable from this module's other failures, every one of which\n // provably leaves the repo untouched.\n //\n // Writing a complete temp file first and renaming it over the target makes\n // the replacement a single atomic step: the model path holds the old model\n // or the new one, never a fragment. Same directory, so it is the same\n // filesystem and `rename` cannot fail with EXDEV.\n // ONE deterministic temp path per model, opened with `w`. Both halves of\n // that are load-bearing, because this module has no delete verb and so can\n // never tidy up after itself.\n //\n // Deterministic, because a per-run unique name (`…<pid>-<n>.tmp`) means every\n // interrupted write leaves ANOTHER untracked file in a live client repo,\n // forever — ten interruptions, ten files for a human to find. One name per\n // model path bounds the mess at one file no matter how often it fails.\n //\n // `w` and NOT `wx`, and this must not be \"hardened\" later: with a\n // deterministic name, `wx` would let a single leftover temp block this model\n // from ever being pulled down again until a human intervened, converting an\n // accumulation nuisance into a permanent hard stop. `w` overwrites the stale\n // temp instead, so the next attempt heals the mess — and a SUCCEEDING attempt\n // removes it outright, because `rename` consumes the source name. That is\n // cleanup without a delete verb, which is the whole trick.\n //\n // The trade being made explicit: `wx` would also stop a concurrent run from\n // clobbering an in-flight temp. That is not a real scenario here — this is a\n // human-invoked CLI against a single checkout — and `rename` is atomic\n // regardless, so the worst a concurrency loss could do is publish one of two\n // complete models rather than a fragment.\n const tmp = `${full}.tmp`;\n const tmpRel = relative(repoRoot, tmp);\n try {\n await writeFile(tmp, body, { encoding: \"utf-8\", flag: \"w\" });\n } catch (e) {\n throw new Error(\n `${rel}: could not stage the replacement model (${(e as Error).message}). ` +\n `The model already in the repo is UNTOUCHED. A partial ${tmpRel} may be left ` +\n `behind — the next pull-down of this model overwrites it, and nothing reads it.`,\n { cause: e },\n );\n }\n try {\n // `rename` replaces the NAME, so if the model path is a symlink (to\n // somewhere inside this repo — anywhere else was refused above) it is\n // replaced by a regular file rather than written through. No entry under\n // `customtypes/` or a slice library is a symlink in any in-scope repo\n // (measured 2026-08-13), and were one to appear the swap shows up in\n // `git diff` as a 120000→100644 mode change rather than silently.\n await rename(tmp, full);\n } catch (e) {\n throw new Error(\n `${rel}: could not replace the model with the staged copy ` +\n `(${(e as Error).message}). The model already in the repo is UNTOUCHED and the ` +\n `staged copy is at ${tmpRel} — the next pull-down of this model overwrites it, ` +\n `and nothing reads it.`,\n { cause: e },\n );\n }\n }\n\n // Resolved, never resolved-by-PATH: see targetPrettierBin. No prettier of its\n // own means the formatter is never invoked at all — this module refuses to\n // hand a path to something it did not find under the TARGET's root.\n const bin = await targetPrettierBin(repoRoot);\n if (bin === null) return { path: rel, formatted: false };\n try {\n return {\n path: rel,\n formatted: await format(repoRoot, [rel], { bin, timeoutMs: PRETTIER_TIMEOUT_MS }),\n };\n } catch {\n // {@link FormatModelFile} is best-effort BY CONTRACT, and this does not take\n // the injection site's word for it. The model is already on disk; rejecting\n // here would report a failed pull-down for a file that is sitting in the\n // repo — the silent-wrong-report failure this module is written against,\n // pointed at its own caller.\n return { path: rel, formatted: false };\n }\n}\n","/** Directory entries that are NOT evidence of a checked-out working tree. A\n * directory containing only these is git metadata (or Finder litter) with no\n * repository checked out into it — see {@link noWorkingTreeFailure}. */\nconst NOT_A_WORKING_TREE: ReadonlySet<string> = new Set([\".git\", \".DS_Store\"]);\n\n/**\n * POSITIVE EVIDENCE OF A CHECKOUT, or the named reason there is none.\n *\n * A `readdir` that succeeds proves the DIRECTORY is readable. It does not prove\n * anything was checked out into it, and every verdict a Prismic command draws is\n * drawn from what is NOT in that directory — so without this, \"I could not\n * establish a checkout\" comes out as \"this repo has no Prismic in it\".\n *\n * The gap that makes it severe is durability. `cloneIfNeeded` accepts ANY\n * non-empty directory at the site's path as that site's checkout\n * (clone-if-needed.ts:138), and `git clone` creates `.git` FIRST and fills the\n * working tree afterwards — so a clone killed in between (its own 5-minute\n * timeout, a cancelled job, a reboot, a full disk) leaves `<workdir>/<slug>/`\n * holding `.git` alone, with `origin` already pointing at the right repo. That\n * directory is non-empty, so no clone is ever attempted again; every config read\n * inside it is ENOENT, so `readPrismicConfig` returns null; and the site then\n * reports \"not a Prismic site — skipped\", exit 0, ON EVERY RUN AFTER THE FIRST.\n * One interrupted clone silently retires that site's drift alarm, and a fleet of\n * them is a green sweep. In the rollout command it is the same directory, one\n * decision earlier: the site is quietly never offered the delivery workflow.\n *\n * THE REAL CAUSE IS ONE LAYER DOWN and is deliberately not fixed here:\n * `cloneIfNeeded`'s \"non-empty directory ⇒ this is the checkout\" rule, and its\n * identity guard, which RETURNS SILENTLY when it cannot verify\n * (clone-if-needed.ts:100 — a directory with no git origin is treated as\n * verified). Both are shared by nine fleet commands, so changing them from one\n * command would be an unreviewed fleet-wide behaviour change; that deserves its\n * own task. This guard makes the Prismic commands honest meanwhile — the answer\n * it refuses to give is the DANGEROUS one (\"no Prismic here\"), never the safe\n * one.\n *\n * It lives in `util/` rather than in either command because two copies of a\n * guard are two chances to fix only one of them: `prismic-models` (which reads)\n * and `prismic-ci` (which opens a PR against a repo) reach the identical\n * directory by the identical route.\n *\n * `.DS_Store` sits alongside `.git` because a fleet workdir on darwin acquires\n * one from Finder, and it is no more a working tree than `.git` is.\n */\nexport function noWorkingTreeFailure(\n repoRoot: string,\n entries: readonly string[],\n noEffect: string,\n): string | null {\n if (!entries.every((e) => NOT_A_WORKING_TREE.has(e))) return null;\n return (\n `this checkout has no working tree: ${repoRoot} holds` +\n ` ${entries.length === 0 ? \"nothing at all\" : `only ${entries.join(\", \")}`}.` +\n ` ${noEffect} — this is NOT \"no Prismic in this repo\"; nothing at all was established` +\n ` about this repo. A git clone killed part-way leaves exactly this (a .git and no files),` +\n ` and a fleet workdir REUSES any non-empty directory as that site's checkout, so it will` +\n ` never re-clone by itself. Delete ${repoRoot} and re-run.`\n );\n}\n"],"mappings":"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","names":["readFile","join","readFile","join","posix","relative","sep"]}
@@ -169,8 +169,111 @@ function templatesByName(which) {
169
169
  return ALL_TEMPLATES.filter((t) => which.includes(t.config));
170
170
  }
171
171
 
172
+ // src/recipes/sync-configs/renovate-action.ts
173
+ var DIGEST_RE = /^[0-9a-f]{40}$/;
174
+ function escapeRegExp(s) {
175
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
176
+ }
177
+ function stripComments(contents) {
178
+ return contents.replace(/^[ \t]*#[^\n]*$/gm, "");
179
+ }
180
+ var USES_LINE_RE = /^([ \t]*(?:- )?)uses:\s*([\w.-]+\/[\w.-]+)@(\S+)(?:[ \t]*#[^\r\n]*)?/gm;
181
+ function usesRefAndComment(contents, slug) {
182
+ const re = new RegExp(
183
+ `^[ \\t]*(?:- )?uses:\\s*${escapeRegExp(slug)}@(\\S+)([ \\t]*#[^\\r\\n]*)?`,
184
+ "m"
185
+ );
186
+ const m = contents.match(re);
187
+ if (!m) return null;
188
+ return { ref: m[1], comment: m[2] ?? "" };
189
+ }
190
+ function pinGap(slug, contents) {
191
+ const found = usesRefAndComment(contents, slug);
192
+ if (!found || !DIGEST_RE.test(found.ref)) {
193
+ return `${slug} is missing or not digest-pinned (needs @<40-hex-sha>) \u2014 this workflow mints a repo-write token, so a mutable tag ref is a supply-chain hole`;
194
+ }
195
+ return null;
196
+ }
197
+ var KNOWN_PINNED_ACTIONS = [
198
+ "renovatebot/github-action",
199
+ "actions/create-github-app-token",
200
+ "actions/checkout"
201
+ ];
202
+ var APP_AUTH_MARKERS = [
203
+ "vars.RENOVATE_APP_ID",
204
+ "secrets.RENOVATE_APP_PRIVATE_KEY",
205
+ "steps.app-token.outputs.token"
206
+ ];
207
+ function renovateActionGaps(contents) {
208
+ const gaps = [];
209
+ const withoutComments = stripComments(contents);
210
+ for (const slug of KNOWN_PINNED_ACTIONS) {
211
+ const gap = pinGap(slug, withoutComments);
212
+ if (gap) gaps.push(gap);
213
+ }
214
+ for (const m of withoutComments.matchAll(USES_LINE_RE)) {
215
+ const slug = m[2];
216
+ const ref = m[3];
217
+ if (KNOWN_PINNED_ACTIONS.includes(slug)) continue;
218
+ if (!DIGEST_RE.test(ref)) {
219
+ gaps.push(
220
+ `${slug}@${ref} is not digest-pinned (needs @<40-hex-sha>) \u2014 this workflow mints a repo-write token, so every action reference must be digest-pinned`
221
+ );
222
+ }
223
+ }
224
+ const missingAuth = APP_AUTH_MARKERS.filter((m) => !withoutComments.includes(m));
225
+ if (missingAuth.length > 0) {
226
+ gaps.push(
227
+ `App auth is not fully wired \u2014 missing ${missingAuth.join(", ")} (Renovate authenticates as the reddoor-renovate GitHub App, not an operator PAT)`
228
+ );
229
+ }
230
+ if (withoutComments.includes("RENOVATE_TOKEN")) {
231
+ gaps.push(
232
+ "contains RENOVATE_TOKEN \u2014 that is the retired operator-PAT identity the App migration replaced"
233
+ );
234
+ }
235
+ if (!/RENOVATE_USERNAME:\s*["']?reddoor-renovate\[bot\]/.test(withoutComments)) {
236
+ gaps.push("RENOVATE_USERNAME does not identify reddoor-renovate[bot]");
237
+ }
238
+ if (!/RENOVATE_GIT_AUTHOR:\s*["']?reddoor-renovate\[bot\]/.test(withoutComments)) {
239
+ gaps.push("RENOVATE_GIT_AUTHOR does not identify reddoor-renovate[bot]");
240
+ }
241
+ if (!/RENOVATE_REPOSITORIES:\s*["']?\$\{\{\s*github\.repository\s*\}\}["']?/.test(withoutComments)) {
242
+ gaps.push(
243
+ "RENOVATE_REPOSITORIES is not scoped to ${{ github.repository }} \u2014 this workflow must only ever touch its own repo"
244
+ );
245
+ }
246
+ if (!/cron:\s*["']?0 \*\/12 \* \* \*["']?/.test(withoutComments)) {
247
+ gaps.push(
248
+ `cron is not the twice-daily "0 */12 * * *" schedule \u2014 this cron IS the fleet's merge cadence (platform auto-merge is disabled fleet-wide, so Renovate merges from inside this run)`
249
+ );
250
+ }
251
+ if (!withoutComments.includes("workflow_dispatch")) {
252
+ gaps.push("missing workflow_dispatch trigger");
253
+ }
254
+ const hasPermissionsKey = withoutComments.includes("permissions:");
255
+ const hasContentsRead = /^[ \t]*contents:\s*["']?read["']?[ \t]*$/m.test(withoutComments);
256
+ if (!hasPermissionsKey || !hasContentsRead) {
257
+ gaps.push("permissions: does not grant contents: read");
258
+ }
259
+ return gaps;
260
+ }
261
+ function withRenovatePinsFrom(template, current) {
262
+ if (current === null) return template;
263
+ const currentWithoutComments = stripComments(current);
264
+ return template.replace(USES_LINE_RE, (full, prefix, slug) => {
265
+ const found = usesRefAndComment(currentWithoutComments, slug);
266
+ if (!found || !DIGEST_RE.test(found.ref)) return full;
267
+ return `${prefix}uses: ${slug}@${found.ref}${found.comment}`;
268
+ });
269
+ }
270
+ var RENOVATE_ACTION_CONFIG = "renovate-action";
271
+
172
272
  export {
173
273
  ALL_TEMPLATES,
174
- templatesByName
274
+ templatesByName,
275
+ renovateActionGaps,
276
+ withRenovatePinsFrom,
277
+ RENOVATE_ACTION_CONFIG
175
278
  };
176
- //# sourceMappingURL=chunk-73UFRZKY.js.map
279
+ //# sourceMappingURL=chunk-MLT4QY5X.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/sync-configs/templates.ts","../src/recipes/sync-configs/renovate-action.ts"],"sourcesContent":["import type { ConfigName } from \"../../types.js\";\n\nexport type ConfigTemplate = {\n config: ConfigName;\n path: string;\n contents: string;\n};\n\nconst eslint: ConfigTemplate = {\n config: \"eslint\",\n path: \"eslint.config.js\",\n contents: `import { createEslintConfig } from \"@reddoorla/maintenance/configs/eslint\";\nimport svelteConfig from \"./svelte.config.js\";\n\nexport default createEslintConfig({ svelteConfig });\n`,\n};\n\nconst prettier: ConfigTemplate = {\n config: \"prettier\",\n path: \".prettierrc.json\",\n contents: `{\n \"trailingComma\": \"all\",\n \"singleQuote\": false,\n \"printWidth\": 100,\n \"plugins\": [\"prettier-plugin-svelte\"]\n}\n`,\n};\n\nconst prettierIgnore: ConfigTemplate = {\n config: \"prettier-ignore\",\n path: \".prettierignore\",\n contents: `pnpm-lock.yaml\n.svelte-kit/\nbuild/\n.netlify/\ndist/\n`,\n};\n\nconst lighthouse: ConfigTemplate = {\n config: \"lighthouse\",\n path: \"lighthouserc.json\",\n contents: `${JSON.stringify(\n {\n $note:\n \"Generated by @reddoorla/maintenance sync-configs; edit src/configs/lighthouse.ts in the package instead.\",\n extends: \"@reddoorla/maintenance/configs/lighthouse\",\n },\n null,\n 2,\n )}\n`,\n};\n\nconst playwrightA11y: ConfigTemplate = {\n config: \"playwright-a11y\",\n path: \"playwright.config.ts\",\n contents: `export { default } from \"@reddoorla/maintenance/configs/playwright-a11y\";\n`,\n};\n\nconst svelte: ConfigTemplate = {\n config: \"svelte\",\n path: \"svelte.config.js\",\n contents: `import { createSvelteConfig } from \"@reddoorla/maintenance/configs/svelte\";\nimport adapter from \"@sveltejs/adapter-netlify\";\n\n/** @type {import('@sveltejs/kit').Config} */\nexport default createSvelteConfig({\n kit: { adapter: adapter({ edge: false, split: false }) },\n});\n`,\n};\n\n// There is deliberately NO ci.yml template. Every live fleet ci.yml carries\n// per-site values a static template cannot own (`netlify-site:`, plus\n// `permissions:` and `node-version:` inputs) — an exact-match heal against a\n// canonical byte template would strip them fleet-wide in green auto-mergeable\n// PRs (the \"armed clobber\" the 2026-08-02 architecture review flagged; it was\n// this file's `ci` entry). Ownership is split instead: the STARTER clone\n// provides each site's ci.yml shape at bootstrap, and Renovate's\n// github-actions manager bumps the pinned reusable-workflow ref per repo when\n// reddoorla/.github tags a new version. The caller job name `ci` + the\n// reusable workflow's job `ci` produce the check context \"ci / ci\" —\n// REQUIRED_CHECK in src/recipes/self-updating/index.ts depends on that naming.\n\nconst renovateAction: ConfigTemplate = {\n config: \"renovate-action\",\n path: \".github/workflows/renovate.yml\",\n contents: `name: renovate\non:\n schedule:\n # Twice daily. GitHub's platform auto-merge is disabled fleet-wide, so Renovate\n # performs its own merges from inside this run — which means this cron is the\n # MERGE cadence, not just the PR-creation cadence.\n - cron: 0 */12 * * *\n workflow_dispatch:\npermissions:\n contents: read\njobs:\n renovate:\n runs-on: ubuntu-latest\n steps:\n # Renovate authenticates as the reddoor-renovate GitHub App (installed\n # org-wide, \"All repositories\" — future repos are auto-covered), NOT as an\n # operator PAT. Why: the App is a distinct identity, so the audit trail\n # finally distinguishes bot from human (the 2026-07-26 unreviewed-major\n # forensics took hours precisely because it couldn't); the credential is\n # permission-scoped and centrally revocable instead of a fleet-write PAT;\n # and the installation gets its own API-quota pool instead of sharing the\n # operator's. The minted token is scoped to THIS repository only\n # (create-github-app-token's default when owner/repositories are omitted).\n # RENOVATE_APP_ID / RENOVATE_APP_PRIVATE_KEY are org-level (all-repos\n # visibility) — nothing to configure per repo.\n # Digest-pinned: this workflow mints a repo-write token, so a mutable tag\n # ref here is a supply-chain hole — a retagged \\`@v3\\`/\\`@v46\\` would run\n # attacker code with that token. Pin to the commit; Renovate bumps these.\n - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0\n id: app-token\n with:\n app-id: \\${{ vars.RENOVATE_APP_ID }}\n private-key: \\${{ secrets.RENOVATE_APP_PRIVATE_KEY }}\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n - uses: renovatebot/github-action@1a96852b0384df1837619d04c60b2d10d1f9ff08 # v46.1.21\n with:\n token: \\${{ steps.app-token.outputs.token }}\n env:\n RENOVATE_REPOSITORIES: \\${{ github.repository }}\n # Commit/PR identity = the App's bot user, so gitAuthor matches the\n # token identity (mismatched authors read as \"operator pushed this\").\n # 312185038 = the bot's user id (gh api 'users/reddoor-renovate[bot]' --jq .id).\n RENOVATE_USERNAME: reddoor-renovate[bot]\n RENOVATE_GIT_AUTHOR: reddoor-renovate[bot] <312185038+reddoor-renovate[bot]@users.noreply.github.com>\n`,\n};\n\nconst renovateConfig: ConfigTemplate = {\n config: \"renovate-config\",\n path: \"renovate.json\",\n contents: `{\n \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n \"extends\": [\"github>reddoorla/.github:renovate-config\"]\n}\n`,\n};\n\nconst netlify: ConfigTemplate = {\n config: \"netlify\",\n path: \"netlify.toml\",\n contents: `[build]\n command = \"pnpm build\"\n publish = \"build/\"\n functions = \"functions/\"\n\n[build.environment]\n NODE_VERSION = \"22\"\n COREPACK_INTEGRITY_KEYS = \"0\"\n\n# Baseline security headers for all responses. CSP is emitted per-response by\n# SvelteKit (see \\`kit.csp\\` in svelte.config.js) so it is intentionally omitted\n# here to avoid conflicting duplicates.\n[[headers]]\n for = \"/*\"\n [headers.values]\n Strict-Transport-Security = \"max-age=63072000; includeSubDomains; preload\"\n X-Content-Type-Options = \"nosniff\"\n X-Frame-Options = \"SAMEORIGIN\"\n Referrer-Policy = \"strict-origin-when-cross-origin\"\n Permissions-Policy = \"camera=(), microphone=(), geolocation=(), interest-cohort=()\"\n Cross-Origin-Opener-Policy = \"same-origin\"\n\n[[headers]]\n for = \"/favicon.png\"\n [headers.values]\n Cache-Control = \"public, max-age=31536000, immutable\"\n\n[[headers]]\n for = \"/_app/immutable/*\"\n [headers.values]\n Cache-Control = \"public, max-age=31536000, immutable\"\n`,\n};\n\nexport const ALL_TEMPLATES: ConfigTemplate[] = [\n eslint,\n prettier,\n prettierIgnore,\n lighthouse,\n playwrightA11y,\n svelte,\n renovateAction,\n renovateConfig,\n netlify,\n];\n\nexport function templatesByName(which: ConfigName[]): ConfigTemplate[] {\n return ALL_TEMPLATES.filter((t) => which.includes(t.config));\n}\n","import type { ConfigName } from \"../../types.js\";\n\n/**\n * `.github/workflows/renovate.yml` (the `renovate-action` template) is\n * compliance-checked, not byte-matched, for the same reason `svelte.config.js`\n * and `netlify.toml` already are (see `isSvelteConfigCompliant` /\n * `isNetlifyConfigCompliant` in `sync-configs.ts`): an exact overwrite is the\n * wrong operation whenever a file legitimately diverges from the template\n * without being wrong. For renovate.yml there are TWO such legitimate\n * divergences, both observed live on 2026-08-31 (issue #651):\n *\n * 1. Renovate bumps its own digest pins. The template pins\n * `renovatebot/github-action@1a96852b...` (# v46.1.21); a site Renovate\n * maintains moves that pin forward on its own (reddoor-starter was already\n * at `@e09d604f...` / v46.2.2). A byte-match recipe reads that as drift and\n * opens a PR that DOWNGRADES the pin — reverting Renovate's own\n * supply-chain update, the exact thing the template's own comment says\n * Renovate is supposed to do. Observed live: it opened\n * `reddoorla/reddoor-starter-blux#1`, closed unmerged.\n * 2. YAML scalar quoting. The template writes an unquoted twice-daily cron and\n * `RENOVATE_USERNAME: reddoor-renovate[bot]` unquoted; a site's prettier\n * may render both quoted instead. Both forms are prettier-clean (verified\n * against the repo's own prettier), so prettier never normalizes one\n * toward the other — once a site's copy diverges in quoting, a byte-match\n * recipe reports drift FOREVER.\n *\n * The fix, following the svelte.config/netlify.toml precedent exactly: stop\n * asking \"is this file byte-identical to the template?\" and ask \"does it\n * still satisfy the invariants we actually own?\". `renovateActionGaps`\n * returns that — a `string[]` of human-readable gaps, empty meaning\n * compliant and left alone, the same shape as `rulesetGaps` (src/github/rulesets.ts)\n * and `renovateGaps` (src/audits/protection-coverage.ts).\n *\n * When a file genuinely IS non-compliant (e.g. its cron changed) the recipe\n * heals it by writing the template — but writing the template verbatim would\n * itself re-introduce the downgrade bug above, overwriting the site's newer\n * digest pins with the template's older ones. `withRenovatePinsFrom` carries\n * the site's own (still-digest-pinned) refs forward onto the template before\n * it is written — see that function's own doc comment for exactly what\n * guarantee this is (and, as importantly, isn't).\n *\n * Every check below runs against a COMMENT-STRIPPED copy of the file (see\n * `stripComments`). A raw-text `.match()`/`.includes()` takes the FIRST hit\n * anywhere in the file, comments included — a canonical pin, cron, or job\n * body sitting in a `#` comment above the real (non-compliant) line reads as\n * compliant, and worse, `withRenovatePinsFrom` would scrape a sha out of a\n * comment like `# old: uses: actions/checkout@1111…1111 # v4` and carry a\n * *commented* pin forward into the healed file — a pin downgrade delivered by\n * the very recipe that exists to prevent pin downgrades.\n */\n\nconst DIGEST_RE = /^[0-9a-f]{40}$/;\n\nfunction escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Strip full-line `#` comments before any compliance check or ref extraction\n * runs, so a canonical-looking line that exists only in a comment can never\n * read as the real thing.\n *\n * Full-line only: a trailing `# vX.Y.Z` after a real `uses:` line is\n * meaningful (it's the human-readable version tag) and must survive — only\n * strip a line whose first non-whitespace character is `#`. This is safe\n * specifically because this workflow's YAML has no block scalars (`|`/`>`),\n * so there is no line in a compliant or non-compliant renovate.yml where a\n * leading `#` is literal string content rather than a comment.\n */\nfunction stripComments(contents: string): string {\n return contents.replace(/^[ \\t]*#[^\\n]*$/gm, \"\");\n}\n\n/** A `uses:` line, anchored to the start of a line (allowing only leading\n * indentation and an optional `- ` list marker before `uses:`) so a match\n * can only ever land on a real step, never on prose inside a comment that\n * happens to contain the word `uses:`. Always run against comment-stripped\n * text (see `stripComments`) — the anchor alone does not exclude comments,\n * since a full-line comment's content can itself start with `- uses:` at\n * matching indentation. */\nconst USES_LINE_RE = /^([ \\t]*(?:- )?)uses:\\s*([\\w.-]+\\/[\\w.-]+)@(\\S+)(?:[ \\t]*#[^\\r\\n]*)?/gm;\n\n/** The ref (and trailing `# vX.Y.Z` comment, if any) a `uses: <slug>@<ref>`\n * line carries in `contents`, or null if that action isn't referenced.\n * Callers must pass comment-stripped text — see `stripComments`. */\nfunction usesRefAndComment(\n contents: string,\n slug: string,\n): { ref: string; comment: string } | null {\n const re = new RegExp(\n `^[ \\\\t]*(?:- )?uses:\\\\s*${escapeRegExp(slug)}@(\\\\S+)([ \\\\t]*#[^\\\\r\\\\n]*)?`,\n \"m\",\n );\n const m = contents.match(re);\n if (!m) return null;\n return { ref: m[1]!, comment: m[2] ?? \"\" };\n}\n\n/** Gap text for one `uses:` action that must be present AND digest-pinned.\n * This workflow mints a repo-write token (the App-token step), so a mutable\n * tag ref on ANY step here is a supply-chain hole — a retagged `@v3`/`@v46`\n * would run attacker code with that token. (The three actions checked here\n * are the ones this workflow ships with by default; `renovateActionGaps`\n * separately widens this to ANY `uses:` line, so an action added later is\n * covered too.) `contents` must already be comment-stripped. */\nfunction pinGap(slug: string, contents: string): string | null {\n const found = usesRefAndComment(contents, slug);\n if (!found || !DIGEST_RE.test(found.ref)) {\n return `${slug} is missing or not digest-pinned (needs @<40-hex-sha>) — this workflow mints a repo-write token, so a mutable tag ref is a supply-chain hole`;\n }\n return null;\n}\n\n/** The three actions this workflow ships with by default and always requires,\n * present and digest-pinned. Checked individually (via `pinGap`, above) so a\n * gap can say which one of the three is the problem; the widen loop in\n * `renovateActionGaps` then covers every OTHER `uses:` line so a step added\n * later can't slip in on a mutable ref unnoticed. */\nconst KNOWN_PINNED_ACTIONS = [\n \"renovatebot/github-action\",\n \"actions/create-github-app-token\",\n \"actions/checkout\",\n];\n\nconst APP_AUTH_MARKERS = [\n \"vars.RENOVATE_APP_ID\",\n \"secrets.RENOVATE_APP_PRIVATE_KEY\",\n \"steps.app-token.outputs.token\",\n];\n\n/** Gaps in a site's `.github/workflows/renovate.yml`. Empty = compliant,\n * leave it alone — see the module doc comment above for why this is\n * compliance-checked rather than byte-matched. */\nexport function renovateActionGaps(contents: string): string[] {\n const gaps: string[] = [];\n const withoutComments = stripComments(contents);\n\n for (const slug of KNOWN_PINNED_ACTIONS) {\n const gap = pinGap(slug, withoutComments);\n if (gap) gaps.push(gap);\n }\n\n // Widen the pin check to every OTHER `uses:` line in the file — the doc\n // comment on `pinGap` says a mutable ref on ANY step is a supply-chain\n // hole; without this, a step added later (e.g. `pnpm/action-setup@v4`)\n // would pass clean just because it isn't one of the three known actions.\n // A local `uses: ./.github/actions/foo` path ref never matches — the regex\n // requires a literal `@<ref>`, which a bare path reference doesn't have.\n for (const m of withoutComments.matchAll(USES_LINE_RE)) {\n const slug = m[2]!;\n const ref = m[3]!;\n if (KNOWN_PINNED_ACTIONS.includes(slug)) continue; // already covered above\n if (!DIGEST_RE.test(ref)) {\n gaps.push(\n `${slug}@${ref} is not digest-pinned (needs @<40-hex-sha>) — this workflow mints a repo-write token, so every action reference must be digest-pinned`,\n );\n }\n }\n\n const missingAuth = APP_AUTH_MARKERS.filter((m) => !withoutComments.includes(m));\n if (missingAuth.length > 0) {\n gaps.push(\n `App auth is not fully wired — missing ${missingAuth.join(\", \")} (Renovate authenticates as the reddoor-renovate GitHub App, not an operator PAT)`,\n );\n }\n\n if (withoutComments.includes(\"RENOVATE_TOKEN\")) {\n gaps.push(\n \"contains RENOVATE_TOKEN — that is the retired operator-PAT identity the App migration replaced\",\n );\n }\n\n // Checked (and reported) as two independent gaps, not one folded string, so\n // a caller can tell which field actually failed.\n if (!/RENOVATE_USERNAME:\\s*[\"']?reddoor-renovate\\[bot\\]/.test(withoutComments)) {\n gaps.push(\"RENOVATE_USERNAME does not identify reddoor-renovate[bot]\");\n }\n if (!/RENOVATE_GIT_AUTHOR:\\s*[\"']?reddoor-renovate\\[bot\\]/.test(withoutComments)) {\n gaps.push(\"RENOVATE_GIT_AUTHOR does not identify reddoor-renovate[bot]\");\n }\n\n if (\n !/RENOVATE_REPOSITORIES:\\s*[\"']?\\$\\{\\{\\s*github\\.repository\\s*\\}\\}[\"']?/.test(withoutComments)\n ) {\n gaps.push(\n \"RENOVATE_REPOSITORIES is not scoped to ${{ github.repository }} — this workflow must only ever touch its own repo\",\n );\n }\n\n if (!/cron:\\s*[\"']?0 \\*\\/12 \\* \\* \\*[\"']?/.test(withoutComments)) {\n gaps.push(\n 'cron is not the twice-daily \"0 */12 * * *\" schedule — this cron IS the fleet\\'s merge cadence (platform auto-merge is disabled fleet-wide, so Renovate merges from inside this run)',\n );\n }\n\n if (!withoutComments.includes(\"workflow_dispatch\")) {\n gaps.push(\"missing workflow_dispatch trigger\");\n }\n\n // A `permissions:` key is present somewhere AND a `contents: read` line\n // (optionally quoted) exists somewhere — not anchored to top-level-first-key,\n // since this predicate doesn't actually need to assert that shape (a\n // job-level permissions block satisfies the same invariant), and requiring\n // `contents: read` to be the first entry under `permissions:` false-positived\n // on both quoting (`contents: \"read\"`) and ordering (`contents: read` listed\n // after another key, e.g. `pull-requests: write`).\n const hasPermissionsKey = withoutComments.includes(\"permissions:\");\n const hasContentsRead = /^[ \\t]*contents:\\s*[\"']?read[\"']?[ \\t]*$/m.test(withoutComments);\n if (!hasPermissionsKey || !hasContentsRead) {\n gaps.push(\"permissions: does not grant contents: read\");\n }\n\n return gaps;\n}\n\n/**\n * The canonical template with each action's `uses:` ref replaced by the ref\n * the site already has, when the site's is digest-pinned.\n *\n * Why: when a file IS genuinely non-compliant (say its cron changed) the\n * recipe heals it by writing the template — and writing the template verbatim\n * would put the template's OLDER pins over the site's newer ones, re-\n * introducing the downgrade this fix removes. So carry the site's pins\n * forward onto the (otherwise-canonical) healed file.\n *\n * This is PIN-NEUTRAL, not downgrade-avoiding: it substitutes the site's ref\n * whenever it is 40 hex characters, with no notion of \"newer\" — digests are\n * unorderable, so there is no way to tell from the strings alone. It never\n * changes a digest-pinned ref in either direction; some fleet sites carry a\n * pin OLDER than the template's and will heal onto their own older pin\n * (defensible — Renovate re-advances it on its own), so \"pin-neutral\" is the\n * accurate description, not \"downgrade-avoiding\".\n *\n * `current === null` (no existing file) returns the template unchanged — there\n * is nothing to carry forward. A non-digest ref on the site (a mutable tag) is\n * never substituted in, since that would carry the mutable-ref hole forward\n * instead of healing it. Likewise, a ref that appears only inside a `#`\n * comment in `current` is never carried forward (see `stripComments`) — a\n * commented sha must never be scraped and pinned into the healed file.\n */\nexport function withRenovatePinsFrom(template: string, current: string | null): string {\n if (current === null) return template;\n const currentWithoutComments = stripComments(current);\n return template.replace(USES_LINE_RE, (full, prefix: string, slug: string) => {\n const found = usesRefAndComment(currentWithoutComments, slug);\n if (!found || !DIGEST_RE.test(found.ref)) return full;\n return `${prefix}uses: ${slug}@${found.ref}${found.comment}`;\n });\n}\n\n/** The `ConfigName` for `.github/workflows/renovate.yml`. Exported once here\n * (rather than re-declared as a local constant in both `sync-configs.ts` and\n * `self-updating/index.ts`) so the two call sites can't drift apart. */\nexport const RENOVATE_ACTION_CONFIG: ConfigName = \"renovate-action\";\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  fetchGaUsers,
3
3
  fetchSearch
4
- } from "./chunk-J3WEZGIN.js";
4
+ } from "./chunk-ZRI5VRPG.js";
5
5
  import {
6
6
  announcementSiteExtras,
7
7
  resolveCopy
@@ -60,4 +60,4 @@ export {
60
60
  scoresFromRow,
61
61
  buildReportDataForSite
62
62
  };
63
- //# sourceMappingURL=chunk-ULHPALI5.js.map
63
+ //# sourceMappingURL=chunk-NDCOOI3V.js.map
@@ -369,4 +369,4 @@ export {
369
369
  getReportById,
370
370
  getReportHtml
371
371
  };
372
- //# sourceMappingURL=chunk-GQDDWYU2.js.map
372
+ //# sourceMappingURL=chunk-QBAAZY7W.js.map