@reddoorla/maintenance 0.95.1 → 0.97.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 (316) hide show
  1. package/README.md +41 -7
  2. package/dist/{announce-CLQYX5I4.js → announce-5ZD2SBGD.js} +39 -31
  3. package/dist/announce-5ZD2SBGD.js.map +1 -0
  4. package/dist/attachments-JFGFQRXQ.js +10 -0
  5. package/dist/{blux-4KNXUP2K.js → blux-NXQLMS4W.js} +1 -1
  6. package/dist/{bump-deps-C5G2EFBB.js → bump-deps-775VLDYB.js} +6 -6
  7. package/dist/{chunk-FUWFLAWQ.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
  9. package/dist/chunk-43SW3FGW.js.map +1 -0
  10. package/dist/{chunk-R53VL3RI.js → chunk-5CJVUPJU.js} +1 -1
  11. package/dist/{chunk-R53VL3RI.js.map → chunk-5CJVUPJU.js.map} +1 -1
  12. package/dist/{chunk-S7RYFUIK.js → chunk-6PGXKN6F.js} +2 -2
  13. package/dist/{chunk-HTABUCF4.js → chunk-6THEZQLA.js} +3 -3
  14. package/dist/chunk-A5QKNOXW.js +72 -0
  15. package/dist/chunk-A5QKNOXW.js.map +1 -0
  16. package/dist/chunk-ASLDCWBF.js +41 -0
  17. package/dist/chunk-ASLDCWBF.js.map +1 -0
  18. package/dist/{chunk-CWXTNPNZ.js → chunk-AYDQQBNG.js} +3 -3
  19. package/dist/chunk-AYDQQBNG.js.map +1 -0
  20. package/dist/{chunk-WVNDEYXL.js → chunk-BINFZQ2T.js} +57 -6
  21. package/dist/chunk-BINFZQ2T.js.map +1 -0
  22. package/dist/{chunk-2NQUCS7O.js → chunk-BIYQN7FH.js} +3 -1
  23. package/dist/{chunk-2NQUCS7O.js.map → chunk-BIYQN7FH.js.map} +1 -1
  24. package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
  25. package/dist/chunk-CXICCLNM.js.map +1 -0
  26. package/dist/{chunk-BYGPUUCT.js → chunk-DRNZVORX.js} +66 -30
  27. package/dist/chunk-DRNZVORX.js.map +1 -0
  28. package/dist/{chunk-LM2NHRCJ.js → chunk-DYTYPKFN.js} +4 -4
  29. package/dist/chunk-DYTYPKFN.js.map +1 -0
  30. package/dist/{chunk-DFKBTBO3.js → chunk-EN6CTBUN.js} +35 -6
  31. package/dist/chunk-EN6CTBUN.js.map +1 -0
  32. package/dist/{chunk-5EEMTJMT.js → chunk-FHFXJ3EM.js} +8 -6
  33. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  34. package/dist/{chunk-Z2K4JPQ3.js → chunk-FP6HOW6J.js} +10 -7
  35. package/dist/chunk-FP6HOW6J.js.map +1 -0
  36. package/dist/{chunk-2LYOAB2E.js → chunk-HSMN35LU.js} +2 -2
  37. package/dist/chunk-ICSRWW5Q.js +148 -0
  38. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  39. package/dist/{chunk-FSGICFXB.js → chunk-IDBPII6G.js} +17 -5
  40. package/dist/chunk-IDBPII6G.js.map +1 -0
  41. package/dist/{chunk-C3IQQKXL.js → chunk-IOTOOTII.js} +121 -1
  42. package/dist/{chunk-C3IQQKXL.js.map → chunk-IOTOOTII.js.map} +1 -1
  43. package/dist/{chunk-BCKEP5S6.js → chunk-JHJNNJLG.js} +2 -2
  44. package/dist/{chunk-TO7DJYLZ.js → chunk-L3S3QZXL.js} +178 -39
  45. package/dist/chunk-L3S3QZXL.js.map +1 -0
  46. package/dist/{chunk-WSJ7RO54.js → chunk-LAG6IP7Y.js} +23 -5
  47. package/dist/chunk-LAG6IP7Y.js.map +1 -0
  48. package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
  49. package/dist/chunk-LAJ3FF54.js.map +1 -0
  50. package/dist/{chunk-2ZAZ7ETT.js → chunk-LDHRIQUW.js} +32 -18
  51. package/dist/chunk-LDHRIQUW.js.map +1 -0
  52. package/dist/{chunk-6R56OKJZ.js → chunk-LF2TJN4X.js} +2 -2
  53. package/dist/chunk-LF2TJN4X.js.map +1 -0
  54. package/dist/{chunk-ZQV7UQNE.js → chunk-LUF52EIY.js} +2 -2
  55. package/dist/{chunk-5UINJNAJ.js → chunk-LZMAG73U.js} +34 -21
  56. package/dist/chunk-LZMAG73U.js.map +1 -0
  57. package/dist/{chunk-VSGMCCGQ.js → chunk-MMSMZC7S.js} +2 -2
  58. package/dist/chunk-MVKNU4QD.js +37 -0
  59. package/dist/chunk-MVKNU4QD.js.map +1 -0
  60. package/dist/{chunk-XPJQXSC4.js → chunk-P7323YVW.js} +27 -5
  61. package/dist/chunk-P7323YVW.js.map +1 -0
  62. package/dist/{chunk-KJRVMMM3.js → chunk-PARKXDXI.js} +36 -5
  63. package/dist/chunk-PARKXDXI.js.map +1 -0
  64. package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
  65. package/dist/{chunk-WMBLR5VA.js → chunk-PZZ6HM5T.js} +3 -3
  66. package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
  67. package/dist/chunk-QFSL2L2B.js.map +1 -0
  68. package/dist/chunk-RQJFGK3A.js +51 -0
  69. package/dist/chunk-RQJFGK3A.js.map +1 -0
  70. package/dist/{chunk-3Z6X3BHU.js → chunk-TC4GANMX.js} +53 -150
  71. package/dist/chunk-TC4GANMX.js.map +1 -0
  72. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  73. package/dist/chunk-TEMQPSCM.js.map +1 -0
  74. package/dist/chunk-TJLKWQOX.js +52 -0
  75. package/dist/chunk-TJLKWQOX.js.map +1 -0
  76. package/dist/{chunk-CJ2RMTT7.js → chunk-TV76A6GN.js} +2 -2
  77. package/dist/{chunk-KZRR4NFG.js → chunk-TVZ6QCOE.js} +3 -3
  78. package/dist/{chunk-BVEDKMNG.js → chunk-U2ZI6G7H.js} +29 -11
  79. package/dist/chunk-U2ZI6G7H.js.map +1 -0
  80. package/dist/chunk-UPE6D4AT.js +69 -0
  81. package/dist/chunk-UPE6D4AT.js.map +1 -0
  82. package/dist/{chunk-R5IMFAFR.js → chunk-VMGHQUTV.js} +7 -16
  83. package/dist/chunk-VMGHQUTV.js.map +1 -0
  84. package/dist/chunk-VQMAAQ5M.js +53 -0
  85. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  86. package/dist/{chunk-AVEQUR44.js → chunk-WMF5S2MB.js} +60 -17
  87. package/dist/chunk-WMF5S2MB.js.map +1 -0
  88. package/dist/{chunk-SVAAONYB.js → chunk-WQLNUEZL.js} +12 -2
  89. package/dist/{chunk-SVAAONYB.js.map → chunk-WQLNUEZL.js.map} +1 -1
  90. package/dist/{chunk-XN37VTDB.js → chunk-X56LAKPR.js} +3 -3
  91. package/dist/chunk-X56LAKPR.js.map +1 -0
  92. package/dist/{chunk-ZCL7C5LH.js → chunk-Y6FCXRIC.js} +1661 -387
  93. package/dist/chunk-Y6FCXRIC.js.map +1 -0
  94. package/dist/{chunk-2SLED3NB.js → chunk-YDZ7XGPZ.js} +1 -1
  95. package/dist/{chunk-2SLED3NB.js.map → chunk-YDZ7XGPZ.js.map} +1 -1
  96. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  97. package/dist/chunk-YJBSB4LP.js.map +1 -0
  98. package/dist/{chunk-BI2VI6EM.js → chunk-YXWBM26P.js} +2 -2
  99. package/dist/{chunk-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
  100. package/dist/chunk-Z7FDJ35L.js.map +1 -0
  101. package/dist/cli/bin.js +111 -51
  102. package/dist/cli/bin.js.map +1 -1
  103. package/dist/cli/commands/audit.d.ts +33 -3
  104. package/dist/cli/commands/audit.js +28 -25
  105. package/dist/cli/commands/audit.js.map +1 -1
  106. package/dist/client-DGTO65WB.js +11 -0
  107. package/dist/configs/playwright-a11y.d.ts +11 -1
  108. package/dist/configs/playwright-a11y.js +3 -1
  109. package/dist/{convert-to-pnpm-YTFREUJK.js → convert-to-pnpm-KLWA53N5.js} +6 -6
  110. package/dist/{db-XKZYDCKM.js → db-II5WAK4D.js} +45 -20
  111. package/dist/db-II5WAK4D.js.map +1 -0
  112. package/dist/deadletter-G4M3HSVM.js +17 -0
  113. package/dist/{digest-7UBGUBRK.js → digest-CWKK2DMR.js} +56 -32
  114. package/dist/digest-CWKK2DMR.js.map +1 -0
  115. package/dist/{digest-collectors-DJFN5ZDK.js → digest-collectors-CYTN2EUX.js} +17 -12
  116. package/dist/{digest-state-3KHZF7GD.js → digest-state-ZDPKR7MW.js} +18 -2
  117. package/dist/digest-state-ZDPKR7MW.js.map +1 -0
  118. package/dist/ensure-site-2QZHFVQ3.js +70 -0
  119. package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
  120. package/dist/ensure-site-JEKV2ODE.js +105 -0
  121. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  122. package/dist/{ensure-site-OAAE5YE2.js → ensure-site-OO4UPWNQ.js} +49 -44
  123. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  124. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  125. package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-RJROYXKO.js} +19 -7
  126. package/dist/{forms-notify-target-UC6PF3AE.js → forms-notify-target-X6UEOI2M.js} +19 -15
  127. package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
  128. package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
  129. package/dist/{github-signals-TX5FPYP6.js → github-signals-QIRTHHET.js} +22 -17
  130. package/dist/github-signals-QIRTHHET.js.map +1 -0
  131. package/dist/{header-image-PSPT7CUM.js → header-image-H7ZJAV26.js} +15 -18
  132. package/dist/header-image-H7ZJAV26.js.map +1 -0
  133. package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-BJ6BKRHF.js} +8 -7
  134. package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-BJ6BKRHF.js.map} +1 -1
  135. package/dist/{health-mirror-XJAKA3EY.js → health-mirror-KU3G7V2Y.js} +10 -10
  136. package/dist/{import-airtable-TAKSN4NV.js → import-airtable-UBF5GYAL.js} +3 -3
  137. package/dist/index.d.ts +158 -419
  138. package/dist/index.js +91 -46
  139. package/dist/index.js.map +1 -1
  140. package/dist/{init-FFE4FCO2.js → init-GOWDA5QS.js} +19 -18
  141. package/dist/{init-FFE4FCO2.js.map → init-GOWDA5QS.js.map} +1 -1
  142. package/dist/{launch-UD36AHUN.js → launch-7N3X2MXB.js} +229 -79
  143. package/dist/launch-7N3X2MXB.js.map +1 -0
  144. package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
  145. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  146. package/dist/match-harness-GBCDSVPS.js +417 -0
  147. package/dist/match-harness-GBCDSVPS.js.map +1 -0
  148. package/dist/migrate-XZ7U5DFC.js +7 -0
  149. package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +4 -4
  150. package/dist/{onboard-TM2KESGF.js → onboard-JQQIMQW7.js} +6 -6
  151. package/dist/{orchestrate-HYVQPRCE.js → orchestrate-L2A5BZFY.js} +13 -10
  152. package/dist/parity-2JGVUCZD.js +14 -0
  153. package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
  154. package/dist/{preflight-RYIP54NY.js → preflight-O7Q6FWHU.js} +35 -11
  155. package/dist/preflight-O7Q6FWHU.js.map +1 -0
  156. package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-NAPWOXXD.js} +39 -9
  157. package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
  158. package/dist/{prismic-models-P6FWWVRS.js → prismic-models-LL6ORTQ2.js} +20 -17
  159. package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
  160. package/dist/prospect/types.d.ts +72 -0
  161. package/dist/prospect/types.js +1 -1
  162. package/dist/{prospect-audit-PRHU53HS.js → prospect-audit-FMPGQIRQ.js} +16 -7
  163. package/dist/prospect-audit-FMPGQIRQ.js.map +1 -0
  164. package/dist/{prospect-audits-R3LWF6U7.js → prospect-audits-IZVQY3OQ.js} +4 -2
  165. package/dist/protection-audit-YXJKFU6O.js +413 -0
  166. package/dist/protection-audit-YXJKFU6O.js.map +1 -0
  167. package/dist/recipes/sync-configs.js +3 -3
  168. package/dist/{render-YGNGTX3W.js → render-MHUUHPLN.js} +36 -9
  169. package/dist/render-MHUUHPLN.js.map +1 -0
  170. package/dist/{render-from-row-IFSBHWDH.js → render-from-row-75JTED2Z.js} +6 -4
  171. package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-2IHAZDVA.js} +14 -9
  172. package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
  173. package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
  174. package/dist/replay-4FVFOMHH.js.map +1 -0
  175. package/dist/{report-LQS4ECTM.js → report-IAHXN5CW.js} +103 -57
  176. package/dist/report-IAHXN5CW.js.map +1 -0
  177. package/dist/{report-mirror-HDTOJE64.js → report-mirror-4QM57LMS.js} +34 -12
  178. package/dist/report-mirror-4QM57LMS.js.map +1 -0
  179. package/dist/roster-GDNSIRAK.js +19 -0
  180. package/dist/roster-GDNSIRAK.js.map +1 -0
  181. package/dist/{schema-A4WCPIF4.js → schema-KWUQEZ5O.js} +1 -1
  182. package/dist/schema-KWUQEZ5O.js.map +1 -0
  183. package/dist/{self-updating-MWXIZLL7.js → self-updating-ZUWPGYUP.js} +8 -7
  184. package/dist/{self-updating-MWXIZLL7.js.map → self-updating-ZUWPGYUP.js.map} +1 -1
  185. package/dist/{selftest-VXTHRPRN.js → selftest-G4W3LIBQ.js} +21 -17
  186. package/dist/{selftest-VXTHRPRN.js.map → selftest-G4W3LIBQ.js.map} +1 -1
  187. package/dist/site-create-ZU3ACBTO.js +45 -0
  188. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  189. package/dist/site-lookup-DTOLJ4EA.js +27 -0
  190. package/dist/site-lookup-DTOLJ4EA.js.map +1 -0
  191. package/dist/{site-mirror-WON4FTJ2.js → site-mirror-WR7ACXOV.js} +26 -12
  192. package/dist/site-mirror-WR7ACXOV.js.map +1 -0
  193. package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-WAUPI6EJ.js} +8 -7
  194. package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-WAUPI6EJ.js.map} +1 -1
  195. package/dist/{submissions-OVZPVBYG.js → submissions-2SHCFOKD.js} +12 -7
  196. package/dist/submissions-2SHCFOKD.js.map +1 -0
  197. package/dist/{submissions-3I3GAQAX.js → submissions-JDZP35JM.js} +49 -9
  198. package/dist/submissions-JDZP35JM.js.map +1 -0
  199. package/dist/{svelte-codemods-WSV2FGNG.js → svelte-codemods-VOLOKKFI.js} +6 -6
  200. package/dist/{sync-K3PKHVLH.js → sync-F4USL4K5.js} +6 -6
  201. package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-OXSWKVZ2.js} +6 -6
  202. package/dist/turso-KQKBBQCK.js +24 -0
  203. package/dist/turso-KQKBBQCK.js.map +1 -0
  204. package/dist/{upgrade-VTD6V4HS.js → upgrade-EVAL3JTO.js} +6 -6
  205. package/dist/util/git.d.ts +49 -10
  206. package/dist/util/git.js +1 -1
  207. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  208. package/dist/{websites-TB4TCAWM.js → websites-Y6FN2XFD.js} +27 -20
  209. package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-TI3JMWSY.js} +7 -5
  210. package/package.json +3 -2
  211. package/dist/airtable-JGCPS7JH.js +0 -9
  212. package/dist/announce-CLQYX5I4.js.map +0 -1
  213. package/dist/attachments-NLZIJ5NU.js +0 -9
  214. package/dist/chunk-2ZAZ7ETT.js.map +0 -1
  215. package/dist/chunk-3Z6X3BHU.js.map +0 -1
  216. package/dist/chunk-5EEMTJMT.js.map +0 -1
  217. package/dist/chunk-5GUJ36TL.js +0 -53
  218. package/dist/chunk-5GUJ36TL.js.map +0 -1
  219. package/dist/chunk-5UINJNAJ.js.map +0 -1
  220. package/dist/chunk-6R56OKJZ.js.map +0 -1
  221. package/dist/chunk-AVEQUR44.js.map +0 -1
  222. package/dist/chunk-BVEDKMNG.js.map +0 -1
  223. package/dist/chunk-BYGPUUCT.js.map +0 -1
  224. package/dist/chunk-CWXTNPNZ.js.map +0 -1
  225. package/dist/chunk-DFKBTBO3.js.map +0 -1
  226. package/dist/chunk-F3JWOPTG.js.map +0 -1
  227. package/dist/chunk-FSGICFXB.js.map +0 -1
  228. package/dist/chunk-GASBX52O.js +0 -12
  229. package/dist/chunk-GASBX52O.js.map +0 -1
  230. package/dist/chunk-IY6256GK.js.map +0 -1
  231. package/dist/chunk-KJRVMMM3.js.map +0 -1
  232. package/dist/chunk-LM2NHRCJ.js.map +0 -1
  233. package/dist/chunk-QF6IQQFP.js.map +0 -1
  234. package/dist/chunk-R5IMFAFR.js.map +0 -1
  235. package/dist/chunk-TO7DJYLZ.js.map +0 -1
  236. package/dist/chunk-TR3HFH6J.js.map +0 -1
  237. package/dist/chunk-WJMPCVOR.js.map +0 -1
  238. package/dist/chunk-WSJ7RO54.js.map +0 -1
  239. package/dist/chunk-WVNDEYXL.js.map +0 -1
  240. package/dist/chunk-XHHMM5HX.js.map +0 -1
  241. package/dist/chunk-XN37VTDB.js.map +0 -1
  242. package/dist/chunk-XPJQXSC4.js.map +0 -1
  243. package/dist/chunk-YFAXOD7Z.js +0 -42
  244. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  245. package/dist/chunk-YLYXW5PH.js.map +0 -1
  246. package/dist/chunk-Z2K4JPQ3.js.map +0 -1
  247. package/dist/chunk-ZCL7C5LH.js.map +0 -1
  248. package/dist/client-G2WGWPCA.js +0 -11
  249. package/dist/db-XKZYDCKM.js.map +0 -1
  250. package/dist/digest-7UBGUBRK.js.map +0 -1
  251. package/dist/digest-state-3KHZF7GD.js.map +0 -1
  252. package/dist/ensure-site-OAAE5YE2.js.map +0 -1
  253. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  254. package/dist/forms-notify-target-UC6PF3AE.js.map +0 -1
  255. package/dist/github-signals-TX5FPYP6.js.map +0 -1
  256. package/dist/header-image-PSPT7CUM.js.map +0 -1
  257. package/dist/launch-UD36AHUN.js.map +0 -1
  258. package/dist/lighthouse-airtable-YMH4UA7R.js +0 -12
  259. package/dist/match-harness-QRG6XZAR.js +0 -70
  260. package/dist/match-harness-QRG6XZAR.js.map +0 -1
  261. package/dist/migrate-Y7R4CESL.js +0 -7
  262. package/dist/parity-CAJ477PS.js +0 -14
  263. package/dist/preflight-RYIP54NY.js.map +0 -1
  264. package/dist/prismic-ci-6GKKZGB5.js.map +0 -1
  265. package/dist/prismic-models-P6FWWVRS.js.map +0 -1
  266. package/dist/prospect-audit-PRHU53HS.js.map +0 -1
  267. package/dist/protection-audit-SJO53MEQ.js +0 -168
  268. package/dist/protection-audit-SJO53MEQ.js.map +0 -1
  269. package/dist/render-YGNGTX3W.js.map +0 -1
  270. package/dist/replay-PCSQOGCZ.js.map +0 -1
  271. package/dist/report-LQS4ECTM.js.map +0 -1
  272. package/dist/report-mirror-HDTOJE64.js.map +0 -1
  273. package/dist/schema-A4WCPIF4.js.map +0 -1
  274. package/dist/site-mirror-WON4FTJ2.js.map +0 -1
  275. package/dist/submissions-3I3GAQAX.js.map +0 -1
  276. package/dist/submissions-OVZPVBYG.js.map +0 -1
  277. /package/dist/{airtable-JGCPS7JH.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  278. /package/dist/{blux-4KNXUP2K.js.map → blux-NXQLMS4W.js.map} +0 -0
  279. /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-775VLDYB.js.map} +0 -0
  280. /package/dist/{chunk-FUWFLAWQ.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  281. /package/dist/{chunk-S7RYFUIK.js.map → chunk-6PGXKN6F.js.map} +0 -0
  282. /package/dist/{chunk-HTABUCF4.js.map → chunk-6THEZQLA.js.map} +0 -0
  283. /package/dist/{chunk-2LYOAB2E.js.map → chunk-HSMN35LU.js.map} +0 -0
  284. /package/dist/{chunk-BCKEP5S6.js.map → chunk-JHJNNJLG.js.map} +0 -0
  285. /package/dist/{chunk-ZQV7UQNE.js.map → chunk-LUF52EIY.js.map} +0 -0
  286. /package/dist/{chunk-VSGMCCGQ.js.map → chunk-MMSMZC7S.js.map} +0 -0
  287. /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
  288. /package/dist/{chunk-WMBLR5VA.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  289. /package/dist/{chunk-CJ2RMTT7.js.map → chunk-TV76A6GN.js.map} +0 -0
  290. /package/dist/{chunk-KZRR4NFG.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  291. /package/dist/{chunk-BI2VI6EM.js.map → chunk-YXWBM26P.js.map} +0 -0
  292. /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
  293. /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
  294. /package/dist/{client-G2WGWPCA.js.map → deadletter-G4M3HSVM.js.map} +0 -0
  295. /package/dist/{digest-collectors-DJFN5ZDK.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
  296. /package/dist/{fleet-state-UEVZ3X3J.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  297. /package/dist/{freeze-A6MUCZU4.js.map → freeze-GUQ43EAU.js.map} +0 -0
  298. /package/dist/{health-mirror-XJAKA3EY.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
  299. /package/dist/{import-airtable-TAKSN4NV.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  300. /package/dist/{lighthouse-VHI77PFB.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
  301. /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  302. /package/dist/{migrate-Y7R4CESL.js.map → migrate-XZ7U5DFC.js.map} +0 -0
  303. /package/dist/{notify-IISTHNBR.js.map → notify-K3WVH3DM.js.map} +0 -0
  304. /package/dist/{onboard-TM2KESGF.js.map → onboard-JQQIMQW7.js.map} +0 -0
  305. /package/dist/{orchestrate-HYVQPRCE.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
  306. /package/dist/{parity-CAJ477PS.js.map → parity-2JGVUCZD.js.map} +0 -0
  307. /package/dist/{pipeline-YROAY6DT.js.map → pipeline-UUPXOS2J.js.map} +0 -0
  308. /package/dist/{prospect-audits-R3LWF6U7.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
  309. /package/dist/{render-from-row-IFSBHWDH.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  310. /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
  311. /package/dist/{sync-K3PKHVLH.js.map → sync-F4USL4K5.js.map} +0 -0
  312. /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
  313. /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-EVAL3JTO.js.map} +0 -0
  314. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
  315. /package/dist/{websites-TB4TCAWM.js.map → websites-Y6FN2XFD.js.map} +0 -0
  316. /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -1,7 +1,6 @@
1
1
  import {
2
- listSendableReports,
3
2
  stampSent
4
- } from "./chunk-F3JWOPTG.js";
3
+ } from "./chunk-TEMQPSCM.js";
5
4
  import {
6
5
  gatingHealth,
7
6
  isHealthGateClear,
@@ -9,7 +8,7 @@ import {
9
8
  } from "./chunk-VAD43MOR.js";
10
9
  import {
11
10
  fetchAttachmentBytes
12
- } from "./chunk-IY6256GK.js";
11
+ } from "./chunk-YJBSB4LP.js";
13
12
  import {
14
13
  openBase,
15
14
  readAirtableConfig
@@ -17,17 +16,19 @@ import {
17
16
  import {
18
17
  renderReportFromRow,
19
18
  requireLighthouse
20
- } from "./chunk-HTABUCF4.js";
19
+ } from "./chunk-6THEZQLA.js";
21
20
  import {
22
- listWebsites,
23
21
  updateLaunched
24
- } from "./chunk-3Z6X3BHU.js";
22
+ } from "./chunk-TC4GANMX.js";
23
+ import {
24
+ siteSlug
25
+ } from "./chunk-ICSRWW5Q.js";
25
26
  import {
26
27
  defaultResendClient
27
28
  } from "./chunk-WEKL2HYJ.js";
28
29
  import {
29
30
  recordFleetEventsBestEffort
30
- } from "./chunk-BI2VI6EM.js";
31
+ } from "./chunk-YXWBM26P.js";
31
32
 
32
33
  // src/reports/send/idempotency.ts
33
34
  function isIdempotencyConflict(err) {
@@ -49,12 +50,12 @@ function withGlobalCc(perSiteCc, to) {
49
50
  if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);
50
51
  return cc;
51
52
  }
52
- async function sendApprovedReports(options = {}) {
53
+ async function sendApprovedReports(options) {
53
54
  const base = openBase(readAirtableConfig());
54
55
  const client = options.resend ?? defaultResendClient();
55
- const sendable = await listSendableReports(base);
56
+ const sendable = await options.sendable();
56
57
  if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
57
- const websites = await listWebsites(base);
58
+ const websites = await options.roster();
58
59
  const sites = new Map(websites.map((w) => [w.id, w]));
59
60
  const lines = [];
60
61
  let anyFailed = false;
@@ -66,7 +67,7 @@ async function sendApprovedReports(options = {}) {
66
67
  continue;
67
68
  }
68
69
  try {
69
- const sent = await sendOne(client, base, site, report);
70
+ const sent = await sendOne(client, base, site, report, options.loadHeaderPlate);
70
71
  lines.push(`\u2713 sent: ${report.reportId} (${sent.display})`);
71
72
  try {
72
73
  await options.reportSentMirror?.(report.id, sent.sentAt, sent.messageId);
@@ -125,7 +126,22 @@ async function sendApprovedReports(options = {}) {
125
126
  }
126
127
  return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
127
128
  }
128
- async function sendOne(client, base, site, report) {
129
+ async function headerPlateFor(base, site, loadHeaderPlate) {
130
+ const stored = await loadHeaderPlate(site.id);
131
+ if (stored) return { bytes: stored, source: "turso" };
132
+ const slug = siteSlug(site.name);
133
+ const { findWebsiteRecordBySlug } = await import("./ensure-site-OO4UPWNQ.js");
134
+ const { mapRow } = await import("./websites-Y6FN2XFD.js");
135
+ const rec = slug ? await findWebsiteRecordBySlug(base, slug) : null;
136
+ const url = rec ? mapRow(rec).headerImage?.url : void 0;
137
+ if (!url) {
138
+ throw new Error(
139
+ `Site '${site.name}' has no Header image: no header plate in Turso and no Airtable attachment to fall back to \u2014 run \`reddoor-maint header-image ${slug || site.name} --write-back\``
140
+ );
141
+ }
142
+ return { bytes: (await fetchAttachmentBytes(url)).bytes, source: "airtable" };
143
+ }
144
+ async function sendOne(client, base, site, report, loadHeaderPlate) {
129
145
  const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };
130
146
  if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {
131
147
  const failing = gatingHealth(gateReport).filter((h) => h.status !== "pass" && h.status !== "n/a").map((h) => {
@@ -134,9 +150,6 @@ async function sendOne(client, base, site, report) {
134
150
  }).join("; ");
135
151
  throw new Error(`Report ${report.reportId} health gate not clear \u2014 ${failing}`);
136
152
  }
137
- if (!site.headerImage) {
138
- throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
139
- }
140
153
  requireLighthouse(report);
141
154
  const explicitTo = parseAddresses(site.reportRecipientsTo);
142
155
  const fallbackTo = parseAddresses(site.pointOfContact);
@@ -163,8 +176,9 @@ async function sendOne(client, base, site, report) {
163
176
  }
164
177
  }
165
178
  }
166
- const original = await fetchAttachmentBytes(site.headerImage.url);
167
- const { html, attachments, subject } = await renderReportFromRow(site, report, original.bytes);
179
+ const header = await headerPlateFor(base, site, loadHeaderPlate);
180
+ console.log(`REPORT_SEND report=${report.reportId} site=${site.name} header=${header.source}`);
181
+ const { html, attachments, subject } = await renderReportFromRow(site, report, header.bytes);
168
182
  const payload = {
169
183
  from: FROM_ADDRESS,
170
184
  to,
@@ -227,4 +241,4 @@ export {
227
241
  parseAddresses,
228
242
  isProbablyEmail
229
243
  };
230
- //# sourceMappingURL=chunk-2ZAZ7ETT.js.map
244
+ //# sourceMappingURL=chunk-LDHRIQUW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/send/idempotency.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["/**\n * True when a thrown send error is Resend's same-key + DIFFERENT-body 409\n * (`invalid_idempotent_request`). The ResendClient (send/resend.ts) discards the\n * status/name and only surfaces the message string, so we match defensively on the\n * stable message substring \"idempotency key has been used\" (case-insensitive); a\n * `name`/`statusCode` of 409/`invalid_idempotent_request` is also accepted if a\n * future client happens to preserve it. A same-key + SAME-body re-send is deduped\n * by Resend (returns the original id) and never reaches here.\n *\n * Shared by both send surfaces that key into Resend's idempotency window:\n * `runDigest` (digest.ts, `digest-<date>` key) and `sendOne` (orchestrate.ts,\n * `report:<id>` key). Both treat a 409 as \"the email already went out under this\n * key on a prior run\" — a no-op for the digest, an already-done success for sendOne.\n */\nexport function isIdempotencyConflict(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n if (/idempotency key has been used/i.test(message)) return true;\n const e = err as { name?: unknown; statusCode?: unknown };\n if (e.name === \"invalid_idempotent_request\") return true;\n if (e.statusCode === 409) return true;\n return false;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../airtable/client.js\";\nimport { stampSent } from \"../airtable/reports.js\";\nimport { updateLaunched } from \"../airtable/websites.js\";\nimport { siteSlug, type WebsiteRow } from \"../../fleet/site-row.js\";\nimport type { ReportRow } from \"../report-row.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { renderReportFromRow, requireLighthouse } from \"./render-from-row.js\";\nimport { defaultResendClient, type ResendClient } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { gatingHealth, isHealthGateClear, isSendOverridden } from \"../checklist.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\n/** Operations inbox CC'd on every outgoing report so there's always an internal\n * copy on file alongside the client recipients. */\nexport const GLOBAL_REPORT_CC = \"info@reddoorla.com\";\n\n/**\n * Append {@link GLOBAL_REPORT_CC} to a site's per-site CC list. The per-site CC is\n * passed through unchanged (preserving prior behavior); the global address is added\n * only when it isn't already present in the CC or To lists (case-insensitive), so a\n * report is never double-addressed to the ops inbox. Returns the final CC list (may\n * be empty if the global address is already the sole To recipient — the caller omits\n * an empty CC).\n */\nexport function withGlobalCc(perSiteCc: string[] | null, to: string[]): string[] {\n const cc = [...(perSiteCc ?? [])];\n const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));\n if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);\n return cc;\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n /**\n * #646 step 4: the send queue and the fleet roster, read from TURSO.\n *\n * Required rather than defaulted, for the reason the mirrors below are: this\n * function is called directly by tests, and a default would let one reach a\n * live store. The CLI wires `listSendableReports`/`listSites` over one\n * connection.\n */\n sendable: () => Promise<ReportRow[]>;\n roster: () => Promise<WebsiteRow[]>;\n /**\n * The site's header plate, from `sites.header_image*` (design D5 made Turso its\n * source; the re-render path has read it from there since #643). `null` means\n * Turso holds no bytes for that site, and the send falls back to the site's\n * Airtable attachment — which is the ONLY reason this path still reads\n * Airtable, and why the fallback fetches ONE site's record rather than the\n * roster it used to get the signed url from.\n */\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>;\n /** #539 Phase 5: Turso write-through for the Websites row a Launch send\n * updates. Injected rather than defaulted — this function is called directly\n * by tests, and a default would open a real libSQL handle inside the suite. */\n siteMirror?: SiteMirror;\n /** #643 (the freeze): Turso write-through for the `Sent at` / `Resend message\n * ID` stamp. stampSent is what removes a row from listSendableReports, so\n * once it lands there is no replay left to converge a lost mirror — and the\n * console's already-sent guards (approve, the commentary lock, re-render)\n * read `sent_at` from Turso. Injected like siteMirror; the CLI wires it\n * through `mirrorWrite` so the freeze switch owns the error semantics. */\n reportSentMirror?: (reportId: string, sentAt: Date, messageId: string | null) => Promise<void>;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions,\n): Promise<{ output: string; code: number }> {\n // Airtable is still opened on this path: `stampSent`, `updateLaunched` and the\n // header fallback below are all Airtable calls. What moved in #646 step 4 are\n // the two READS that decided what the run does at all.\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await options.sendable();\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await options.roster();\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const sent = await sendOne(client, base, site, report, options.loadHeaderPlate);\n lines.push(`✓ sent: ${report.reportId} (${sent.display})`);\n // Mirror the stamp into Turso. Caught here rather than thrown so one\n // report's lost mirror still lets the batch continue AND still runs this\n // report's Launch flip below — but it reds the run (`anyFailed`), because\n // post-freeze nothing converges the miss; `db sync --force` is the manual\n // converge during the rollback window.\n try {\n await options.reportSentMirror?.(report.id, sent.sentAt, sent.messageId);\n } catch (e) {\n lines.push(` ✗ sent-stamp mirror failed for ${report.reportId}: ${(e as Error).message}`);\n anyFailed = true;\n }\n if (report.sendOverride) {\n const failing = gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => h.field);\n await recordFleetEventsBestEffort(\n [\n {\n id: `report_sent_with_override:${report.id}`,\n ts: new Date().toISOString(),\n type: \"report_sent_with_override\",\n siteId: site.id,\n siteName: site.name,\n summary: `sent with override — ${report.overrideReason ?? \"\"}`,\n data: { reportId: report.reportId, reason: report.overrideReason, failing },\n },\n ],\n new Date(),\n );\n }\n if (report.reportType === \"Launch\") {\n try {\n const fields = await updateLaunched(base, site.id, new Date().toISOString());\n // Status and `Launched at` travel together — mirroring them as two\n // updates would open a window where Turso says a site is maintained\n // but never launched.\n await options.siteMirror?.site(site.id, fields);\n lines.push(` ↳ launched: ${site.name} flipped to maintained`);\n await recordFleetEventsBestEffort(\n [\n {\n id: `site_launched:${site.id}`,\n ts: new Date().toISOString(),\n type: \"site_launched\",\n siteId: site.id,\n siteName: site.name,\n summary: \"launched — now in maintenance\",\n data: null,\n },\n ],\n new Date(),\n );\n } catch (e) {\n // Post-freeze a swallowed flip failure is permanent divergence: Turso\n // — the store lead routing reads — would keep the site in\n // launch-period forever (its leads go operator-only). Red the run\n // instead of shrugging; the email itself already went out.\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\n/**\n * The header plate for one send: Turso first, the site's Airtable attachment as\n * the fallback (#646 step 4).\n *\n * Turso first for the reason the re-render path already had: the bytes are local,\n * and an Airtable attachment url is SIGNED and expiring, so fetching one when the\n * same image is already in hand is latency plus a dependency on a url that may be\n * dead. The fallback exists because the plate columns were backfilled, not\n * enforced — a site whose image never made it across must still be able to send,\n * exactly as it did before this change.\n *\n * The fallback is a ONE-SITE Airtable lookup, not the whole Websites table: the\n * roster no longer comes from Airtable, so the signed url is not already in hand.\n * A site with no record there (every `site_<ULID>` site) and no plate gets a named\n * error with the command that fixes it — not a silent unillustrated report.\n */\nasync function headerPlateFor(\n base: AirtableBase,\n site: WebsiteRow,\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>,\n): Promise<{ bytes: Uint8Array; source: \"turso\" | \"airtable\" }> {\n const stored = await loadHeaderPlate(site.id);\n if (stored) return { bytes: stored, source: \"turso\" };\n const slug = siteSlug(site.name);\n const { findWebsiteRecordBySlug } = await import(\"../airtable/ensure-site.js\");\n const { mapRow } = await import(\"../airtable/websites.js\");\n const rec = slug ? await findWebsiteRecordBySlug(base, slug) : null;\n const url = rec ? mapRow(rec).headerImage?.url : undefined;\n if (!url) {\n throw new Error(\n `Site '${site.name}' has no Header image: no header plate in Turso and no Airtable ` +\n `attachment to fall back to — run \\`reddoor-maint header-image ${slug || site.name} --write-back\\``,\n );\n }\n return { bytes: (await fetchAttachmentBytes(url)).bytes, source: \"airtable\" };\n}\n\n/** What the caller needs to mirror the stamp: the exact values stampSent wrote\n * (`messageId` null on the 409 path, where Airtable's field is left untouched\n * too), plus the display string for the ✓ line. */\ntype SentStamp = { display: string; sentAt: Date; messageId: string | null };\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>,\n): Promise<SentStamp> {\n // Hard health gate: a Maintenance/Testing report whose gating evidence isn't all pass/n/a must\n // never go out — even if \"Approved to send\" was set directly in Airtable. Throw so the row is\n // skipped and `Sent at` stays null (at-least-once retry preserved). Launch/Announcement have no\n // gating fields → vacuously clear. A logged send-anyway override (Phase 10) bypasses the gate.\n const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };\n if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {\n const failing = gatingHealth(gateReport)\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => {\n const note = report.autoEvidence?.[h.field]?.note;\n return `${h.field} (${h.status}${note ? `: ${note}` : \"\"})`;\n })\n .join(\"; \");\n throw new Error(`Report ${report.reportId} health gate not clear — ${failing}`);\n }\n // Fail fast, before the header fetch and the sharp downscale. Same rule and\n // same message the renderer enforces — shared, not restated.\n requireLighthouse(report);\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const header = await headerPlateFor(base, site, loadHeaderPlate);\n console.log(`REPORT_SEND report=${report.reportId} site=${site.name} header=${header.source}`);\n // ONE render path, shared with the console's on-demand re-render — so a\n // preview cannot drift from what the client actually receives. The assembly\n // that used to live here moved into renderReportFromRow unchanged.\n const { html, attachments, subject } = await renderReportFromRow(site, report, header.bytes);\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n // Always CC the ops inbox (info@reddoorla.com), in addition to any per-site CC.\n const finalCc = withGlobalCc(cc, to);\n if (finalCc.length > 0) payload.cc = finalCc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n const when = new Date();\n await stampSent(base, report.id, when, null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return { display: \"idempotent-conflict\", sentAt: when, messageId: null };\n }\n throw err;\n }\n const when = new Date();\n await stampSent(base, report.id, when, result.messageId);\n return { display: result.messageId, sentAt: when, messageId: result.messageId };\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":["when"]}
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-VAD43MOR.js";
5
5
  import {
6
6
  siteSlug
7
- } from "./chunk-3Z6X3BHU.js";
7
+ } from "./chunk-ICSRWW5Q.js";
8
8
 
9
9
  // src/db/import-airtable.ts
10
10
  var EXCLUDED_WEBSITE_FIELDS = /* @__PURE__ */ new Set([
@@ -412,4 +412,4 @@ export {
412
412
  reapRefusal,
413
413
  importFleetState
414
414
  };
415
- //# sourceMappingURL=chunk-6R56OKJZ.js.map
415
+ //# sourceMappingURL=chunk-LF2TJN4X.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/import-airtable.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { SitesTable, SiteHealthTable, SiteScheduleTable, ReportsTable } from \"./schema.js\";\nimport { siteSlug } from \"../fleet/site-row.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\n\n/** One raw Airtable record: id + the fields object exactly as the API returns it\n * (empty cells are ABSENT, not null — the API omits them). */\nexport type RawRecord = { id: string; fields: Record<string, unknown> };\n\n/**\n * Phase 1.3 of #539: map raw Airtable records into the 0007 fleet-state tables,\n * and upsert them idempotently.\n *\n * The mapping here is the SINGLE source of truth for Airtable-column →\n * Turso-column: the parity harness (`db parity`) imports these same functions,\n * so \"what the importer writes\" and \"what parity expects\" cannot drift apart.\n *\n * Values are stored RAW (the cell as Airtable returns it, stringified where the\n * column is TEXT). Coercion — `toFrequency`, `toVerdict`, recipient splitting —\n * stays in the READ layer, exactly as `mapRow` does it today, so the store\n * never bakes in a lossy interpretation. The two exceptions are shapes, not\n * meanings: `Accepted Watch Conditions` normalizes to a JSON array (mapRow\n * accepts array or delimited string; the new store keeps one shape), and the\n * report checklist re-keys from Airtable column names to the stable keys in\n * src/reports/checklist.ts.\n */\n\n/** Websites columns that must NEVER reach Turso, and why. Everything else that\n * is populated but unmapped lands in `sites.legacy` (JSON, keyed by original\n * column name) so no data is silently dropped. */\nexport const EXCLUDED_WEBSITE_FIELDS: ReadonlySet<string> = new Set([\n // Dropped under D4's rule (empty everywhere + unreferenced), verified 2026-08-23.\n \"site host username\",\n \"site host password\",\n \"launch day\",\n \"contract link\",\n // Plaintext credentials — operator ruling 2026-08-23: these live on ONLY in\n // the frozen Airtable base. A new store does not inherit plaintext creds.\n \"DNS username\",\n \"DNS password\",\n \"cms username\",\n \"cms password\",\n // Link columns: Turso joins by rec id (D1); the links carry no extra data.\n \"Reports\",\n \"Submissions\",\n \"Spam Screenouts\",\n // Regenerated into sites.header_image as a BLOB by the header-image CLI (D5);\n // the Airtable attachment (an expiring signed URL) is never migrated.\n \"Header image\",\n]);\n\nconst s = (v: unknown): string | null => {\n if (v === undefined || v === null) return null;\n if (typeof v === \"string\") return v.trim() === \"\" ? null : v;\n return String(v);\n};\nconst n = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\nconst b01 = (v: unknown): number => (v === true ? 1 : 0);\nconst b01n = (v: unknown): number | null => (typeof v === \"boolean\" ? (v ? 1 : 0) : null);\nconst json = (v: unknown): string | null => (v === undefined ? null : JSON.stringify(v));\n\n/** Direct field→column map for `sites` (operator-owned config). Exported for\n * the site-details write-through (fleet-state.mirrorSiteField), so the editor\n * and the importer share ONE Airtable-column → sites-column truth. */\nexport const SITE_FIELDS: Record<string, keyof SitesTable> = {\n Name: \"name\",\n url: \"url\",\n Status: \"status\",\n \"point of contact\": \"point_of_contact\",\n \"maintenence freq\": \"maintenance_freq\", // Airtable's misspelling dies at this boundary\n \"testing freq\": \"testing_freq\",\n \"maintenance day\": \"maintenance_day\",\n \"testing day\": \"testing_day\",\n \"GA4 property ID\": \"ga4_property_id\",\n \"Search query\": \"search_query\",\n \"Search Console property\": \"search_console_property\",\n \"Git repo\": \"git_repo\",\n \"Netlify ID\": \"netlify_id\",\n \"Report recipients (To)\": \"report_recipients_to\",\n \"Report recipients (CC)\": \"report_recipients_cc\",\n \"Copy — Intro\": \"copy_intro\",\n \"Copy — Contact\": \"copy_contact\",\n \"Copy — Footer\": \"copy_footer\",\n \"Newsletter Webhook\": \"newsletter_webhook\",\n \"Mailchimp API Key\": \"mailchimp_api_key\",\n \"Mailchimp Audience ID\": \"mailchimp_audience_id\",\n \"Notify Routing\": \"notify_routing\",\n \"Prismic Ack Until\": \"prismic_ack_until\",\n \"Launched at\": \"launched_at\",\n // Non-text columns. They live here so `mirrorSiteField` can resolve them like\n // any other editor field; the coercion that makes them non-text is in\n // `siteValueFor`, which BOTH this importer and that mirror go through.\n \"Require Turnstile\": \"require_turnstile\",\n \"Accepted Watch Conditions\": \"accepted_watch_conditions\",\n};\n\n/** Normalize an `Accepted Watch Conditions` cell (Airtable array, or a delimited\n * string) to the trimmed list both stores agree on. */\nfunction normalizeAwc(raw: unknown): string[] {\n if (Array.isArray(raw)) {\n return raw\n .filter((x): x is string => typeof x === \"string\")\n .map((x) => x.trim())\n .filter(Boolean);\n }\n if (typeof raw === \"string\") {\n return raw\n .split(/[\\n,]/)\n .map((x) => x.trim())\n .filter(Boolean);\n }\n return [];\n}\n\n/**\n * Resolve one Airtable Websites cell to the value its `sites` column stores.\n *\n * The ONE shared path between `mapWebsiteRecord` (full-row import) and\n * `fleet-state.mirrorSiteField` (the editor's write-through) — same contract and\n * the same reason as `healthColumnFor`: a coercion that exists twice will\n * eventually disagree, and here a disagreement is not subtle. Parity compares\n * raw-to-raw, so a mirror storing `\"true\"` where the importer stores `1` reds\n * every hourly run until the next import quietly papers over it.\n */\nexport function siteValueFor(column: keyof SitesTable, raw: unknown): string | number | null {\n if (column === \"require_turnstile\") return b01(raw);\n if (column === \"accepted_watch_conditions\") {\n const awc = normalizeAwc(raw);\n return awc.length > 0 ? JSON.stringify(awc) : null;\n }\n return s(raw);\n}\n\n/** Direct field→column map for `site_health` (nightly-cron-owned). Exported for\n * the Phase 3 writer mirrors (fleet-state.mirrorHealthFields) and their\n * lockstep tests, so the nightly writers and the importer share ONE\n * Airtable-column → site_health-column truth. */\nexport const HEALTH_FIELDS: Record<string, keyof SiteHealthTable> = {\n pScore: \"p_score\",\n rScore: \"r_score\",\n bpScore: \"bp_score\",\n seoScore: \"seo_score\",\n \"Last lighthouse audit at\": \"lighthouse_at\",\n \"A11y Violations\": \"a11y_violations\",\n \"Deps Drifted\": \"deps_drifted\",\n \"Deps Major Behind\": \"deps_major_behind\",\n \"Deps Outdated\": \"deps_outdated\",\n \"Deps Major Outdated\": \"deps_major_outdated\",\n \"Security Vulns Critical\": \"vulns_critical\",\n \"Security Vulns High\": \"vulns_high\",\n \"Security Vulns Moderate\": \"vulns_moderate\",\n \"Security Vulns Low\": \"vulns_low\",\n \"Last security audit at\": \"security_audit_at\",\n \"Security advisories\": \"security_advisories\",\n \"Security Auto-Fix Attempts\": \"auto_fix_attempts\",\n \"Analytics soft-fail at\": \"analytics_soft_fail_at\",\n \"Cert days remaining\": \"cert_days_remaining\",\n \"Domain checked at\": \"domain_checked_at\",\n \"Deploy status\": \"deploy_status\",\n \"Last deploy at\": \"last_deploy_at\",\n \"Deploy log URL\": \"deploy_log_url\",\n \"Deploy checked at\": \"deploy_checked_at\",\n \"Function health\": \"function_health\",\n \"CMS Reachable\": \"cms_reachable\",\n \"Turnstile widget\": \"turnstile_widget\",\n \"Function health checked at\": \"function_health_checked_at\",\n \"Broken links\": \"broken_links\",\n \"Browser checked at\": \"browser_checked_at\",\n \"Uptime Reachable\": \"uptime_reachable\",\n \"Titles & Meta OK\": \"titles_meta_ok\",\n \"Smoke OK\": \"smoke_ok\",\n \"Last Smoke At\": \"last_smoke_at\",\n \"Form E2E OK\": \"form_e2e_ok\",\n \"Form E2E checked at\": \"form_e2e_checked_at\",\n \"Renovate Failing CIs\": \"renovate_failing_cis\",\n \"Default Branch CI\": \"default_branch_ci\",\n \"Last Commit At\": \"last_commit_at\",\n \"GitHub Signals At\": \"github_signals_at\",\n \"Prismic Models\": \"prismic_models\",\n \"Prismic Models Checked At\": \"prismic_models_checked_at\",\n \"Prismic Models Drift\": \"prismic_models_drift\",\n};\n\n/** Numeric health columns (everything else in HEALTH_FIELDS stores as text). */\nconst HEALTH_NUMERIC: ReadonlySet<keyof SiteHealthTable> = new Set([\n \"p_score\",\n \"r_score\",\n \"bp_score\",\n \"seo_score\",\n \"a11y_violations\",\n \"deps_drifted\",\n \"deps_major_behind\",\n \"deps_outdated\",\n \"deps_major_outdated\",\n \"vulns_critical\",\n \"vulns_high\",\n \"vulns_moderate\",\n \"vulns_low\",\n \"auto_fix_attempts\",\n \"cert_days_remaining\",\n \"broken_links\",\n \"renovate_failing_cis\",\n]);\n\n/** Boolean-checkbox health columns (Airtable true/absent → 1/0/null). */\nexport const HEALTH_BOOLEAN: Record<string, keyof SiteHealthTable> = {\n \"Crossbrowser OK\": \"crossbrowser_ok\",\n \"Mobile OK\": \"mobile_ok\",\n \"Links OK\": \"links_ok\",\n};\n\n/** Fields consumed by the schedule table. */\nexport const SCHEDULE_FIELDS: Record<string, keyof SiteScheduleTable> = {\n \"Next maintenance at\": \"next_maintenance_at\",\n \"Next testing at\": \"next_testing_at\",\n};\n\n/** Resolve one Airtable health field to its site_health column AND the exact\n * coercion the importer applies to it. The ONE shared path between\n * mapWebsiteRecord (full-row import) and fleet-state.mirrorHealthFields\n * (partial write-through): a coercion that exists twice will eventually\n * disagree, and the hourly import silently papers over the loser. Returns\n * null for a field no health column claims. */\nexport function healthColumnFor(\n field: string,\n): { col: keyof SiteHealthTable; coerce: (v: unknown) => string | number | null } | null {\n const direct = HEALTH_FIELDS[field];\n if (direct) return { col: direct, coerce: HEALTH_NUMERIC.has(direct) ? n : s };\n const bool = HEALTH_BOOLEAN[field];\n if (bool) return { col: bool, coerce: b01n };\n return null;\n}\n\n/** The schedule twin of {@link healthColumnFor} (both columns store as text). */\nexport function scheduleColumnFor(\n field: string,\n): { col: keyof SiteScheduleTable; coerce: (v: unknown) => string | null } | null {\n const col = SCHEDULE_FIELDS[field];\n return col ? { col, coerce: s } : null;\n}\n\nexport type MappedWebsite = {\n site: Omit<\n SitesTable,\n \"header_image\" | \"header_image_filename\" | \"header_image_type\" | \"header_image_generated_at\"\n >;\n health: SiteHealthTable;\n schedule: SiteScheduleTable;\n};\n\n/** Map one raw Websites record into its three-table shape. Pure. */\nexport function mapWebsiteRecord(rec: RawRecord, computedAt: string): MappedWebsite {\n const f = rec.fields;\n const name = s(f[\"Name\"]);\n if (!name) throw new Error(`Websites ${rec.id}: blank Name — cannot derive a slug`);\n const slug = siteSlug(name);\n if (!slug) throw new Error(`Websites ${rec.id}: Name \"${name}\" yields an empty slug`);\n\n const site: MappedWebsite[\"site\"] = {\n id: rec.id,\n slug,\n name,\n url: null,\n status: null,\n point_of_contact: null,\n maintenance_freq: null,\n testing_freq: null,\n maintenance_day: null,\n testing_day: null,\n ga4_property_id: null,\n search_query: null,\n search_console_property: null,\n git_repo: null,\n netlify_id: null,\n report_recipients_to: null,\n report_recipients_cc: null,\n copy_intro: null,\n copy_contact: null,\n copy_footer: null,\n newsletter_webhook: null,\n mailchimp_api_key: null,\n mailchimp_audience_id: null,\n notify_routing: null,\n require_turnstile: 0,\n accepted_watch_conditions: null,\n prismic_ack_until: null,\n launched_at: null,\n legacy: null,\n };\n // Every mapped column goes through siteValueFor, so the non-text ones\n // (require_turnstile, accepted_watch_conditions) get the SAME coercion the\n // editor's write-through mirror applies. The literals above are placeholders\n // this loop overwrites; they exist only to satisfy the row type.\n for (const [field, col] of Object.entries(SITE_FIELDS)) {\n if (col === \"name\") continue; // handled above (validated)\n (site as unknown as Record<string, unknown>)[col] = siteValueFor(col, f[field]);\n }\n\n const health = { site_id: rec.id } as SiteHealthTable;\n // Through healthColumnFor — the SAME resolution+coercion the Phase 3 writer\n // mirrors use, so import and write-through cannot drift apart.\n for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {\n const m = healthColumnFor(field);\n if (m) (health as unknown as Record<string, unknown>)[m.col] = m.coerce(f[field]);\n }\n\n const schedule: SiteScheduleTable = {\n site_id: rec.id,\n next_maintenance_at: s(f[\"Next maintenance at\"]),\n next_testing_at: s(f[\"Next testing at\"]),\n computed_at: computedAt,\n };\n\n // Everything populated that no table claims and no exclusion bans → legacy.\n const claimed = new Set<string>([\n \"Name\",\n ...Object.keys(SITE_FIELDS),\n ...Object.keys(HEALTH_FIELDS),\n ...Object.keys(HEALTH_BOOLEAN),\n ...Object.keys(SCHEDULE_FIELDS),\n ]);\n const legacy: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(f)) {\n if (claimed.has(field) || EXCLUDED_WEBSITE_FIELDS.has(field)) continue;\n legacy[field] = value;\n }\n site.legacy = Object.keys(legacy).length > 0 ? JSON.stringify(legacy) : null;\n\n return { site, health, schedule };\n}\n\n/** Stable-key checklist mapping (Airtable column name → key). */\nconst CHECKLIST_BY_FIELD: ReadonlyArray<{ field: string; key: string }> = [\n ...MAINTENANCE_CHECKLIST,\n ...TESTING_CHECKLIST,\n].map((i) => ({ field: i.field, key: i.key }));\n\n/** Map one raw Reports record. `renderedHtml` is fetched by the caller (the\n * attachment URL is an expiring signed URL — see the design's Attachments\n * section); pass null when the record has no attachment or the fetch failed. */\nexport function mapReportRecord(rec: RawRecord, renderedHtml: string | null): ReportsTable {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const checklist: Record<string, boolean> = {};\n for (const { field, key } of CHECKLIST_BY_FIELD) checklist[key] = Boolean(f[field]);\n\n return {\n id: rec.id,\n site_id: linkSites[0] ?? null,\n report_id: s(f[\"Report ID\"]),\n report_type: s(f[\"Report type\"]),\n period: s(f[\"Period\"]),\n period_start: s(f[\"Period start\"]),\n period_end: s(f[\"Period end\"]),\n completed_on: s(f[\"Completed on\"]),\n lighthouse_performance: n(f[\"Lighthouse — Performance\"]),\n lighthouse_accessibility: n(f[\"Lighthouse — Accessibility\"]),\n lighthouse_best_practices: n(f[\"Lighthouse — Best Practices\"]),\n lighthouse_seo: n(f[\"Lighthouse — SEO\"]),\n ga_users_current: n(f[\"GA users (period)\"]),\n ga_users_previous: n(f[\"GA users (prev period)\"]),\n search_found_page1: b01n(f[\"Search found page 1\"]),\n search_position: n(f[\"Search position\"]),\n last_tested_date: s(f[\"Last tested date\"]),\n commentary: s(f[\"Commentary\"]),\n subject_override: s(f[\"Subject override\"]),\n draft_ready: b01(f[\"Draft ready\"]),\n approved_to_send: b01(f[\"Approved to send\"]),\n approved_at: s(f[\"Approved At\"]),\n approved_by: s(f[\"Approved By\"]),\n send_override: b01(f[\"Send override\"]),\n override_reason: s(f[\"Override reason\"]),\n override_by: s(f[\"Override by\"]),\n override_at: s(f[\"Override at\"]),\n sent_at: s(f[\"Sent at\"]),\n delivery_status: s(f[\"Delivery status\"]) ?? \"pending\",\n resend_message_id: s(f[\"Resend message ID\"]),\n checklist: JSON.stringify(checklist),\n // The Airtable cell is a long-text field, so the API hands us a STRING of\n // JSON — store it verbatim. json() here would double-encode it, and\n // parseAutoEvidence on the read side would then parse to a string and\n // yield null (evidence silently lost). The non-string branch only exists\n // for defensive completeness.\n checklist_auto_evidence:\n typeof f[\"Checklist auto-evidence\"] === \"string\"\n ? s(f[\"Checklist auto-evidence\"])\n : json(f[\"Checklist auto-evidence\"] ?? undefined),\n rendered_html: renderedHtml,\n };\n}\n\n/** First Rendered HTML attachment's signed URL, or null. Exposed so the CLI can\n * fetch it and tests can assert which URL would be fetched. */\nexport function renderedHtmlUrl(rec: RawRecord): string | null {\n const atts = rec.fields[\"Rendered HTML\"] as Array<{ url?: string }> | undefined;\n const url = atts?.[0]?.url;\n return typeof url === \"string\" && url.length > 0 ? url : null;\n}\n\n/** What the import removed because Airtable no longer has the record — and what\n * it declined to remove. `refusals` is non-empty only when the incoming read\n * looked untrustworthy; in that case NOTHING was deleted for that table. */\nexport type ReapSummary = {\n sites: string[];\n reports: string[];\n refusals: string[];\n};\n\n/** Reaping is the only destructive thing the importer does, and it acts on the\n * absence of evidence — so it is only ever as trustworthy as the read that\n * motivated it. A short read (a truncated page, a partial outage) is\n * indistinguishable from mass deletion by looking at the rows alone, so the\n * allowance is deliberately shaped like an operator tidying up rather than\n * like a bad page: at most 10% of what is stored, with a floor of 5 so a small\n * table stays workable. Over that, refuse the whole table and let parity red\n * the run — a wedged-red sync is recoverable, an emptied Turso is not. */\nconst REAP_FLOOR = 5;\nconst REAP_FRACTION = 0.1;\n\nexport function reapAllowance(stored: number): number {\n return Math.max(REAP_FLOOR, Math.ceil(stored * REAP_FRACTION));\n}\n\n/** Human lines plus the FLEET_REAP machine line, emitted on EVERY pass (nothing\n * reaped included) — an absent line must read as \"the reap never ran\", never as\n * \"it ran clean\", the same contract FLEET_PARITY and FLEET_SYNC hold. Shared by\n * `db sync` and the one-shot `db import-airtable`: both can delete, so a second\n * hand-rolled copy is how one of them ends up doing it quietly. */\nexport function formatReapSummary(reaped: ReapSummary): string[] {\n const lines: string[] = [];\n for (const id of reaped.sites) {\n lines.push(`✂ reaped sites ${id} (+ its health/schedule rows) — no longer in Airtable`);\n }\n for (const id of reaped.reports) {\n lines.push(`✂ reaped reports ${id} — no longer in Airtable`);\n }\n for (const reason of reaped.refusals) lines.push(`⚠ ${reason}`);\n lines.push(\n `FLEET_REAP sites=${reaped.sites.length} reports=${reaped.reports.length} ` +\n `refused=${reaped.refusals.length}`,\n );\n return lines;\n}\n\n/** Null when the reap may proceed, else the human reason it was refused. */\nexport function reapRefusal(\n table: string,\n incoming: number,\n stored: number,\n toReap: number,\n): string | null {\n if (incoming === 0 && stored > 0) {\n return `${table}: REFUSED to reap — Airtable returned 0 rows while ${stored} are stored; that is a failed read, not an empty fleet. Nothing deleted.`;\n }\n const allowed = reapAllowance(stored);\n if (toReap > allowed) {\n return `${table}: REFUSED to reap — ${toReap} rows are missing from Airtable, over the ${allowed}-row allowance for ${stored} stored. Nothing deleted; re-run, and if the deletions are real, remove them in smaller batches.`;\n }\n return null;\n}\n\nexport type ImportSummary = {\n sites: number;\n reports: number;\n /** Rows removed (or refused) because their Airtable record is gone. */\n reaped: ReapSummary;\n /** Reports whose Rendered HTML attachment could not be fetched (URL expired /\n * network) — imported with rendered_html null, named so the run is honest. */\n renderedHtmlMisses: string[];\n /** Attachment fetches attempted and succeeded this run. */\n renderedHtmlFetched: number;\n /** Fetches skipped because the stored row already carries rendered_html\n * (only in reportHtml: \"when-missing\" mode — the hourly sync's mode). */\n renderedHtmlSkipped: number;\n};\n\nexport type ImportOptions = {\n /** \"always\" (default): fetch every report's Rendered HTML attachment — the\n * one-shot full import's mode. \"when-missing\": fetch only for reports whose\n * stored row has no rendered_html yet — the hourly sync's mode, so 24 runs a\n * day don't re-download every attachment every hour. Safe for Phase 2:\n * nothing reads reports.rendered_html from Turso until the Phase 4 report\n * review lands; revisit the staleness story there (a re-rendered report's\n * stored HTML goes stale under \"when-missing\"). Parity is unaffected —\n * rendered_html is a parity SKIP_COLUMN. */\n reportHtml?: \"always\" | \"when-missing\";\n};\n\nexport type ImportIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n listReportRecords: () => Promise<RawRecord[]>;\n /** Fetch an attachment body; null on failure (the miss is reported, not fatal). */\n fetchAttachment: (url: string) => Promise<string | null>;\n now: () => Date;\n};\n\n/**\n * Import both tables, idempotently: upsert by rec id, so a re-run converges\n * instead of duplicating. On conflict the sites upsert deliberately does NOT\n * touch the header_image* columns — Airtable stopped being their source (D5),\n * so a re-import must never wipe a regenerated image. rendered_html is only\n * overwritten when the fetch succeeded, for the same reason: a re-run with an\n * expired signed URL must not erase a body captured while it was valid.\n */\nexport async function importFleetState(\n db: Db,\n io: ImportIo,\n opts: ImportOptions = {},\n): Promise<ImportSummary> {\n const computedAt = io.now().toISOString();\n const websites = await io.listWebsiteRecords();\n\n const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));\n const slugOwner = new Map<string, string>();\n for (const m of mapped) {\n const prior = slugOwner.get(m.site.slug);\n if (prior)\n throw new Error(`slug collision: ${prior} and ${m.site.id} both map to '${m.site.slug}'`);\n slugOwner.set(m.site.slug, m.site.id);\n }\n\n for (const { site, health, schedule } of mapped) {\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n }\n\n const reports = await io.listReportRecords();\n // \"when-missing\": one pre-read of which report rows already hold a body, so\n // the loop can skip their attachment fetches. A skipped fetch keeps the\n // stored body (the upsert falls into the rowSansHtml branch) and is counted,\n // not treated as a miss.\n const alreadyStored =\n opts.reportHtml === \"when-missing\"\n ? new Set(\n (\n await db\n .selectFrom(\"reports\")\n .select(\"id\")\n .where(\"rendered_html\", \"is not\", null)\n .execute()\n ).map((r) => r.id),\n )\n : null;\n const misses: string[] = [];\n let fetched = 0;\n let skipped = 0;\n for (const rec of reports) {\n const url = renderedHtmlUrl(rec);\n const skip = url !== null && alreadyStored !== null && alreadyStored.has(rec.id);\n if (skip) skipped++;\n const html = url && !skip ? await io.fetchAttachment(url) : null;\n if (url && !skip && html === null) misses.push(rec.id);\n if (html !== null) fetched++;\n const row = mapReportRecord(rec, html);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(html !== null ? row : rowSansHtml))\n .execute();\n }\n\n const reaped: ReapSummary = { sites: [], reports: [], refusals: [] };\n\n // Sites first. No foreign keys are declared, so the health and schedule rows\n // must be removed explicitly — parity only reverse-checks `sites`, so an\n // orphan left here would linger forever, unnoticed and still readable.\n const liveSiteIds = new Set(mapped.map((m) => m.site.id));\n const storedSites = (await db.selectFrom(\"sites\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleSites = storedSites.filter((id) => !liveSiteIds.has(id)).sort();\n const siteRefusal = reapRefusal(\"sites\", websites.length, storedSites.length, staleSites.length);\n if (siteRefusal) reaped.refusals.push(siteRefusal);\n else if (staleSites.length > 0) {\n await db.deleteFrom(\"site_health\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"site_schedule\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"sites\").where(\"id\", \"in\", staleSites).execute();\n reaped.sites = staleSites;\n }\n\n const liveReportIds = new Set(reports.map((r) => r.id));\n const storedReports = (await db.selectFrom(\"reports\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleReports = storedReports.filter((id) => !liveReportIds.has(id)).sort();\n const reportRefusal = reapRefusal(\n \"reports\",\n reports.length,\n storedReports.length,\n staleReports.length,\n );\n if (reportRefusal) reaped.refusals.push(reportRefusal);\n else if (staleReports.length > 0) {\n await db.deleteFrom(\"reports\").where(\"id\", \"in\", staleReports).execute();\n reaped.reports = staleReports;\n }\n\n return {\n sites: mapped.length,\n reports: reports.length,\n renderedHtmlMisses: misses,\n renderedHtmlFetched: fetched,\n renderedHtmlSkipped: skipped,\n reaped,\n };\n}\n"],"mappings":";;;;;;;;;AA8BO,IAAM,0BAA+C,oBAAI,IAAI;AAAA;AAAA,EAElE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA,EAGA;AACF,CAAC;AAED,IAAM,IAAI,CAAC,MAA8B;AACvC,MAAI,MAAM,UAAa,MAAM,KAAM,QAAO;AAC1C,MAAI,OAAO,MAAM,SAAU,QAAO,EAAE,KAAK,MAAM,KAAK,OAAO;AAC3D,SAAO,OAAO,CAAC;AACjB;AACA,IAAM,IAAI,CAAC,MAA+B,OAAO,MAAM,WAAW,IAAI;AACtE,IAAM,MAAM,CAAC,MAAwB,MAAM,OAAO,IAAI;AACtD,IAAM,OAAO,CAAC,MAA+B,OAAO,MAAM,YAAa,IAAI,IAAI,IAAK;AACpF,IAAM,OAAO,CAAC,MAA+B,MAAM,SAAY,OAAO,KAAK,UAAU,CAAC;AAK/E,IAAM,cAAgD;AAAA,EAC3D,MAAM;AAAA,EACN,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,oBAAoB;AAAA,EACpB,oBAAoB;AAAA;AAAA,EACpB,gBAAgB;AAAA,EAChB,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,2BAA2B;AAAA,EAC3B,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,0BAA0B;AAAA,EAC1B,0BAA0B;AAAA,EAC1B,qBAAgB;AAAA,EAChB,uBAAkB;AAAA,EAClB,sBAAiB;AAAA,EACjB,sBAAsB;AAAA,EACtB,qBAAqB;AAAA,EACrB,yBAAyB;AAAA,EACzB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,eAAe;AAAA;AAAA;AAAA;AAAA,EAIf,qBAAqB;AAAA,EACrB,6BAA6B;AAC/B;AAIA,SAAS,aAAa,KAAwB;AAC5C,MAAI,MAAM,QAAQ,GAAG,GAAG;AACtB,WAAO,IACJ,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ,EAChD,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AAAA,EACnB;AACA,MAAI,OAAO,QAAQ,UAAU;AAC3B,WAAO,IACJ,MAAM,OAAO,EACb,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AAAA,EACnB;AACA,SAAO,CAAC;AACV;AAYO,SAAS,aAAa,QAA0B,KAAsC;AAC3F,MAAI,WAAW,oBAAqB,QAAO,IAAI,GAAG;AAClD,MAAI,WAAW,6BAA6B;AAC1C,UAAM,MAAM,aAAa,GAAG;AAC5B,WAAO,IAAI,SAAS,IAAI,KAAK,UAAU,GAAG,IAAI;AAAA,EAChD;AACA,SAAO,EAAE,GAAG;AACd;AAMO,IAAM,gBAAuD;AAAA,EAClE,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,4BAA4B;AAAA,EAC5B,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,iBAAiB;AAAA,EACjB,uBAAuB;AAAA,EACvB,2BAA2B;AAAA,EAC3B,uBAAuB;AAAA,EACvB,2BAA2B;AAAA,EAC3B,sBAAsB;AAAA,EACtB,0BAA0B;AAAA,EAC1B,uBAAuB;AAAA,EACvB,8BAA8B;AAAA,EAC9B,0BAA0B;AAAA,EAC1B,uBAAuB;AAAA,EACvB,qBAAqB;AAAA,EACrB,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,iBAAiB;AAAA,EACjB,oBAAoB;AAAA,EACpB,8BAA8B;AAAA,EAC9B,gBAAgB;AAAA,EAChB,sBAAsB;AAAA,EACtB,oBAAoB;AAAA,EACpB,oBAAoB;AAAA,EACpB,YAAY;AAAA,EACZ,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,uBAAuB;AAAA,EACvB,wBAAwB;AAAA,EACxB,qBAAqB;AAAA,EACrB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,kBAAkB;AAAA,EAClB,6BAA6B;AAAA,EAC7B,wBAAwB;AAC1B;AAGA,IAAM,iBAAqD,oBAAI,IAAI;AAAA,EACjE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAGM,IAAM,iBAAwD;AAAA,EACnE,mBAAmB;AAAA,EACnB,aAAa;AAAA,EACb,YAAY;AACd;AAGO,IAAM,kBAA2D;AAAA,EACtE,uBAAuB;AAAA,EACvB,mBAAmB;AACrB;AAQO,SAAS,gBACd,OACuF;AACvF,QAAM,SAAS,cAAc,KAAK;AAClC,MAAI,OAAQ,QAAO,EAAE,KAAK,QAAQ,QAAQ,eAAe,IAAI,MAAM,IAAI,IAAI,EAAE;AAC7E,QAAM,OAAO,eAAe,KAAK;AACjC,MAAI,KAAM,QAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;AAC3C,SAAO;AACT;AAGO,SAAS,kBACd,OACgF;AAChF,QAAM,MAAM,gBAAgB,KAAK;AACjC,SAAO,MAAM,EAAE,KAAK,QAAQ,EAAE,IAAI;AACpC;AAYO,SAAS,iBAAiB,KAAgB,YAAmC;AAClF,QAAM,IAAI,IAAI;AACd,QAAM,OAAO,EAAE,EAAE,MAAM,CAAC;AACxB,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,YAAY,IAAI,EAAE,0CAAqC;AAClF,QAAM,OAAO,SAAS,IAAI;AAC1B,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,YAAY,IAAI,EAAE,WAAW,IAAI,wBAAwB;AAEpF,QAAM,OAA8B;AAAA,IAClC,IAAI,IAAI;AAAA,IACR;AAAA,IACA;AAAA,IACA,KAAK;AAAA,IACL,QAAQ;AAAA,IACR,kBAAkB;AAAA,IAClB,kBAAkB;AAAA,IAClB,cAAc;AAAA,IACd,iBAAiB;AAAA,IACjB,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,cAAc;AAAA,IACd,yBAAyB;AAAA,IACzB,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,sBAAsB;AAAA,IACtB,sBAAsB;AAAA,IACtB,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,aAAa;AAAA,IACb,oBAAoB;AAAA,IACpB,mBAAmB;AAAA,IACnB,uBAAuB;AAAA,IACvB,gBAAgB;AAAA,IAChB,mBAAmB;AAAA,IACnB,2BAA2B;AAAA,IAC3B,mBAAmB;AAAA,IACnB,aAAa;AAAA,IACb,QAAQ;AAAA,EACV;AAKA,aAAW,CAAC,OAAO,GAAG,KAAK,OAAO,QAAQ,WAAW,GAAG;AACtD,QAAI,QAAQ,OAAQ;AACpB,IAAC,KAA4C,GAAG,IAAI,aAAa,KAAK,EAAE,KAAK,CAAC;AAAA,EAChF;AAEA,QAAM,SAAS,EAAE,SAAS,IAAI,GAAG;AAGjC,aAAW,SAAS,CAAC,GAAG,OAAO,KAAK,aAAa,GAAG,GAAG,OAAO,KAAK,cAAc,CAAC,GAAG;AACnF,UAAM,IAAI,gBAAgB,KAAK;AAC/B,QAAI,EAAG,CAAC,OAA8C,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC;AAAA,EAClF;AAEA,QAAM,WAA8B;AAAA,IAClC,SAAS,IAAI;AAAA,IACb,qBAAqB,EAAE,EAAE,qBAAqB,CAAC;AAAA,IAC/C,iBAAiB,EAAE,EAAE,iBAAiB,CAAC;AAAA,IACvC,aAAa;AAAA,EACf;AAGA,QAAM,UAAU,oBAAI,IAAY;AAAA,IAC9B;AAAA,IACA,GAAG,OAAO,KAAK,WAAW;AAAA,IAC1B,GAAG,OAAO,KAAK,aAAa;AAAA,IAC5B,GAAG,OAAO,KAAK,cAAc;AAAA,IAC7B,GAAG,OAAO,KAAK,eAAe;AAAA,EAChC,CAAC;AACD,QAAM,SAAkC,CAAC;AACzC,aAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,CAAC,GAAG;AAC9C,QAAI,QAAQ,IAAI,KAAK,KAAK,wBAAwB,IAAI,KAAK,EAAG;AAC9D,WAAO,KAAK,IAAI;AAAA,EAClB;AACA,OAAK,SAAS,OAAO,KAAK,MAAM,EAAE,SAAS,IAAI,KAAK,UAAU,MAAM,IAAI;AAExE,SAAO,EAAE,MAAM,QAAQ,SAAS;AAClC;AAGA,IAAM,qBAAoE;AAAA,EACxE,GAAG;AAAA,EACH,GAAG;AACL,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,KAAK,EAAE,IAAI,EAAE;AAKtC,SAAS,gBAAgB,KAAgB,cAA2C;AACzF,QAAM,IAAI,IAAI;AACd,QAAM,YAAa,EAAE,MAAM,KAA8B,CAAC;AAC1D,QAAM,YAAqC,CAAC;AAC5C,aAAW,EAAE,OAAO,IAAI,KAAK,mBAAoB,WAAU,GAAG,IAAI,QAAQ,EAAE,KAAK,CAAC;AAElF,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,SAAS,UAAU,CAAC,KAAK;AAAA,IACzB,WAAW,EAAE,EAAE,WAAW,CAAC;AAAA,IAC3B,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,QAAQ,EAAE,EAAE,QAAQ,CAAC;AAAA,IACrB,cAAc,EAAE,EAAE,cAAc,CAAC;AAAA,IACjC,YAAY,EAAE,EAAE,YAAY,CAAC;AAAA,IAC7B,cAAc,EAAE,EAAE,cAAc,CAAC;AAAA,IACjC,wBAAwB,EAAE,EAAE,+BAA0B,CAAC;AAAA,IACvD,0BAA0B,EAAE,EAAE,iCAA4B,CAAC;AAAA,IAC3D,2BAA2B,EAAE,EAAE,kCAA6B,CAAC;AAAA,IAC7D,gBAAgB,EAAE,EAAE,uBAAkB,CAAC;AAAA,IACvC,kBAAkB,EAAE,EAAE,mBAAmB,CAAC;AAAA,IAC1C,mBAAmB,EAAE,EAAE,wBAAwB,CAAC;AAAA,IAChD,oBAAoB,KAAK,EAAE,qBAAqB,CAAC;AAAA,IACjD,iBAAiB,EAAE,EAAE,iBAAiB,CAAC;AAAA,IACvC,kBAAkB,EAAE,EAAE,kBAAkB,CAAC;AAAA,IACzC,YAAY,EAAE,EAAE,YAAY,CAAC;AAAA,IAC7B,kBAAkB,EAAE,EAAE,kBAAkB,CAAC;AAAA,IACzC,aAAa,IAAI,EAAE,aAAa,CAAC;AAAA,IACjC,kBAAkB,IAAI,EAAE,kBAAkB,CAAC;AAAA,IAC3C,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,eAAe,IAAI,EAAE,eAAe,CAAC;AAAA,IACrC,iBAAiB,EAAE,EAAE,iBAAiB,CAAC;AAAA,IACvC,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,SAAS,EAAE,EAAE,SAAS,CAAC;AAAA,IACvB,iBAAiB,EAAE,EAAE,iBAAiB,CAAC,KAAK;AAAA,IAC5C,mBAAmB,EAAE,EAAE,mBAAmB,CAAC;AAAA,IAC3C,WAAW,KAAK,UAAU,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnC,yBACE,OAAO,EAAE,yBAAyB,MAAM,WACpC,EAAE,EAAE,yBAAyB,CAAC,IAC9B,KAAK,EAAE,yBAAyB,KAAK,MAAS;AAAA,IACpD,eAAe;AAAA,EACjB;AACF;AAIO,SAAS,gBAAgB,KAA+B;AAC7D,QAAM,OAAO,IAAI,OAAO,eAAe;AACvC,QAAM,MAAM,OAAO,CAAC,GAAG;AACvB,SAAO,OAAO,QAAQ,YAAY,IAAI,SAAS,IAAI,MAAM;AAC3D;AAmBA,IAAM,aAAa;AACnB,IAAM,gBAAgB;AAEf,SAAS,cAAc,QAAwB;AACpD,SAAO,KAAK,IAAI,YAAY,KAAK,KAAK,SAAS,aAAa,CAAC;AAC/D;AAOO,SAAS,kBAAkB,QAA+B;AAC/D,QAAM,QAAkB,CAAC;AACzB,aAAW,MAAM,OAAO,OAAO;AAC7B,UAAM,KAAK,uBAAkB,EAAE,4DAAuD;AAAA,EACxF;AACA,aAAW,MAAM,OAAO,SAAS;AAC/B,UAAM,KAAK,yBAAoB,EAAE,+BAA0B;AAAA,EAC7D;AACA,aAAW,UAAU,OAAO,SAAU,OAAM,KAAK,UAAK,MAAM,EAAE;AAC9D,QAAM;AAAA,IACJ,oBAAoB,OAAO,MAAM,MAAM,YAAY,OAAO,QAAQ,MAAM,YAC3D,OAAO,SAAS,MAAM;AAAA,EACrC;AACA,SAAO;AACT;AAGO,SAAS,YACd,OACA,UACA,QACA,QACe;AACf,MAAI,aAAa,KAAK,SAAS,GAAG;AAChC,WAAO,GAAG,KAAK,2DAAsD,MAAM;AAAA,EAC7E;AACA,QAAM,UAAU,cAAc,MAAM;AACpC,MAAI,SAAS,SAAS;AACpB,WAAO,GAAG,KAAK,4BAAuB,MAAM,6CAA6C,OAAO,sBAAsB,MAAM;AAAA,EAC9H;AACA,SAAO;AACT;AA6CA,eAAsB,iBACpB,IACA,IACA,OAAsB,CAAC,GACC;AACxB,QAAM,aAAa,GAAG,IAAI,EAAE,YAAY;AACxC,QAAM,WAAW,MAAM,GAAG,mBAAmB;AAE7C,QAAM,SAAS,SAAS,IAAI,CAAC,MAAM,iBAAiB,GAAG,UAAU,CAAC;AAClE,QAAM,YAAY,oBAAI,IAAoB;AAC1C,aAAW,KAAK,QAAQ;AACtB,UAAM,QAAQ,UAAU,IAAI,EAAE,KAAK,IAAI;AACvC,QAAI;AACF,YAAM,IAAI,MAAM,mBAAmB,KAAK,QAAQ,EAAE,KAAK,EAAE,iBAAiB,EAAE,KAAK,IAAI,GAAG;AAC1F,cAAU,IAAI,EAAE,KAAK,MAAM,EAAE,KAAK,EAAE;AAAA,EACtC;AAEA,aAAW,EAAE,MAAM,QAAQ,SAAS,KAAK,QAAQ;AAC/C,UAAM,GACH,WAAW,OAAO,EAClB,OAAO,IAAI,EACX,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,IAAI,CAAC,EACpD,QAAQ;AACX,UAAM,GACH,WAAW,aAAa,EACxB,OAAO,MAAM,EACb,WAAW,CAAC,OAAO,GAAG,OAAO,SAAS,EAAE,YAAY,MAAM,CAAC,EAC3D,QAAQ;AACX,UAAM,GACH,WAAW,eAAe,EAC1B,OAAO,QAAQ,EACf,WAAW,CAAC,OAAO,GAAG,OAAO,SAAS,EAAE,YAAY,QAAQ,CAAC,EAC7D,QAAQ;AAAA,EACb;AAEA,QAAM,UAAU,MAAM,GAAG,kBAAkB;AAK3C,QAAM,gBACJ,KAAK,eAAe,iBAChB,IAAI;AAAA,KAEA,MAAM,GACH,WAAW,SAAS,EACpB,OAAO,IAAI,EACX,MAAM,iBAAiB,UAAU,IAAI,EACrC,QAAQ,GACX,IAAI,CAAC,MAAM,EAAE,EAAE;AAAA,EACnB,IACA;AACN,QAAM,SAAmB,CAAC;AAC1B,MAAI,UAAU;AACd,MAAI,UAAU;AACd,aAAW,OAAO,SAAS;AACzB,UAAM,MAAM,gBAAgB,GAAG;AAC/B,UAAM,OAAO,QAAQ,QAAQ,kBAAkB,QAAQ,cAAc,IAAI,IAAI,EAAE;AAC/E,QAAI,KAAM;AACV,UAAM,OAAO,OAAO,CAAC,OAAO,MAAM,GAAG,gBAAgB,GAAG,IAAI;AAC5D,QAAI,OAAO,CAAC,QAAQ,SAAS,KAAM,QAAO,KAAK,IAAI,EAAE;AACrD,QAAI,SAAS,KAAM;AACnB,UAAM,MAAM,gBAAgB,KAAK,IAAI;AACrC,UAAM,EAAE,eAAe,KAAK,GAAG,YAAY,IAAI;AAC/C,SAAK;AACL,UAAM,GACH,WAAW,SAAS,EACpB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,SAAS,OAAO,MAAM,WAAW,CAAC,EACjF,QAAQ;AAAA,EACb;AAEA,QAAM,SAAsB,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC,GAAG,UAAU,CAAC,EAAE;AAKnE,QAAM,cAAc,IAAI,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;AACxD,QAAM,eAAe,MAAM,GAAG,WAAW,OAAO,EAAE,OAAO,IAAI,EAAE,QAAQ,GAAG;AAAA,IAAI,CAAC,MAC7E,OAAO,EAAE,EAAE;AAAA,EACb;AACA,QAAM,aAAa,YAAY,OAAO,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,KAAK;AACzE,QAAM,cAAc,YAAY,SAAS,SAAS,QAAQ,YAAY,QAAQ,WAAW,MAAM;AAC/F,MAAI,YAAa,QAAO,SAAS,KAAK,WAAW;AAAA,WACxC,WAAW,SAAS,GAAG;AAC9B,UAAM,GAAG,WAAW,aAAa,EAAE,MAAM,WAAW,MAAM,UAAU,EAAE,QAAQ;AAC9E,UAAM,GAAG,WAAW,eAAe,EAAE,MAAM,WAAW,MAAM,UAAU,EAAE,QAAQ;AAChF,UAAM,GAAG,WAAW,OAAO,EAAE,MAAM,MAAM,MAAM,UAAU,EAAE,QAAQ;AACnE,WAAO,QAAQ;AAAA,EACjB;AAEA,QAAM,gBAAgB,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AACtD,QAAM,iBAAiB,MAAM,GAAG,WAAW,SAAS,EAAE,OAAO,IAAI,EAAE,QAAQ,GAAG;AAAA,IAAI,CAAC,MACjF,OAAO,EAAE,EAAE;AAAA,EACb;AACA,QAAM,eAAe,cAAc,OAAO,CAAC,OAAO,CAAC,cAAc,IAAI,EAAE,CAAC,EAAE,KAAK;AAC/E,QAAM,gBAAgB;AAAA,IACpB;AAAA,IACA,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,aAAa;AAAA,EACf;AACA,MAAI,cAAe,QAAO,SAAS,KAAK,aAAa;AAAA,WAC5C,aAAa,SAAS,GAAG;AAChC,UAAM,GAAG,WAAW,SAAS,EAAE,MAAM,MAAM,MAAM,YAAY,EAAE,QAAQ;AACvE,WAAO,UAAU;AAAA,EACnB;AAEA,SAAO;AAAA,IACL,OAAO,OAAO;AAAA,IACd,SAAS,QAAQ;AAAA,IACjB,oBAAoB;AAAA,IACpB,qBAAqB;AAAA,IACrB,qBAAqB;AAAA,IACrB;AAAA,EACF;AACF;","names":[]}
@@ -10,7 +10,7 @@ import {
10
10
  deleteBranch,
11
11
  forceCheckoutBranch,
12
12
  isWorkingTreeClean
13
- } from "./chunk-AVEQUR44.js";
13
+ } from "./chunk-WMF5S2MB.js";
14
14
 
15
15
  // src/recipes/_with-recipe.ts
16
16
  async function withRecipe(body) {
@@ -118,4 +118,4 @@ async function withRecipe(body) {
118
118
  export {
119
119
  withRecipe
120
120
  };
121
- //# sourceMappingURL=chunk-ZQV7UQNE.js.map
121
+ //# sourceMappingURL=chunk-LUF52EIY.js.map
@@ -1,3 +1,18 @@
1
+ // src/reports/header-image/geometry.ts
2
+ var CANVAS = { width: 2400, height: 3200 };
3
+ var SCREEN = { x: 309, y: 1887, w: 1347, h: 841 };
4
+ var HEADLINE = { x: 277, y: 998 };
5
+ var HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 };
6
+ var HEADLINE_INK = { r: 209, g: 34, b: 62 };
7
+ var DOMAIN = {
8
+ x: 280,
9
+ baseline: 3020,
10
+ size: 80,
11
+ weight: 400,
12
+ color: "#747474"
13
+ };
14
+ var PAPER = "#fcfcfc";
15
+
1
16
  // src/reports/header-image/assets/index.ts
2
17
  import { readFile } from "fs/promises";
3
18
  import { existsSync } from "fs";
@@ -50,6 +65,14 @@ var DEVICE_SCALE_FACTOR = 2;
50
65
  var DEFAULT_SETTLE_MS = 2500;
51
66
  var NAV_TIMEOUT_MS = 6e4;
52
67
  var IDLE_BUDGET_MS = 3e3;
68
+ var CONSENT_SELECTOR = '[class*="cookie" i],[id*="cookie" i],[class*="consent" i],[id*="consent" i]';
69
+ var CONSENT_BUTTON_NAME = /^(accept( all)?( cookies)?|allow( all)?|reject( all)?|decline|got it|ok(ay)?|i (agree|understand)|agree)$/i;
70
+ var CONSENT_CLICK_TIMEOUT_MS = 1500;
71
+ function consentHideRule(consentSelector) {
72
+ const extra = consentSelector?.trim();
73
+ const selector = extra ? `${CONSENT_SELECTOR},${extra}` : CONSENT_SELECTOR;
74
+ return `${selector}{display:none!important;visibility:hidden!important;pointer-events:none!important}`;
75
+ }
53
76
  async function captureHomepage(url, options = {}) {
54
77
  const shooter = options.shooter ?? await defaultShooter();
55
78
  return shooter.shoot({
@@ -57,7 +80,8 @@ async function captureHomepage(url, options = {}) {
57
80
  width: VIEWPORT.width,
58
81
  height: VIEWPORT.height,
59
82
  deviceScaleFactor: DEVICE_SCALE_FACTOR,
60
- settleMs: options.settleMs ?? DEFAULT_SETTLE_MS
83
+ settleMs: options.settleMs ?? DEFAULT_SETTLE_MS,
84
+ ...options.consentSelector !== void 0 ? { consentSelector: options.consentSelector } : {}
61
85
  });
62
86
  }
63
87
  async function defaultShooter() {
@@ -74,6 +98,9 @@ async function defaultShooter() {
74
98
  await page.waitForLoadState("networkidle", { timeout: IDLE_BUDGET_MS }).catch(() => {
75
99
  });
76
100
  await page.evaluate("document.fonts && document.fonts.ready");
101
+ await page.getByRole("button", { name: CONSENT_BUTTON_NAME }).first().click({ timeout: CONSENT_CLICK_TIMEOUT_MS }).catch(() => {
102
+ });
103
+ await page.addStyleTag({ content: consentHideRule(opts.consentSelector) });
77
104
  await page.waitForTimeout(opts.settleMs);
78
105
  const buf = await page.screenshot({ type: "png" });
79
106
  return new Uint8Array(buf);
@@ -86,23 +113,6 @@ async function defaultShooter() {
86
113
 
87
114
  // src/reports/header-image/compose.ts
88
115
  import sharp from "sharp";
89
-
90
- // src/reports/header-image/geometry.ts
91
- var CANVAS = { width: 2400, height: 3200 };
92
- var SCREEN = { x: 302, y: 1913, w: 1349, h: 844 };
93
- var HEADLINE = { x: 277, y: 998 };
94
- var HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 };
95
- var HEADLINE_INK = { r: 209, g: 34, b: 62 };
96
- var DOMAIN = {
97
- x: 280,
98
- baseline: 3020,
99
- size: 80,
100
- weight: 400,
101
- color: "#747474"
102
- };
103
- var PAPER = "#fcfcfc";
104
-
105
- // src/reports/header-image/compose.ts
106
116
  var JPEG_QUALITY = 88;
107
117
  function escapeXml(s) {
108
118
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -202,7 +212,7 @@ async function applyReportTypeHeadline(header, reportType) {
202
212
  const existing = await headlineInkCount(header);
203
213
  if (existing > HEADLINE_INK_TOLERANCE) {
204
214
  console.warn(
205
- `\u2691 header headline skipped: the stored header already carries a headline (${existing} ink px in the band) \u2014 it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \`header-image ${"<site>"} --write-airtable\` to regenerate it clean.`
215
+ `\u2691 header headline skipped: the stored header already carries a headline (${existing} ink px in the band) \u2014 it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \`header-image ${"<site>"} --write-back\` to regenerate it clean.`
206
216
  );
207
217
  return header;
208
218
  }
@@ -217,7 +227,8 @@ async function applyReportTypeHeadline(header, reportType) {
217
227
  async function generateHeaderImage(input) {
218
228
  const screenshot = await captureHomepage(input.url, {
219
229
  ...input.shooter !== void 0 ? { shooter: input.shooter } : {},
220
- ...input.settleMs !== void 0 ? { settleMs: input.settleMs } : {}
230
+ ...input.settleMs !== void 0 ? { settleMs: input.settleMs } : {},
231
+ ...input.consentSelector !== void 0 ? { consentSelector: input.consentSelector } : {}
221
232
  });
222
233
  await assertNotBlank(screenshot);
223
234
  const domain = domainFromUrl(input.url);
@@ -231,10 +242,12 @@ async function generateHeaderImage(input) {
231
242
  }
232
243
 
233
244
  export {
245
+ CANVAS,
246
+ SCREEN,
234
247
  loadPlate,
235
248
  loadHeadline,
236
249
  domainFromUrl,
237
250
  applyReportTypeHeadline,
238
251
  generateHeaderImage
239
252
  };
240
- //# sourceMappingURL=chunk-5UINJNAJ.js.map
253
+ //# sourceMappingURL=chunk-LZMAG73U.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/header-image/geometry.ts","../src/reports/header-image/assets/index.ts","../src/reports/header-image/index.ts","../src/reports/header-image/capture.ts","../src/reports/header-image/compose.ts"],"sourcesContent":["// Measured off the real plate, not guessed.\n//\n// SCREEN is the hole INSIDE the laptop bezel. It is derived from the bezel —\n// a perfectly flat black frame (luminance 0.0) — because that is\n// content-independent. Deriving it from content instead (diffing two headers for\n// pixels that vary) yields a rect that spans the laptop's outer panel, covering\n// the bezel on three sides, so the plate's baked-in screenshot shows as a strip\n// under every site's content.\n//\n// ⚠️ THIS RECT IS A PROPERTY OF THE PLATE ASSET, AND THE ASSET HAS MOVED ONCE.\n// The values below were correct for `plate.png` (#476) and went stale when #570\n// re-exported the Figma frame as `plate-clean.png` to drop the baked headline:\n// the laptop moved 7px right and 26px up and grew slightly. Nothing re-measured\n// SCREEN, so for three weeks every generated header left rows 1887..1912 and\n// columns 1651..1655 of the plate UNPAINTED — and what showed through there was\n// the Alamo Anatomy homepage baked into the export: a green nav bar with a\n// \"Contact Us\" pill, across the top of all 13 maintained sites' headers, in\n// client-facing maintenance reports. Found 2026-09-17 on 29 Navy's.\n//\n// Evidence that the old values were right for the old asset, and are wrong for\n// this one — the hole measured by walking outward from the screen interior to\n// the first flat-black run:\n//\n// plate.png (#476) x=302 y=1913 w=1349 ← dx=0 dy=0 dw=0 vs SCREEN\n// plate-clean.png x=309 y=1887 w=1347 ← dx=-7 dy=26 dw=2\n//\n// The old cross-check in this comment said \"the photo-to-flat-black transition\n// at the bottom lands at y=2756, exactly SCREEN.y + SCREEN.h - 1\". That is true\n// of plate.png — y=2755 is photo, y=2756 is rgb(2,2,2), y=2757 is black — and\n// false of plate-clean.png, where y=2756 sits 29 rows inside the bottom bezel's\n// black run (2728..2775). A cross-check that still passes against the wrong file\n// is not a cross-check, which is why the guard is now a test against the bundled\n// asset (tests/reports/header-image/plate-geometry.test.ts) rather than a number\n// in a comment. Re-export the plate and that test fails; it cannot go stale\n// silently the way this paragraph did.\n//\n// Re-measure only if the Figma template changes — and if you do, find the bezel.\n\n/** The plate's pixel dimensions — Figma frame 600x800 exported at 4x. */\nexport const CANVAS = { width: 2400, height: 3200 } as const;\n\n/** The MacBook Pro screen the site screenshot is pasted into — the area inside\n * the bezel, so the bezel stays visible around the content exactly as it does\n * in the hand-made headers. Aspect 1.6017 (16:10 to within 0.11%).\n *\n * Measured on plate-clean.png: content spans x 310..1655, y 1888..2727, with a\n * single anti-aliased blend pixel on the outside of the left, top and bottom\n * edges (x=309 rgb(5,10,8); y=1887 rgb(14,29,24); y=2727 rgb(27,50,74)). The\n * rect INCLUDES those blend pixels deliberately: each one is part baked\n * screenshot, so leaving it uncovered leaves a faint tinted line of the other\n * site, while covering it repaints one pixel of bezel edge and is invisible. */\nexport const SCREEN = { x: 309, y: 1887, w: 1347, h: 841 } as const;\n\n/** Where a report-type headline overlay lands on the clean plate.\n *\n * Measured, not guessed: red ink present in the baked maintenance plate but\n * absent from plate-clean spans x=277 y=998 w=1326 h=658 — and\n * headline-maintenance.png is that ink box cropped to its own edges\n * (1328x660, ±1px of anti-aliasing at the threshold), so compositing the\n * asset at this origin reproduces the baked plate's headline exactly. */\nexport const HEADLINE = { x: 277, y: 998 } as const;\n\n/** The band a headline occupies, sized to the WIDEST registered overlay\n * (Testing, 1715x664). Used to detect a header that already carries a baked-in\n * headline, so it can't be stamped a second time. Deliberately clear of the\n * logo above (ends y≈506) and the laptop screen below (starts y=1887), so the\n * only ink that can land here is a headline. */\nexport const HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 } as const;\n\n/** The headline ink colour (brand red), and how far a pixel may drift from it\n * and still count — JPEG re-encoding moves it by a level or two. */\nexport const HEADLINE_INK = { r: 209, g: 34, b: 62 } as const;\n\n/** The per-site domain line, bottom left.\n *\n * Solved against the hand-made reference rather than guessed: rendering\n * \"gallerysonder.com\" with these values reproduces Sonder's original ink box\n * (x=283 y=2963 w=645 h=74) EXACTLY — dx=0 dy=0 dw=0 dh=0. A sweep over sizes\n * 62-88 at weights 300/400 has a single exact solution at size 80 / weight 400.\n *\n * `baseline` is the text baseline in canvas pixels; the ink box sits above it\n * by the cap height and below by the descender. */\nexport const DOMAIN = {\n x: 280,\n baseline: 3020,\n size: 80,\n weight: 400,\n color: \"#747474\",\n} as const;\n\n/** Flat paper tone, used as the compositing background so a transparent or\n * short source can never punch a hole in the plate. */\nexport const PAPER = \"#fcfcfc\" as const;\n","import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PLATE = \"plate-clean.png\";\n\n/** Per-report-type headline overlays, stamped over the CLEAN plate at send time\n * (see orchestrate.ts). Types with no entry go out on the clean plate.\n *\n * Source: Figma \"Web-Maintenance-Email\" (mQ3hy2d9JnOG9ljCzbZS8j) —\n * Maintenance is node 158:15, Testing is 153:721 (\"maintenance & testing\",\n * which is what a Testing report actually covers). Export each at 4x; Figma\n * crops a text export to its ink box, which is why these are 1328x660 /\n * 1715x664 rather than the 1840x720 text box.\n *\n * ⚠️ A Figma MCP `download_assets` export arrives FLATTENED — never\n * transparent — and it flattens onto WHATEVER SITS BEHIND THE NODE, which is\n * not one fixed colour: Maintenance/Testing live inside a white Header frame\n * and come back on white, while Announcement/Launch sit on the bare canvas and\n * come back on Figma's canvas grey (30,30,30). Stamping either unprocessed\n * paints a solid slab over the paper texture — that, not a bad design file, is\n * what made the 2026-08-20 Testing asset unusable.\n *\n * Recover the alpha instead of re-exporting by hand: for ink C over backdrop B,\n * a = (px - B) / (C - B). DETECT B (the export's most common colour) rather\n * than assuming white, and pick the channel with the largest |C - B| — green\n * separates the brand red from white by 221 levels but from canvas grey by only\n * 4, which would quantise the alpha to noise. Verified against the known-good\n * Maintenance asset at mean abs alpha diff 0.022, 0.0037% of pixels off by\n * more than 8.\n *\n * Editing this copy needs Figma desktop, not the Plugin API: `characters`\n * requires loadFontAsync and the MCP font environment carries no Helvetica at\n * all, so the licensed Helvetica Neue LT Std can never load there. */\nconst HEADLINE_FILES = {\n Maintenance: \"headline-maintenance.png\",\n Testing: \"headline-testing.png\",\n Announcement: \"headline-announcement.png\",\n Launch: \"headline-launch.png\",\n} as const;\n\nexport type HeadlineKind = keyof typeof HEADLINE_FILES;\n\n/** The headline kind for a report type, or null for types that ship clean. */\nexport function headlineKindFor(reportType: string): HeadlineKind | null {\n return Object.hasOwn(HEADLINE_FILES, reportType) ? (reportType as HeadlineKind) : null;\n}\n\n// Walk up from this module's URL looking for the assets dir in either the dev\n// layout (src/reports/header-image/assets/) or the published layout\n// (dist/reports/header-image/assets/). REQUIRED because tsup inlines this module\n// into dist/cli/bin.js — so import.meta.url-based sibling resolution looks in\n// dist/cli/ and fails with ENOENT. This exact bug shipped in 0.10.0-0.10.1 for\n// the maintenance-email assets; tests passed in dev because vitest evaluates the\n// source file, where import.meta.url is already correct. Only `pnpm test:dist`\n// catches a regression here.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n for (;;) {\n for (const layout of [\"src\", \"dist\"]) {\n const candidate = join(dir, layout, \"reports\", \"header-image\", \"assets\", PLATE);\n if (existsSync(candidate)) {\n cachedAssetsDir = dirname(candidate);\n return cachedAssetsDir;\n }\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadPlate: could not locate the header-image assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/** Read the bundled 2400x3200 CLEAN plate (no headline — the report type's\n * headline is stamped at send time, so one stored image serves every type). */\nexport async function loadPlate(): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));\n}\n\n/** Read a headline overlay (transparent PNG, ink-box cropped). */\nexport async function loadHeadline(kind: HeadlineKind): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), HEADLINE_FILES[kind])));\n}\n","import sharp from \"sharp\";\nimport { captureHomepage, type Shooter } from \"./capture.js\";\nimport { composeHeaderImage, stampHeadline, headlineInkCount } from \"./compose.js\";\nimport { loadPlate, loadHeadline, headlineKindFor } from \"./assets/index.js\";\nimport { CANVAS } from \"./geometry.js\";\n\n/** Headline-red pixels tolerated in the band before a stored header is treated\n * as already carrying a headline. A clean-plate header measures 0 and a baked\n * one ~62,000, so this sits far from both — it exists only to absorb stray\n * JPEG artefacts, not to tune a boundary. */\nconst HEADLINE_INK_TOLERANCE = 500;\n\nexport type GenerateInput = {\n /** The site's production URL. */\n url: string;\n /** Airtable site slug; drives the attachment filename. */\n slug?: string;\n /** Injected browser IO (tests). */\n shooter?: Shooter;\n /** Per-site settle override for animation-heavy homepages. */\n settleMs?: number;\n /** Extra CSS selector(s) hidden before the shutter, for consent or\n * interstitial UI the capture's class/id heuristic misses (#654). */\n consentSelector?: string;\n};\n\nexport type GeneratedHeaderImage = {\n bytes: Uint8Array;\n /** The domain as printed on the plate. */\n domain: string;\n filename: string;\n contentType: \"image/jpeg\";\n};\n\n/** Strip scheme, leading www and any path — the plate prints a bare domain. */\nexport function domainFromUrl(url: string): string {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n}\n\n/**\n * A capture is \"blank\" when it is almost entirely near-white — a consent gate,\n * a failed render (blank canvas), or a shot fired before first paint. The\n * operator reviews the rendered email before sending, so this is a backstop\n * rather than the only gate; its job is to stop an obviously broken shot from\n * replacing a good stored header.\n *\n * Uniformity alone is NOT the signal: a homepage with a large solid-colour\n * hero (any brand colour) is legitimately uniform but not blank. Only\n * near-white uniformity is flagged, since every real failure mode here\n * (consent overlay, unrendered canvas, pre-paint flash) lands on a white\n * background.\n */\nasync function assertNotBlank(shot: Uint8Array): Promise<void> {\n const { data, info } = await sharp(Buffer.from(shot))\n .resize(64, 40, { fit: \"fill\" })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n const total = info.width * info.height;\n const at = (i: number): number => data[i] ?? 0;\n\n let sumR = 0;\n let sumG = 0;\n let sumB = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n sumR += at(i);\n sumG += at(i + 1);\n sumB += at(i + 2);\n }\n const meanR = sumR / total;\n const meanG = sumG / total;\n const meanB = sumB / total;\n\n let near = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n if (\n Math.abs(at(i) - meanR) < 8 &&\n Math.abs(at(i + 1) - meanG) < 8 &&\n Math.abs(at(i + 2) - meanB) < 8\n ) {\n near++;\n }\n }\n\n const uniform = near / total > 0.95;\n const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;\n if (uniform && nearWhite) {\n throw new Error(\n \"header-image: capture looks blank (near-white, >95% one colour) — keeping the existing header\",\n );\n }\n}\n\n/**\n * Stamp the report type's headline onto a stored (clean) header at send time.\n * Types with no headline registered pass through untouched, as does any header\n * that must not be stamped:\n *\n * - wrong dimensions (a legacy hand-made header), where fixed coordinates\n * would misplace the text;\n * - a header that ALREADY CARRIES A HEADLINE, i.e. one built on the old baked\n * plate. Stamping those prints the new headline directly over the baked one\n * and the two overprint into unreadable pulp — which shipped in a real\n * announcement on 2026-08-24. An earlier version of this comment claimed\n * such headers were safe because they'd \"keep their baked headline\"; that\n * was wrong. They are canvas-sized, so nothing stopped them being stamped.\n *\n * The band is empty on a clean-plate header and holds ~62k red px on a baked\n * one, so the check is unambiguous rather than a tuned threshold. A skipped\n * header still goes out looking correct (its baked headline is a real headline)\n * and self-heals on the site's next draft, which regenerates it clean;\n * `header-image --all --force` does the whole fleet at once.\n */\nexport async function applyReportTypeHeadline(\n header: Uint8Array,\n reportType: string,\n): Promise<Uint8Array> {\n const kind = headlineKindFor(reportType);\n if (!kind) return header;\n // Best-effort by design: the headline is cosmetic, so anything unexpected —\n // undecodable bytes included — warns and sends the header as stored rather\n // than failing the send. Mandatory processing (and its hard failures) lives\n // in prepareHeaderImage downstream.\n try {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n console.warn(\n `⚑ header headline skipped: stored header is ${meta.width}x${meta.height}, not ${CANVAS.width}x${CANVAS.height} — sending it as stored`,\n );\n return header;\n }\n const existing = await headlineInkCount(header);\n if (existing > HEADLINE_INK_TOLERANCE) {\n console.warn(\n `⚑ header headline skipped: the stored header already carries a headline (${existing} ink px in the band) — it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \\`header-image ${\"<site>\"} --write-back\\` to regenerate it clean.`,\n );\n return header;\n }\n return await stampHeadline(header, await loadHeadline(kind));\n } catch (err) {\n console.warn(\n `⚑ header headline skipped (${err instanceof Error ? err.message : String(err)}) — sending the header as stored`,\n );\n return header;\n }\n}\n\n/** Capture a site's homepage and composite its header image. */\nexport async function generateHeaderImage(input: GenerateInput): Promise<GeneratedHeaderImage> {\n const screenshot = await captureHomepage(input.url, {\n ...(input.shooter !== undefined ? { shooter: input.shooter } : {}),\n ...(input.settleMs !== undefined ? { settleMs: input.settleMs } : {}),\n ...(input.consentSelector !== undefined ? { consentSelector: input.consentSelector } : {}),\n });\n await assertNotBlank(screenshot);\n\n const domain = domainFromUrl(input.url);\n const bytes = await composeHeaderImage({\n plate: await loadPlate(),\n screenshot,\n domain,\n });\n const base = input.slug ?? domain.split(\".\")[0];\n return { bytes, domain, filename: `${base}Header.jpg`, contentType: \"image/jpeg\" };\n}\n","/** Viewport matching the plate's MacBook screen aspect (16:10), so the crop in\n * compose.ts is a no-op for a well-behaved homepage. */\nconst VIEWPORT = { width: 1600, height: 1000 } as const;\n/** 2x so the 1349px-wide screen rect is fed real pixels, not upscaled ones. */\nconst DEVICE_SCALE_FACTOR = 2;\n/** Entrance animations and webfonts settle before the shutter. Measured against\n * live fleet sites; overridable per site because consent gates and long\n * animations vary. */\nconst DEFAULT_SETTLE_MS = 2500;\n/** Budget for the `load` milestone — the navigation we actually gate on.\n *\n * Do NOT put `waitUntil: \"networkidle\"` back on the `goto`. Measured across all\n * 14 live fleet sites (30s timeout): `load` succeeded on 14/14 in 346–1205ms,\n * while `networkidle` NEVER fired on 4 of them (ERP Industrials, Vineyard,\n * Revogen, 1836dig) — chat widgets, analytics polling and websockets hold the\n * connection open forever, so `goto` threw and those sites could never get a\n * header image at all. */\nconst NAV_TIMEOUT_MS = 60_000;\n/** Best-effort extra wait for network idle, applied *after* `load` and with its\n * rejection swallowed. The 10 sites that do settle still get the benefit; the 4\n * that never will just proceed. 3s is sized from the measurement above: the\n * slowest site that does reach idle took 4078ms from `goto` start, and by the\n * time this runs the `load` milestone (~0.3–1.2s) has already passed, so 3s\n * covers the real remaining settle time without stalling the never-idle sites. */\nconst IDLE_BUDGET_MS = 3_000;\n\n/** Attribute heuristic for a cookie / consent banner: any element whose class or\n * id mentions \"cookie\" or \"consent\", case-insensitively. Sonder's header shipped\n * its consent panel and the panel's scrim over the hero (#654) — the banner\n * never leaves on its own, so settle time was irrelevant and hiding it is the\n * whole fix. Kept narrow on purpose: a broader net (e.g. \"modal\", \"overlay\")\n * would start hiding hero art on sites that use those words for content. */\nexport const CONSENT_SELECTOR =\n '[class*=\"cookie\" i],[id*=\"cookie\" i],[class*=\"consent\" i],[id*=\"consent\" i]';\n\n/** Accessible names of the buttons a consent banner offers. Clicking one lets the\n * site run its own dismissal, which is the only thing that reliably removes a\n * scrim living OUTSIDE the banner element (a `body::before`, a sibling overlay). */\nexport const CONSENT_BUTTON_NAME =\n /^(accept( all)?( cookies)?|allow( all)?|reject( all)?|decline|got it|ok(ay)?|i (agree|understand)|agree)$/i;\n\n/** Budget for the best-effort consent click. A site with no banner has no button,\n * so the click times out on every capture there — it must be short, and it is\n * swallowed. Sized so the 14 consent-free fleet sites pay ≤1.5s each. */\nconst CONSENT_CLICK_TIMEOUT_MS = 1_500;\n\n/**\n * The CSS rule that hides consent UI before the shutter. `consentSelector` is a\n * per-site addition for banners the heuristic misses (a newsletter interstitial,\n * a GDPR shield with a bespoke class); it is joined onto the heuristic, never a\n * replacement for it. Pure, so it can be tested without a browser.\n */\nexport function consentHideRule(consentSelector?: string): string {\n const extra = consentSelector?.trim();\n const selector = extra ? `${CONSENT_SELECTOR},${extra}` : CONSENT_SELECTOR;\n return `${selector}{display:none!important;visibility:hidden!important;pointer-events:none!important}`;\n}\n\nexport type ShootOptions = {\n url: string;\n width: number;\n height: number;\n deviceScaleFactor: number;\n settleMs: number;\n /** Extra CSS selector(s) hidden before the shutter, for a site whose consent\n * or interstitial UI the class/id heuristic misses. */\n consentSelector?: string;\n};\n\n/** Injected browser IO. The real impl drives Playwright; tests pass a fake. */\nexport type Shooter = {\n shoot: (opts: ShootOptions) => Promise<Uint8Array>;\n};\n\nexport type CaptureOptions = {\n shooter?: Shooter;\n settleMs?: number;\n consentSelector?: string;\n};\n\n/**\n * Screenshot a site's homepage for the header image.\n *\n * Viewport-only, never `fullPage`: the plate shows a laptop screen, not a\n * scroll. Failures propagate — a caller must be able to keep the site's existing\n * header rather than overwrite it with a broken shot.\n */\nexport async function captureHomepage(\n url: string,\n options: CaptureOptions = {},\n): Promise<Uint8Array> {\n const shooter = options.shooter ?? (await defaultShooter());\n return shooter.shoot({\n url,\n width: VIEWPORT.width,\n height: VIEWPORT.height,\n deviceScaleFactor: DEVICE_SCALE_FACTOR,\n settleMs: options.settleMs ?? DEFAULT_SETTLE_MS,\n ...(options.consentSelector !== undefined ? { consentSelector: options.consentSelector } : {}),\n });\n}\n\n/** Real Playwright shooter. Lazily imported so unit tests never load it and the\n * static import graph stays central-dep-free for `test:dist`. */\nexport async function defaultShooter(): Promise<Shooter> {\n const { chromium } = await import(\"@playwright/test\");\n return {\n async shoot(opts) {\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({\n viewport: { width: opts.width, height: opts.height },\n deviceScaleFactor: opts.deviceScaleFactor,\n });\n await page.goto(opts.url, { waitUntil: \"load\", timeout: NAV_TIMEOUT_MS });\n // Best-effort only — a site that never idles must still be captured.\n await page.waitForLoadState(\"networkidle\", { timeout: IDLE_BUDGET_MS }).catch(() => {});\n await page.evaluate(\"document.fonts && document.fonts.ready\");\n // Dismiss consent UI, strictly best-effort — a header capture must never\n // fail because a site has no banner. The click goes FIRST: once the rule\n // below hides the button it is no longer actionable, and the site's own\n // dismissal is what unwinds a scrim that lives outside the banner. Then\n // the style tag catches a banner with no matching button, or one that\n // fades out slower than the settle. Both run before the settle wait so\n // the settle absorbs whatever animation the dismissal starts.\n await page\n .getByRole(\"button\", { name: CONSENT_BUTTON_NAME })\n .first()\n .click({ timeout: CONSENT_CLICK_TIMEOUT_MS })\n .catch(() => {});\n await page.addStyleTag({ content: consentHideRule(opts.consentSelector) });\n await page.waitForTimeout(opts.settleMs);\n const buf = await page.screenshot({ type: \"png\" });\n return new Uint8Array(buf);\n } finally {\n await browser.close();\n }\n },\n };\n}\n","import sharp from \"sharp\";\nimport type { OverlayOptions } from \"sharp\";\nimport {\n CANVAS,\n SCREEN,\n DOMAIN,\n PAPER,\n HEADLINE,\n HEADLINE_BAND,\n HEADLINE_INK,\n} from \"./geometry.js\";\n\nexport type ComposeInput = {\n /** The bundled 2400x3200 plate. */\n plate: Uint8Array;\n /** A homepage screenshot, any size. Cropped to fill the screen rect. */\n screenshot: Uint8Array;\n /** The site's public domain, drawn bottom-left (e.g. \"gallerysonder.com\"). */\n domain: string;\n};\n\n/** JPEG quality. The hand-made headers are ~2.4MB; 88 lands near 0.7MB with no\n * visible difference at the 600px the email actually renders. */\nconst JPEG_QUALITY = 88;\n\n/** Escape the five XML metacharacters. The domain is interpolated into an SVG,\n * and a bare `&` yields invalid XML that sharp rejects outright. */\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\n}\n\n/**\n * The domain line as a transparent SVG overlay the size of the full canvas, so\n * it can be composited at 0,0 and positioned by its own coordinates.\n *\n * ⚠️ FONT AVAILABILITY IS ENVIRONMENT-DEPENDENT. sharp renders SVG through\n * librsvg + fontconfig, so the family below resolves against INSTALLED fonts.\n * On macOS it resolves to real Helvetica Neue, which reproduces the hand-made\n * reference exactly (verified: ink box dx=0 dy=0 dw=0 dh=0). A stock\n * ubuntu-latest runner has none of Helvetica Neue / Helvetica / Arial, so it\n * falls through to `sans-serif` — typically DejaVu Sans, which is wider and\n * would render a visibly different domain line.\n *\n * This matters because report drafting (which regenerates the header) runs on\n * ubuntu-latest in `.github/workflows/daily-reports.yml`. That workflow now\n * installs `fonts-urw-base35`, which provides Nimbus Sans — metrically\n * equivalent to Helvetica and third in the stack below — so a CI-generated\n * header's domain line matches a locally-generated one. Any OTHER environment\n * that runs this code still needs a Helvetica-metric font installed.\n */\nfunction domainSvg(domain: string): Buffer {\n return Buffer.from(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${CANVAS.width}\" height=\"${CANVAS.height}\">\n <text x=\"${DOMAIN.x}\" y=\"${DOMAIN.baseline}\"\n font-family=\"Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif\"\n font-size=\"${DOMAIN.size}\" font-weight=\"${DOMAIN.weight}\"\n fill=\"${DOMAIN.color}\">${escapeXml(domain)}</text>\n </svg>`,\n \"utf-8\",\n );\n}\n\n/**\n * Composite one site's header image: paste its homepage screenshot into the\n * plate's laptop screen and draw its domain underneath.\n *\n * PURE — no browser, no network, no filesystem. Fidelity lives here, so it must\n * be verifiable in a unit test.\n *\n * The screenshot is cropped to FILL the 16:10 screen from the top: a taller\n * homepage loses its fold rather than being squashed, which would be obvious\n * and wrong. `flatten` guards against a transparent source punching a hole in\n * the plate.\n */\nexport async function composeHeaderImage(input: ComposeInput): Promise<Uint8Array> {\n const screen = await sharp(Buffer.from(input.screenshot))\n .resize(SCREEN.w, SCREEN.h, { fit: \"cover\", position: \"top\" })\n .flatten({ background: PAPER })\n .toBuffer();\n\n const layers: OverlayOptions[] = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];\n if (input.domain.trim().length > 0) {\n layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });\n }\n\n const out = await sharp(Buffer.from(input.plate))\n .composite(layers)\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n\n/**\n * Count headline-red pixels already present in the headline band.\n *\n * A header composed on the clean plate has EXACTLY ZERO (measured), because the\n * band sits between the logo and the laptop and nothing else paints there. A\n * header built on the old baked plate has ~62,000 — its headline is part of the\n * image. That gap is what lets {@link stampHeadline}'s caller refuse to print a\n * second headline over the first. PURE.\n */\nexport async function headlineInkCount(header: Uint8Array): Promise<number> {\n const { data, info } = await sharp(Buffer.from(header))\n .extract({\n left: HEADLINE_BAND.x,\n top: HEADLINE_BAND.y,\n width: HEADLINE_BAND.w,\n height: HEADLINE_BAND.h,\n })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n let n = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n const r = data[i] ?? 0;\n const g = data[i + 1] ?? 0;\n const b = data[i + 2] ?? 0;\n // Generous band around the brand red rather than an exact match: the stored\n // header is a JPEG, so its ink has been through lossy re-encoding.\n if (r > HEADLINE_INK.r - 60 && g < HEADLINE_INK.g + 60 && b < HEADLINE_INK.b + 60) n++;\n }\n return n;\n}\n\n/**\n * Stamp a report-type headline overlay onto a finished (clean-plate) header at\n * the measured HEADLINE origin. PURE and strict: the header must be exactly\n * CANVAS-sized — a legacy hand-made header at other dimensions would misplace\n * the text, so the caller (applyReportTypeHeadline) size-guards and skips\n * instead of calling in. One extra JPEG generation at quality 88 is invisible\n * at the email's 600px display size.\n */\nexport async function stampHeadline(header: Uint8Array, headline: Uint8Array): Promise<Uint8Array> {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n throw new Error(\n `stampHeadline: header is ${meta.width}x${meta.height}, expected ${CANVAS.width}x${CANVAS.height}`,\n );\n }\n const out = await sharp(Buffer.from(header))\n .composite([{ input: Buffer.from(headline), left: HEADLINE.x, top: HEADLINE.y }])\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n"],"mappings":"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","names":["sharp","sharp"]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runMigrations
3
- } from "./chunk-C3IQQKXL.js";
3
+ } from "./chunk-IOTOOTII.js";
4
4
  import {
5
5
  defaultCredentialsPath
6
6
  } from "./chunk-6VAL7XJT.js";
@@ -48,4 +48,4 @@ export {
48
48
  readDbConfig,
49
49
  openDb
50
50
  };
51
- //# sourceMappingURL=chunk-VSGMCCGQ.js.map
51
+ //# sourceMappingURL=chunk-MMSMZC7S.js.map
@@ -0,0 +1,37 @@
1
+ // src/github/config.ts
2
+ import { execFileSync } from "child_process";
3
+ var defaultExecFileSync = (cmd, args, opts) => execFileSync(cmd, [...args], opts);
4
+ function ghAuthToken(exec = defaultExecFileSync) {
5
+ const { GITHUB_TOKEN: _github, GH_TOKEN: _gh, ...env } = process.env;
6
+ void _github;
7
+ void _gh;
8
+ try {
9
+ const out = exec("gh", ["auth", "token"], {
10
+ env,
11
+ encoding: "utf8",
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ timeout: 5e3
14
+ });
15
+ return out.trim() || null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ function resolveToken(exec) {
21
+ const set = process.env.GITHUB_TOKEN;
22
+ if (set === void 0) return ghAuthToken(exec);
23
+ const trimmed = set.trim();
24
+ if (trimmed) return trimmed;
25
+ if (set === "") return null;
26
+ return ghAuthToken(exec);
27
+ }
28
+ function readGitHubConfig(opts = {}) {
29
+ const token = resolveToken(opts.execFileSync);
30
+ if (!token) return null;
31
+ return { token };
32
+ }
33
+
34
+ export {
35
+ readGitHubConfig
36
+ };
37
+ //# sourceMappingURL=chunk-MVKNU4QD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/github/config.ts"],"sourcesContent":["import { execFileSync } from \"node:child_process\";\n\nexport type GitHubConfig = {\n /** Broad PAT used by the tool's own `gh` calls (PRs, branch protection, secrets). */\n token: string;\n};\n\n/** The shape of `node:child_process`'s `execFileSync` that `ghAuthToken` needs —\n * injectable so a test never shells out (and never depends on the runner's\n * own keyring). */\nexport type ExecFileSyncFn = (\n cmd: string,\n args: readonly string[],\n opts: {\n env: NodeJS.ProcessEnv;\n encoding: \"utf8\";\n stdio: [\"ignore\", \"pipe\", \"ignore\"];\n timeout: number;\n },\n) => string;\n\nconst defaultExecFileSync: ExecFileSyncFn = (cmd, args, opts) => execFileSync(cmd, [...args], opts);\n\n/**\n * The token `gh` itself is logged in with (`gh auth token`), or null when `gh`\n * is missing, not logged in, or prints nothing. #665.\n *\n * Two traps, both from the issue:\n *\n * - `gh auth token` ECHOES `$GITHUB_TOKEN` / `$GH_TOKEN` back when either is\n * set, so the child runs with both stripped. Without that a dead file token\n * makes the good keyring token look dead as well — which is exactly how the\n * original failure was misdiagnosed once.\n * - The token is NOT validated by pinging an endpoint. A fine-grained token can\n * be refused by `/user` and still be fully working for the calls it is scoped\n * to; \"validating\" it there would declare working tokens dead.\n *\n * stderr is discarded (gh prints its \"not logged in\" hint there) and the call\n * is bounded so a wedged keychain prompt cannot hang a recipe.\n */\nexport function ghAuthToken(exec: ExecFileSyncFn = defaultExecFileSync): string | null {\n const { GITHUB_TOKEN: _github, GH_TOKEN: _gh, ...env } = process.env;\n void _github;\n void _gh;\n try {\n const out = exec(\"gh\", [\"auth\", \"token\"], {\n env,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n timeout: 5_000,\n });\n return out.trim() || null;\n } catch {\n return null;\n }\n}\n\n/**\n * The broad token, from the environment or the keyring, or null for \"nobody has\n * one\". `GITHUB_TOKEN` has THREE states here, not two, and the third is the one\n * #665's fix collapsed by accident:\n *\n * - **Absent** — the recommended shape. Ask `gh auth token`.\n * - **Set to a value** — that value wins; `gh` is never asked.\n * - **Set to the EMPTY STRING** — `GITHUB_TOKEN=` in a shell, an env block, or\n * a test's `vi.stubEnv(\"GITHUB_TOKEN\", \"\")`. That is a DELIBERATE \"no token\",\n * and it must resolve to null rather than quietly reaching past the operator\n * to the keyring. Under the previous `?.trim() || ghAuthToken()` it did not:\n * `\"\"` is falsy, so an explicitly-disabled token fell through and a machine\n * that had run `gh auth login` silently resolved one anyway. The token gate\n * then read as configured on a developer's laptop and unconfigured in CI —\n * the same code taking different paths on ambient login state, which is how\n * `tests/cli/prismic-ci-command.test.ts` came to pass only where `gh` was\n * logged out.\n *\n * Whitespace-only is NOT the empty string and keeps falling through to the\n * keyring: `GITHUB_TOKEN=\" \"` is a mis-pasted `credentials.env` line, never\n * an instruction, and #665 exists precisely so a bad file value cannot override\n * a working keyring credential.\n */\nfunction resolveToken(exec?: ExecFileSyncFn): string | null {\n const set = process.env.GITHUB_TOKEN;\n if (set === undefined) return ghAuthToken(exec);\n const trimmed = set.trim();\n if (trimmed) return trimmed;\n if (set === \"\") return null; // explicitly disabled — do not consult the keyring\n return ghAuthToken(exec); // blank-but-not-empty: an accident, not a decision\n}\n\n/**\n * Read GitHub config from the environment (credentials.env is loaded into process.env by the CLI).\n *\n * `GITHUB_TOKEN` wins when set; when it is ABSENT the token comes from\n * `gh auth token` — the keyring `gh auth login` populated — so `GITHUB_TOKEN`\n * may be left OUT of `credentials.env` entirely, and should be: `gh.ts` hands\n * this token to `gh` as `GH_TOKEN`, so a set-but-dead file value actively\n * overrides a working keyring credential, and a file COPY of the keyring token\n * goes stale on the next `gh auth login/refresh/logout` (#665). Returns null\n * only when neither source has a token — the signal that git/GitHub features\n * aren't configured.\n */\nexport function readGitHubConfig(\n opts: { execFileSync?: ExecFileSyncFn } = {},\n): GitHubConfig | null {\n const token = resolveToken(opts.execFileSync);\n if (!token) return null;\n return { token };\n}\n"],"mappings":";AAAA,SAAS,oBAAoB;AAqB7B,IAAM,sBAAsC,CAAC,KAAK,MAAM,SAAS,aAAa,KAAK,CAAC,GAAG,IAAI,GAAG,IAAI;AAmB3F,SAAS,YAAY,OAAuB,qBAAoC;AACrF,QAAM,EAAE,cAAc,SAAS,UAAU,KAAK,GAAG,IAAI,IAAI,QAAQ;AACjE,OAAK;AACL,OAAK;AACL,MAAI;AACF,UAAM,MAAM,KAAK,MAAM,CAAC,QAAQ,OAAO,GAAG;AAAA,MACxC;AAAA,MACA,UAAU;AAAA,MACV,OAAO,CAAC,UAAU,QAAQ,QAAQ;AAAA,MAClC,SAAS;AAAA,IACX,CAAC;AACD,WAAO,IAAI,KAAK,KAAK;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAyBA,SAAS,aAAa,MAAsC;AAC1D,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,QAAQ,OAAW,QAAO,YAAY,IAAI;AAC9C,QAAM,UAAU,IAAI,KAAK;AACzB,MAAI,QAAS,QAAO;AACpB,MAAI,QAAQ,GAAI,QAAO;AACvB,SAAO,YAAY,IAAI;AACzB;AAcO,SAAS,iBACd,OAA0C,CAAC,GACtB;AACrB,QAAM,QAAQ,aAAa,KAAK,YAAY;AAC5C,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,EAAE,MAAM;AACjB;","names":[]}