@reddoorla/maintenance 0.96.0 → 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 (237) hide show
  1. package/README.md +5 -6
  2. package/dist/{announce-45HVP4J4.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-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
  6. package/dist/{bump-deps-EL3TKMIL.js → bump-deps-775VLDYB.js} +6 -6
  7. package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
  9. package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
  10. package/dist/chunk-ASLDCWBF.js +41 -0
  11. package/dist/chunk-ASLDCWBF.js.map +1 -0
  12. package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
  13. package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
  14. package/dist/chunk-BINFZQ2T.js.map +1 -0
  15. package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
  16. package/dist/chunk-DRNZVORX.js.map +1 -0
  17. package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
  18. package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
  19. package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
  20. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  21. package/dist/{chunk-H445EH33.js → chunk-FP6HOW6J.js} +10 -7
  22. package/dist/chunk-FP6HOW6J.js.map +1 -0
  23. package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
  24. package/dist/chunk-ICSRWW5Q.js +148 -0
  25. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  26. package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
  27. package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
  28. package/dist/{chunk-T76MA2FH.js → chunk-IOTOOTII.js} +12 -1
  29. package/dist/{chunk-T76MA2FH.js.map → chunk-IOTOOTII.js.map} +1 -1
  30. package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
  31. package/dist/{chunk-XY7YV2YQ.js → chunk-L3S3QZXL.js} +2 -2
  32. package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
  33. package/dist/{chunk-P4A6D6HO.js → chunk-LDHRIQUW.js} +32 -18
  34. package/dist/chunk-LDHRIQUW.js.map +1 -0
  35. package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
  36. package/dist/chunk-LF2TJN4X.js.map +1 -0
  37. package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
  38. package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
  39. package/dist/chunk-LZMAG73U.js.map +1 -0
  40. package/dist/{chunk-6INOBNRO.js → chunk-MMSMZC7S.js} +2 -2
  41. package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
  42. package/dist/chunk-MVKNU4QD.js.map +1 -0
  43. package/dist/{chunk-62YWOFVN.js → chunk-PARKXDXI.js} +3 -3
  44. package/dist/{chunk-62YWOFVN.js.map → chunk-PARKXDXI.js.map} +1 -1
  45. package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
  46. package/dist/chunk-RQJFGK3A.js +51 -0
  47. package/dist/chunk-RQJFGK3A.js.map +1 -0
  48. package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
  49. package/dist/chunk-TC4GANMX.js.map +1 -0
  50. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  51. package/dist/chunk-TEMQPSCM.js.map +1 -0
  52. package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
  53. package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
  54. package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
  55. package/dist/{chunk-3EA43S3R.js → chunk-U2ZI6G7H.js} +12 -12
  56. package/dist/chunk-UPE6D4AT.js +69 -0
  57. package/dist/chunk-UPE6D4AT.js.map +1 -0
  58. package/dist/{chunk-OTEVKRAH.js → chunk-VMGHQUTV.js} +7 -16
  59. package/dist/chunk-VMGHQUTV.js.map +1 -0
  60. package/dist/chunk-VQMAAQ5M.js +53 -0
  61. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  62. package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
  63. package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
  64. package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
  65. package/dist/chunk-X56LAKPR.js.map +1 -0
  66. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  67. package/dist/chunk-YJBSB4LP.js.map +1 -0
  68. package/dist/{chunk-EQQ4H6L3.js → chunk-YXWBM26P.js} +2 -2
  69. package/dist/cli/bin.js +92 -50
  70. package/dist/cli/bin.js.map +1 -1
  71. package/dist/cli/commands/audit.d.ts +33 -3
  72. package/dist/cli/commands/audit.js +25 -22
  73. package/dist/cli/commands/audit.js.map +1 -1
  74. package/dist/client-DGTO65WB.js +11 -0
  75. package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-KLWA53N5.js} +6 -6
  76. package/dist/{db-5K4CDEGJ.js → db-II5WAK4D.js} +13 -13
  77. package/dist/{digest-5AJGWS33.js → digest-CWKK2DMR.js} +28 -27
  78. package/dist/digest-CWKK2DMR.js.map +1 -0
  79. package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CYTN2EUX.js} +15 -12
  80. package/dist/ensure-site-2QZHFVQ3.js +70 -0
  81. package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
  82. package/dist/ensure-site-JEKV2ODE.js +105 -0
  83. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  84. package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
  85. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  86. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  87. package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
  88. package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-X6UEOI2M.js} +17 -13
  89. package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
  90. package/dist/{github-signals-DMU74SAX.js → github-signals-QIRTHHET.js} +20 -15
  91. package/dist/github-signals-QIRTHHET.js.map +1 -0
  92. package/dist/{header-image-BRTOLHJF.js → header-image-H7ZJAV26.js} +13 -17
  93. package/dist/header-image-H7ZJAV26.js.map +1 -0
  94. package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-BJ6BKRHF.js} +7 -7
  95. package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-KU3G7V2Y.js} +10 -10
  96. package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
  97. package/dist/index.d.ts +102 -494
  98. package/dist/index.js +60 -49
  99. package/dist/index.js.map +1 -1
  100. package/dist/{init-3LMZTCVD.js → init-GOWDA5QS.js} +15 -15
  101. package/dist/{launch-XTDMC6LD.js → launch-7N3X2MXB.js} +36 -34
  102. package/dist/launch-7N3X2MXB.js.map +1 -0
  103. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  104. package/dist/{chunk-IHHAYXKY.js → match-harness-GBCDSVPS.js} +70 -4
  105. package/dist/match-harness-GBCDSVPS.js.map +1 -0
  106. package/dist/migrate-XZ7U5DFC.js +7 -0
  107. package/dist/notify-K3WVH3DM.js +1 -1
  108. package/dist/{onboard-CZYBWVWH.js → onboard-JQQIMQW7.js} +6 -6
  109. package/dist/{orchestrate-TGDFGY52.js → orchestrate-L2A5BZFY.js} +13 -10
  110. package/dist/parity-2JGVUCZD.js +14 -0
  111. package/dist/{preflight-POZZI3GN.js → preflight-O7Q6FWHU.js} +35 -11
  112. package/dist/preflight-O7Q6FWHU.js.map +1 -0
  113. package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-NAPWOXXD.js} +11 -11
  114. package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
  115. package/dist/{prismic-models-URYGODNY.js → prismic-models-LL6ORTQ2.js} +19 -16
  116. package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
  117. package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-FMPGQIRQ.js} +2 -2
  118. package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
  119. package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
  120. package/dist/recipes/sync-configs.js +3 -3
  121. package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
  122. package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-2IHAZDVA.js} +13 -8
  123. package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
  124. package/dist/{report-RC5LGPU3.js → report-IAHXN5CW.js} +102 -58
  125. package/dist/report-IAHXN5CW.js.map +1 -0
  126. package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-4QM57LMS.js} +28 -10
  127. package/dist/report-mirror-4QM57LMS.js.map +1 -0
  128. package/dist/roster-GDNSIRAK.js +19 -0
  129. package/dist/roster-GDNSIRAK.js.map +1 -0
  130. package/dist/{self-updating-RKKIYNV4.js → self-updating-ZUWPGYUP.js} +7 -7
  131. package/dist/{selftest-AFLR7YV7.js → selftest-G4W3LIBQ.js} +21 -17
  132. package/dist/{selftest-AFLR7YV7.js.map → selftest-G4W3LIBQ.js.map} +1 -1
  133. package/dist/site-create-ZU3ACBTO.js +45 -0
  134. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  135. package/dist/{site-mirror-H2FYMN55.js → site-mirror-WR7ACXOV.js} +10 -10
  136. package/dist/site-mirror-WR7ACXOV.js.map +1 -0
  137. package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-WAUPI6EJ.js} +7 -7
  138. package/dist/{submissions-II5J35QM.js → submissions-2SHCFOKD.js} +11 -6
  139. package/dist/submissions-2SHCFOKD.js.map +1 -0
  140. package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-VOLOKKFI.js} +6 -6
  141. package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
  142. package/dist/{sync-configs-HGLQV55H.js → sync-configs-OXSWKVZ2.js} +6 -6
  143. package/dist/turso-KQKBBQCK.js +24 -0
  144. package/dist/turso-KQKBBQCK.js.map +1 -0
  145. package/dist/{upgrade-WZ44IARG.js → upgrade-EVAL3JTO.js} +6 -6
  146. package/dist/util/git.d.ts +1 -1
  147. package/dist/util/git.js +1 -1
  148. package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
  149. package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
  150. package/package.json +1 -1
  151. package/dist/airtable-SJV4ZSMS.js +0 -9
  152. package/dist/announce-45HVP4J4.js.map +0 -1
  153. package/dist/attachments-NLZIJ5NU.js +0 -9
  154. package/dist/chunk-37B4LVB3.js.map +0 -1
  155. package/dist/chunk-72FJZ6LO.js.map +0 -1
  156. package/dist/chunk-74QRWE6G.js +0 -53
  157. package/dist/chunk-74QRWE6G.js.map +0 -1
  158. package/dist/chunk-CGQZFQAY.js.map +0 -1
  159. package/dist/chunk-F3JWOPTG.js.map +0 -1
  160. package/dist/chunk-GEUC5VGM.js.map +0 -1
  161. package/dist/chunk-H445EH33.js.map +0 -1
  162. package/dist/chunk-IHHAYXKY.js.map +0 -1
  163. package/dist/chunk-IY6256GK.js.map +0 -1
  164. package/dist/chunk-KN52PM63.js.map +0 -1
  165. package/dist/chunk-MZZDMBDT.js.map +0 -1
  166. package/dist/chunk-OTEVKRAH.js.map +0 -1
  167. package/dist/chunk-P4A6D6HO.js.map +0 -1
  168. package/dist/chunk-UIITN37J.js.map +0 -1
  169. package/dist/chunk-YFAXOD7Z.js +0 -42
  170. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  171. package/dist/chunk-ZH5LOQET.js.map +0 -1
  172. package/dist/client-L7NAUG6J.js +0 -11
  173. package/dist/digest-5AJGWS33.js.map +0 -1
  174. package/dist/ensure-site-XESZHZEB.js.map +0 -1
  175. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  176. package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
  177. package/dist/github-signals-DMU74SAX.js.map +0 -1
  178. package/dist/header-image-BRTOLHJF.js.map +0 -1
  179. package/dist/launch-XTDMC6LD.js.map +0 -1
  180. package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
  181. package/dist/match-harness-OW45CB5H.js +0 -77
  182. package/dist/match-harness-OW45CB5H.js.map +0 -1
  183. package/dist/migrate-NE3DUMTM.js +0 -7
  184. package/dist/parity-AEKYSLN6.js +0 -14
  185. package/dist/preflight-POZZI3GN.js.map +0 -1
  186. package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
  187. package/dist/prismic-models-URYGODNY.js.map +0 -1
  188. package/dist/report-RC5LGPU3.js.map +0 -1
  189. package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
  190. package/dist/site-mirror-H2FYMN55.js.map +0 -1
  191. package/dist/submissions-II5J35QM.js.map +0 -1
  192. package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
  193. /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  194. /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
  195. /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-775VLDYB.js.map} +0 -0
  196. /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  197. /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
  198. /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
  199. /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
  200. /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
  201. /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
  202. /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
  203. /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
  204. /package/dist/{chunk-XY7YV2YQ.js.map → chunk-L3S3QZXL.js.map} +0 -0
  205. /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
  206. /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
  207. /package/dist/{chunk-6INOBNRO.js.map → chunk-MMSMZC7S.js.map} +0 -0
  208. /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  209. /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
  210. /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
  211. /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  212. /package/dist/{chunk-3EA43S3R.js.map → chunk-U2ZI6G7H.js.map} +0 -0
  213. /package/dist/{chunk-EQQ4H6L3.js.map → chunk-YXWBM26P.js.map} +0 -0
  214. /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
  215. /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
  216. /package/dist/{db-5K4CDEGJ.js.map → db-II5WAK4D.js.map} +0 -0
  217. /package/dist/{client-L7NAUG6J.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
  218. /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  219. /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-BJ6BKRHF.js.map} +0 -0
  220. /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
  221. /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  222. /package/dist/{init-3LMZTCVD.js.map → init-GOWDA5QS.js.map} +0 -0
  223. /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  224. /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-XZ7U5DFC.js.map} +0 -0
  225. /package/dist/{onboard-CZYBWVWH.js.map → onboard-JQQIMQW7.js.map} +0 -0
  226. /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
  227. /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
  228. /package/dist/{prospect-audit-AJEE3Z4L.js.map → prospect-audit-FMPGQIRQ.js.map} +0 -0
  229. /package/dist/{parity-AEKYSLN6.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  230. /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-ZUWPGYUP.js.map} +0 -0
  231. /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-WAUPI6EJ.js.map} +0 -0
  232. /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
  233. /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
  234. /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
  235. /package/dist/{upgrade-WZ44IARG.js.map → upgrade-EVAL3JTO.js.map} +0 -0
  236. /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
  237. /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/draft-fields.ts","../src/reports/airtable/reports.ts"],"sourcesContent":["/** The fields a new report row carries, as ONE pure function (#646 step 4).\n *\n * This used to live inside `createDraft` in the Airtable layer, because Airtable\n * was the primary write. Turso mints the report id and owns the row now, so the\n * field set has two consumers: the Turso creator (`createReportDraft`, via the\n * importer's `mapReportRecord`) and the legacy Airtable `createDraft`. It lives\n * OUT of `src/reports/airtable/` so the creator survives step 6 deleting that\n * directory.\n *\n * The keys are Airtable COLUMN NAMES and stay that way on purpose: they are the\n * vocabulary `mapReportRecord` reads, so a draft maps to a `reports` row through\n * the very function the parity harness diffs against — parity-clean by\n * construction rather than by a second column list someone has to remember to\n * extend. The column names outlive the Airtable client either way (design D1 kept\n * them as the import vocabulary; #646's own checklist notes that step 6 must\n * replace those maps, and this does not add a new place that has to change).\n */\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport type { EvidenceRecord } from \"./auto-tick.js\";\n\nexport type DraftInput = {\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key. Omitted on legacy callers; written only when supplied. */\n period?: string;\n periodStart: Date;\n periodEnd: Date;\n completedOn: Date;\n lighthouse: LighthouseScores;\n lastTestedDate: Date | null;\n /** GA \"Users\" for the period / previous period. Omitted when GA is not configured\n * for the site or the fetch failed — the operator fills the fields manually. */\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n /** Search-presence result. `searchFoundPage1` is written whenever the check ran (true or\n * false — false is the operator-only negative signal). `searchPosition` only when found. */\n searchFoundPage1?: boolean;\n searchPosition?: number;\n subjectOverride?: string;\n /** Checklist fields to tick at create time (the auto-tick \"pass\" set). */\n checklistTicks?: string[];\n /** Auto-tick evidence snapshot to persist (keyed by checklist field). */\n autoEvidence?: Record<string, EvidenceRecord>;\n};\n\n/** `YYYY-MM-DD`, the date shape every report date column stores. */\nexport function ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The full field set for a new report row.\n *\n * `Delivery status` is set to \"pending\" at CREATION time, not at send time. This\n * matters for H4: if the send's stamp wrote \"pending\" after the webhook had\n * already written \"delivered\" (a race), the operator would see a regressed status.\n *\n * Optional values are OMITTED rather than written as null — the pre-GA behaviour,\n * where a blank cell means \"fill this in by hand\" and a written null would claim\n * the value was measured as absent.\n */\nexport function draftFields(input: DraftInput): Record<string, unknown> {\n const fields: Record<string, unknown> = {\n \"Report ID\": input.reportId,\n Site: [input.siteId],\n \"Report type\": input.reportType,\n \"Period start\": ymd(input.periodStart),\n \"Period end\": ymd(input.periodEnd),\n \"Completed on\": ymd(input.completedOn),\n \"Lighthouse — Performance\": input.lighthouse.performance,\n \"Lighthouse — Accessibility\": input.lighthouse.accessibility,\n \"Lighthouse — Best Practices\": input.lighthouse.bestPractices,\n \"Lighthouse — SEO\": input.lighthouse.seo,\n \"Delivery status\": \"pending\",\n };\n if (input.lastTestedDate) fields[\"Last tested date\"] = ymd(input.lastTestedDate);\n if (input.gaUsersCurrent !== undefined) fields[\"GA users (period)\"] = input.gaUsersCurrent;\n if (input.gaUsersPrevious !== undefined) fields[\"GA users (prev period)\"] = input.gaUsersPrevious;\n if (input.searchFoundPage1 !== undefined) fields[\"Search found page 1\"] = input.searchFoundPage1;\n if (input.searchPosition !== undefined) fields[\"Search position\"] = input.searchPosition;\n if (input.period !== undefined) fields[\"Period\"] = input.period;\n if (input.subjectOverride !== undefined) fields[\"Subject override\"] = input.subjectOverride;\n // Auto-ticked checklist boxes + the evidence snapshot. The booleans are the same columns the\n // operator/dashboard toggle; the JSON is what the health gate reads (isHealthGateClear/gatingHealth)\n // and also drives the dashboard's green/amber badges.\n for (const field of input.checklistTicks ?? []) fields[field] = true;\n if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {\n fields[\"Checklist auto-evidence\"] = JSON.stringify(input.autoEvidence);\n }\n return fields;\n}\n","import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport { isAirtableRecordId, skipsAirtableShadow } from \"../../fleet/site-id.js\";\nimport { draftFields, ymd, type DraftInput } from \"../draft-fields.js\";\nimport type { ReportType, LighthouseScores } from \"../types.js\";\nimport { ALL_CHECKLIST_FIELDS } from \"../checklist.js\";\nimport {\n toReportType,\n parseAutoEvidence,\n type DeliveryStatus,\n type ReportRow,\n} from \"../report-row.js\";\n\n// The report-row model and its pure coercers live in src/reports/report-row.ts\n// since #539 Phase 6 step 1 (#646). Re-exported so existing imports resolve unchanged.\nexport {\n toReportType,\n isPendingApproval,\n parseAutoEvidence,\n type DeliveryStatus,\n type ReportRow,\n} from \"../report-row.js\";\n\nexport const REPORTS_TABLE = \"Reports\";\n\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): ReportRow {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const html =\n ((f[\"Rendered HTML\"] as Array<{ url: string; filename: string }> | undefined) ?? [])[0] ?? null;\n return {\n id: rec.id,\n reportId: String(f[\"Report ID\"] ?? \"\"),\n siteId: linkSites[0] ?? \"\",\n reportType: toReportType(f[\"Report type\"] as string | undefined),\n period: (f[\"Period\"] as string | undefined) ?? null,\n periodStart: (f[\"Period start\"] as string | undefined) ?? null,\n periodEnd: (f[\"Period end\"] as string | undefined) ?? null,\n completedOn: (f[\"Completed on\"] as string | undefined) ?? null,\n lighthouse: lighthouseFromFields(f),\n gaUsersCurrent: (f[\"GA users (period)\"] as number | undefined) ?? null,\n gaUsersPrevious: (f[\"GA users (prev period)\"] as number | undefined) ?? null,\n searchFoundPage1:\n typeof f[\"Search found page 1\"] === \"boolean\" ? (f[\"Search found page 1\"] as boolean) : null,\n searchPosition: (f[\"Search position\"] as number | undefined) ?? null,\n lastTestedDate: (f[\"Last tested date\"] as string | undefined) ?? null,\n commentary: (f[\"Commentary\"] as string | undefined) ?? null,\n subjectOverride: (f[\"Subject override\"] as string | undefined) ?? null,\n draftReady: Boolean(f[\"Draft ready\"]),\n approvedToSend: Boolean(f[\"Approved to send\"]),\n sentAt: (f[\"Sent at\"] as string | undefined) ?? null,\n approvedAt: (f[\"Approved At\"] as string | undefined) ?? null,\n approvedBy: (f[\"Approved By\"] as string | undefined) ?? null,\n deliveryStatus: ((f[\"Delivery status\"] as string | undefined) ?? \"pending\") as DeliveryStatus,\n renderedHtmlAttachment: html,\n resendMessageId: (f[\"Resend message ID\"] as string | undefined) ?? null,\n checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),\n autoEvidence: parseAutoEvidence(f[\"Checklist auto-evidence\"]),\n sendOverride: Boolean(f[\"Send override\"]),\n overrideReason: (f[\"Override reason\"] as string | undefined) ?? null,\n overrideBy: (f[\"Override by\"] as string | undefined) ?? null,\n overrideAt: (f[\"Override at\"] as string | undefined) ?? null,\n };\n}\n\nfunction lighthouseFromFields(f: Record<string, unknown>): LighthouseScores | null {\n const p = f[\"Lighthouse — Performance\"];\n const a = f[\"Lighthouse — Accessibility\"];\n const b = f[\"Lighthouse — Best Practices\"];\n const s = f[\"Lighthouse — SEO\"];\n if (\n typeof p !== \"number\" ||\n typeof a !== \"number\" ||\n typeof b !== \"number\" ||\n typeof s !== \"number\"\n )\n return null;\n return { performance: p, accessibility: a, bestPractices: b, seo: s };\n}\n\n/** The GA + Search-presence fields a draft can carry, written to the Reports row.\n * `searchFoundPage1` is written whenever the check ran (true or false — false is the\n * operator-only negative signal); `searchPosition` only when found on page 1. Shared by\n * the create path (DraftInput) and the reuse path (updateReportScores). */\nexport type ReportEnrichment = {\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n searchFoundPage1?: boolean;\n searchPosition?: number;\n};\n\n// The draft's field set and its `DraftInput` moved to src/reports/draft-fields.ts\n// in #646 step 4: Turso creates reports now, and the creator must outlive this\n// directory. Re-exported so existing imports resolve unchanged.\nexport { draftFields, ymd, type DraftInput } from \"../draft-fields.js\";\n\n/**\n * Escape a string for safe interpolation into an Airtable filterByFormula.\n * Airtable formulas use SQL-like string literals; we escape backslash and\n * double quote. Used wherever an externally-supplied string flows into a\n * formula (e.g. Resend message ids on the webhook path).\n */\nexport function escapeFormulaString(s: string): string {\n return s.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\n/** Called with the record Airtable echoed back from the create, before it is\n * mapped (#539 Phase 5). Structurally typed rather than importing the db\n * layer's `RawRecord`, so this module stays the leaf it has always been. */\nexport type CreatedDraftMirror = (rec: {\n id: string;\n fields: Record<string, unknown>;\n}) => Promise<void>;\n\n/**\n * Create a Reports row in Airtable, letting Airtable mint the report id.\n *\n * LEGACY since #646 step 4 (operator decision 2026-09-17): no production path\n * calls this any more. Reports are created in Turso with a minted\n * `report_<ULID>` id (`src/reports/create-report.ts`), because Airtable cannot be\n * told what record id to use and every later write addresses the report by its\n * id. Kept — not deleted — because deleting anything from this directory is step\n * 6, which needs its own operator go.\n */\nexport async function createDraft(\n base: AirtableBase,\n input: DraftInput,\n mirror?: CreatedDraftMirror,\n): Promise<ReportRow> {\n // #646 step 3: NOT a shadow write, so it does not skip. This path MINTS the\n // report id — Airtable's record id is the report id — and a `site_<ULID>` site\n // has no Websites record for the `Site` link to point at. Skipping would silently\n // produce no report; letting Airtable reject the link would surface as an opaque\n // INVALID_RECORD_ID. Refuse by name; the Turso creator is what such a site uses.\n if (!isAirtableRecordId(input.siteId)) {\n throw new Error(\n `createDraft: site ${input.siteId} was created in Turso and has no Airtable record — ` +\n `create the report in Turso instead (createReportDraft, #646 step 4)`,\n );\n }\n const fields = draftFields(input) as FieldSet;\n const created = (await base(REPORTS_TABLE).create([{ fields }])) as Records<FieldSet>;\n const rec = created[0];\n if (!rec) throw new Error(\"Airtable create returned no records\");\n const raw = { id: rec.id, fields: rec.fields as Record<string, unknown> };\n // The mirror gets what Airtable STORED, never the caller's DraftInput: parity\n // diffs Turso against the stored record, so mapping the input would diverge\n // the moment Airtable normalises a value. Injected rather than called\n // directly so this module keeps no db dependency; the injected\n // implementation owns the never-throw policy (see reports/report-mirror.ts).\n if (mirror) await mirror(raw);\n return mapRow(raw);\n}\n\n/**\n * Every writer below is a SHADOW of a Turso row since the freeze, and since #646\n * step 4 a report's id may be a minted `report_<ULID>` that Airtable has never\n * heard of. Sending such an id to Airtable is a guaranteed 404 on the drafting,\n * approve, send and webhook paths alike, so each writer asks the same question\n * the site writers already ask — could Airtable hold this row at all? — and skips\n * with one greppable `AIRTABLE_SHADOW skipped=non-rec-id writer=<name>` line\n * when it could not. A `rec…` report is untouched: its shadow row is still\n * written until step 6 deletes this layer.\n *\n * The test that covers this (tests/reports/airtable/shadow-skip-report-ids.test.ts)\n * fails if an exported writer here is missing from it.\n */\nexport async function setDraftReady(\n base: AirtableBase,\n recordId: string,\n ready: boolean,\n): Promise<void> {\n if (skipsAirtableShadow(\"setDraftReady\", recordId)) return;\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Draft ready\": ready } }]);\n}\n\n/**\n * Overwrite the four `Lighthouse — *` score cells (and, when supplied, `Completed\n * on`) on an EXISTING Reports row. The launch re-run path uses this: it reuses the\n * already-created Launch row but must refresh its scores to match the freshly-run\n * audit — otherwise the re-rendered preview shows new scores while the row (and the\n * eventually-sent email, which reads the row) keeps the stale ones. The create path\n * already writes fresh scores via `createDraft`; this is its update-side mirror, using\n * the same exact field names so the two stay in lockstep.\n */\nexport async function updateReportScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n completedOn?: Date,\n enrichment?: ReportEnrichment,\n): Promise<void> {\n if (skipsAirtableShadow(\"updateReportScores\", recordId)) return;\n const fields: FieldSet = {\n \"Lighthouse — Performance\": scores.performance,\n \"Lighthouse — Accessibility\": scores.accessibility,\n \"Lighthouse — Best Practices\": scores.bestPractices,\n \"Lighthouse — SEO\": scores.seo,\n };\n if (completedOn) fields[\"Completed on\"] = ymd(completedOn);\n if (enrichment?.gaUsersCurrent !== undefined)\n fields[\"GA users (period)\"] = enrichment.gaUsersCurrent;\n if (enrichment?.gaUsersPrevious !== undefined)\n fields[\"GA users (prev period)\"] = enrichment.gaUsersPrevious;\n if (enrichment?.searchFoundPage1 !== undefined)\n fields[\"Search found page 1\"] = enrichment.searchFoundPage1;\n if (enrichment?.searchPosition !== undefined)\n fields[\"Search position\"] = enrichment.searchPosition;\n await base(REPORTS_TABLE).update([{ id: recordId, fields }]);\n}\n\n/** Write one report's operator commentary (#539 Phase 4 report review). The\n * caller (`setReportCommentary`) has already validated the length and refused\n * the write for an already-sent report, so this stays a plain single-field\n * writer — the same division of labour as `updateSiteField`/`setSiteDetail`. */\nexport async function updateReportCommentary(\n base: AirtableBase,\n recordId: string,\n text: string,\n): Promise<void> {\n if (skipsAirtableShadow(\"updateReportCommentary\", recordId)) return;\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { Commentary: text } }]);\n}\n\nexport async function listSendableReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula:\n \"AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())\",\n pageSize: 100,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\n/**\n * Fetch every Reports row, unfiltered. Site-scoped callers filter the result in\n * memory: the `Site` linked-record field CANNOT be formula-filtered by record id\n * (see findReportByPeriod's doc for why), and the fleet's Reports table is small\n * enough that one paged fetch-all beats N broken-or-per-site queries.\n */\nexport async function listAllReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function listReportsForSite(base: AirtableBase, siteId: string): Promise<ReportRow[]> {\n // Client-side match on the mapped siteId (mapRow reads the record id from the\n // REST response, where it IS present) — record ids can't appear in formulas.\n return (await listAllReports(base)).filter((r) => r.siteId === siteId);\n}\n\n/**\n * Mark a row as sent: write `Sent at` and (when known) `Resend message ID`.\n * Crucially does NOT touch `Delivery status` — that's set to \"pending\" in\n * createDraft and updated by the webhook from there. If we wrote \"pending\" here\n * we could clobber a \"delivered\" that the webhook raced ahead and wrote first (H4).\n *\n * `messageId` may be `null`: the 409 idempotency-conflict path has no recoverable\n * id (the original send's id was lost when the prior run's stamp failed), so it\n * passes null and we write ONLY `Sent at`. Writing a sentinel string like\n * \"idempotent-conflict\" into the id column would masquerade as a real Resend id\n * and silently orphan `findReportByMessageId` lookups; leaving it null is honest —\n * the row still stops replaying (Sent at is set), delivery tracking for that one\n * report is simply degraded (the id is genuinely unknowable on that path).\n */\nexport async function stampSent(\n base: AirtableBase,\n recordId: string,\n sentAt: Date,\n messageId: string | null,\n): Promise<void> {\n if (skipsAirtableShadow(\"stampSent\", recordId)) return;\n const fields: Record<string, string> = { \"Sent at\": sentAt.toISOString() };\n if (messageId !== null) fields[\"Resend message ID\"] = messageId;\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields,\n },\n ]);\n}\n\nexport async function setDeliveryStatus(\n base: AirtableBase,\n recordId: string,\n status: DeliveryStatus,\n): Promise<void> {\n if (skipsAirtableShadow(\"setDeliveryStatus\", recordId)) return;\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Delivery status\": status } }]);\n}\n\n/**\n * Stamp the approval on a Reports row: flips `Approved to send` TRUE and records\n * who/when for the audit trail. The caller (approveReport handler) is responsible\n * for idempotency — this is the raw write. Never touches `Sent at`.\n */\nexport async function approveReportRow(\n base: AirtableBase,\n recordId: string,\n approvedAt: Date,\n approvedBy: string,\n): Promise<void> {\n if (skipsAirtableShadow(\"approveReportRow\", recordId)) return;\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Approved to send\": true,\n \"Approved At\": approvedAt.toISOString(),\n \"Approved By\": approvedBy,\n },\n },\n ]);\n}\n\n/**\n * Stamp a logged send-anyway override on a Reports row: sets `Send override` TRUE, the reason, and\n * who/when — AND flips `Approved to send` TRUE (with the same Approved At/By stamp) so the daily\n * cron delivers the overridden report. Mirrors {@link approveReportRow}; never touches `Sent at`.\n */\nexport async function overrideReportRow(\n base: AirtableBase,\n recordId: string,\n overrideAt: Date,\n overrideBy: string,\n reason: string,\n): Promise<void> {\n if (skipsAirtableShadow(\"overrideReportRow\", recordId)) return;\n const at = overrideAt.toISOString();\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Send override\": true,\n \"Override reason\": reason,\n \"Override by\": overrideBy,\n \"Override at\": at,\n \"Approved to send\": true,\n \"Approved At\": at,\n \"Approved By\": overrideBy,\n },\n },\n ]);\n}\n\n/**\n * True when an `.find` rejection is a GENUINE not-found, not a transient failure.\n * The Airtable SDK stamps `.statusCode` (404) and/or `.error` (\"NOT_FOUND\") on\n * its errors. Anything else (429 rate-limit, 500 outage, bad-PAT 401, network\n * error) must NOT be masked as a 404 — see getReportById.\n */\nfunction isNotFoundError(err: unknown): boolean {\n if (typeof err !== \"object\" || err === null) return false;\n const e = err as { statusCode?: unknown; error?: unknown; name?: unknown; message?: unknown };\n if (e.statusCode === 404) return true;\n const tag = String(e.error ?? e.name ?? e.message ?? \"\");\n return tag === \"NOT_FOUND\" || /not found/i.test(tag);\n}\n\n/**\n * Fetch one Reports row by its Airtable record id, or null if it doesn't exist.\n * Only a GENUINE not-found (404 / NOT_FOUND) collapses to null; every other\n * failure (outage, 429, bad PAT, network error) is rethrown so the adapter\n * surfaces a 500 instead of a misleading 404. Swallowing all throws previously\n * turned an Airtable outage into a \"no such report\".\n */\nexport async function getReportById(\n base: AirtableBase,\n recordId: string,\n): Promise<ReportRow | null> {\n try {\n const rec = await base(REPORTS_TABLE).find(recordId);\n return mapRow({ id: rec.id, fields: rec.fields as Record<string, unknown> });\n } catch (err) {\n if (isNotFoundError(err)) return null;\n throw err;\n }\n}\n\nexport async function findReportByMessageId(\n base: AirtableBase,\n messageId: string,\n): Promise<ReportRow | null> {\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula: `{Resend message ID} = \"${escapeFormulaString(messageId)}\"`,\n maxRecords: 1,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows[0] ?? null;\n}\n\n/**\n * Find the Reports row for a `(site, reportType, period)` triple, or null. The\n * idempotency lookup behind search-before-create drafting.\n *\n * The site is matched CLIENT-side, never in the formula: Airtable's formula layer\n * renders linked-record fields ({Site}) as the linked rows' PRIMARY-FIELD NAMES,\n * not record ids, so any formula comparing {Site} or ARRAYJOIN({Site}) against a\n * `recXXX` id matches NOTHING (live-proven against the real base — do not\n * reintroduce that idiom). Record ids exist only in the REST response, where\n * mapRow reads them. So the formula filters on the real scalar fields (Report\n * type + Period — escaped, keeping it injection-safe if their source ever\n * changes), and the first mapped row whose siteId matches wins. The candidate\n * set is at most one row per site for the (type, period), so this stays small.\n */\nexport async function findReportByPeriod(\n base: AirtableBase,\n siteId: string,\n reportType: ReportType,\n period: string,\n): Promise<ReportRow | null> {\n const safeType = escapeFormulaString(reportType);\n const safePeriod = escapeFormulaString(period);\n const formula = `AND({Report type} = \"${safeType}\", {Period} = \"${safePeriod}\")`;\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ filterByFormula: formula, pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows.find((r) => r.siteId === siteId) ?? null;\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ignoreRulesFor,
3
3
  pathsMissingFromHead
4
- } from "./chunk-EASYCD6F.js";
4
+ } from "./chunk-WMF5S2MB.js";
5
5
 
6
6
  // src/recipes/_head-guard.ts
7
7
  import { rm, rmdir } from "fs/promises";
@@ -49,4 +49,4 @@ export {
49
49
  refusedByGit,
50
50
  undoRefusedWrites
51
51
  };
52
- //# sourceMappingURL=chunk-4UTJ4G6P.js.map
52
+ //# sourceMappingURL=chunk-TJLKWQOX.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-SQ4FSNYL.js";
3
+ } from "./chunk-LUF52EIY.js";
4
4
  import {
5
5
  defaultSpawn
6
6
  } from "./chunk-LBYOLBW7.js";
@@ -76,4 +76,4 @@ async function bumpDeps(site, opts = {}) {
76
76
  export {
77
77
  bumpDeps
78
78
  };
79
- //# sourceMappingURL=chunk-VEIIUCYL.js.map
79
+ //# sourceMappingURL=chunk-TV76A6GN.js.map
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  REPORT_LIST_COLUMNS
3
- } from "./chunk-GEUC5VGM.js";
3
+ } from "./chunk-BINFZQ2T.js";
4
4
  import {
5
5
  mapReportRecord,
6
6
  mapWebsiteRecord
7
- } from "./chunk-UIITN37J.js";
7
+ } from "./chunk-LF2TJN4X.js";
8
8
 
9
9
  // src/db/parity.ts
10
10
  var SKIP_COLUMNS = /* @__PURE__ */ new Set([
@@ -129,4 +129,4 @@ export {
129
129
  checkFleetParity,
130
130
  formatParityResult
131
131
  };
132
- //# sourceMappingURL=chunk-D3CTOCUR.js.map
132
+ //# sourceMappingURL=chunk-TVZ6QCOE.js.map
@@ -1,32 +1,32 @@
1
+ import {
2
+ smokeSuite
3
+ } from "./chunk-EN6CTBUN.js";
1
4
  import {
2
5
  onboard
3
- } from "./chunk-CA6ADGOC.js";
6
+ } from "./chunk-6PGXKN6F.js";
4
7
  import {
5
8
  convertToPnpm
6
- } from "./chunk-DVJNYPFR.js";
9
+ } from "./chunk-AYDQQBNG.js";
7
10
  import {
8
11
  svelteCodemods
9
- } from "./chunk-RRE74VWI.js";
12
+ } from "./chunk-HSMN35LU.js";
10
13
  import {
11
14
  healthEndpoint
12
- } from "./chunk-XED2DNAI.js";
13
- import {
14
- smokeSuite
15
- } from "./chunk-NVLREB5R.js";
15
+ } from "./chunk-LAG6IP7Y.js";
16
16
  import {
17
17
  RESTORED_NOTE,
18
18
  refusedByGit,
19
19
  undoRefusedWrites
20
- } from "./chunk-4UTJ4G6P.js";
20
+ } from "./chunk-TJLKWQOX.js";
21
21
  import {
22
22
  syncConfigs
23
- } from "./chunk-IMYL67JV.js";
23
+ } from "./chunk-3RLDX3Q2.js";
24
24
  import {
25
25
  withRecipe
26
- } from "./chunk-SQ4FSNYL.js";
26
+ } from "./chunk-LUF52EIY.js";
27
27
  import {
28
28
  runAudits
29
- } from "./chunk-XY7YV2YQ.js";
29
+ } from "./chunk-L3S3QZXL.js";
30
30
  import {
31
31
  siteLabel
32
32
  } from "./chunk-XXTZBPUY.js";
@@ -161,4 +161,4 @@ export {
161
161
  DEFAULT_INIT_STEPS,
162
162
  init
163
163
  };
164
- //# sourceMappingURL=chunk-3EA43S3R.js.map
164
+ //# sourceMappingURL=chunk-U2ZI6G7H.js.map
@@ -0,0 +1,69 @@
1
+ import {
2
+ draftFields,
3
+ setDraftReady
4
+ } from "./chunk-TEMQPSCM.js";
5
+ import {
6
+ isPendingApproval
7
+ } from "./chunk-ASLDCWBF.js";
8
+ import {
9
+ encodeUlid,
10
+ skipsAirtableShadow
11
+ } from "./chunk-RQJFGK3A.js";
12
+
13
+ // src/reports/queue.ts
14
+ var REPORT_TIER = {
15
+ Maintenance: 1,
16
+ Testing: 2,
17
+ Announcement: 3,
18
+ Launch: 3
19
+ };
20
+ function reportTier(type) {
21
+ return REPORT_TIER[type];
22
+ }
23
+ async function queueDraft(base, report, mirror) {
24
+ const setReady = async (id, ready) => {
25
+ await setDraftReady(base, id, ready);
26
+ await mirror.patch(id, { draft_ready: ready ? 1 : 0 });
27
+ };
28
+ const newTier = reportTier(report.reportType);
29
+ const others = (await mirror.forSite(report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
30
+ const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
31
+ if (blocker) {
32
+ await setReady(report.id, false);
33
+ return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
34
+ }
35
+ const supersededIds = [];
36
+ for (const r of others) {
37
+ await setReady(r.id, false);
38
+ supersededIds.push(r.id);
39
+ }
40
+ await setReady(report.id, true);
41
+ return { queued: true, supersededIds };
42
+ }
43
+
44
+ // src/fleet/report-id.ts
45
+ function mintReportId(now = Date.now()) {
46
+ const random = new Uint8Array(10);
47
+ globalThis.crypto.getRandomValues(random);
48
+ return `report_${encodeUlid(now, random)}`;
49
+ }
50
+
51
+ // src/reports/create-report.ts
52
+ async function createReportDraft(input, deps) {
53
+ const id = (deps.mintId ?? mintReportId)();
54
+ const row = await deps.create({ id, fields: draftFields(input) });
55
+ skipsAirtableShadow("createReportDraft", id);
56
+ return row;
57
+ }
58
+ async function findReportForPeriod(store, siteId, reportType, period) {
59
+ const rows = await store.forSite(siteId);
60
+ return rows.find((r) => r.reportType === reportType && r.period === period) ?? null;
61
+ }
62
+
63
+ export {
64
+ createReportDraft,
65
+ findReportForPeriod,
66
+ reportTier,
67
+ queueDraft
68
+ };
69
+ //# sourceMappingURL=chunk-UPE6D4AT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/queue.ts","../src/fleet/report-id.ts","../src/reports/create-report.ts"],"sourcesContent":["import type { ReportType } from \"./types.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { isPendingApproval, setDraftReady } from \"./airtable/reports.js\";\nimport type { ReportMirror } from \"./report-mirror.js\";\n\n/**\n * Report tiers — a higher tier is a SUPERSET of the lower ones, so only the highest-tier\n * un-sent draft is worth keeping in a site's approve queue. Maintenance ⊂ Testing ⊂\n * {Announcement, Launch}. Announcement and Launch share tier 3 (neither supersets the other).\n */\nexport const REPORT_TIER: Record<ReportType, number> = {\n Maintenance: 1,\n Testing: 2,\n Announcement: 3,\n Launch: 3,\n};\n\nexport function reportTier(type: ReportType): number {\n return REPORT_TIER[type];\n}\n\nexport type QueueOutcome = {\n /** Whether `report` was placed in (kept in) the approve queue. */\n queued: boolean;\n /** When `queued` is false, the queued report type that subsumes this one. */\n blockedBy?: ReportType;\n /** Ids of lower-tier queued reports that this one superseded (un-queued). */\n supersededIds: string[];\n};\n\n/**\n * Enforce \"one report queued per site, highest tier wins\" at draft time. Given a freshly\n * created/reused report, look at the OTHER reports already pending approval for the same site\n * (Draft ready ∧ ¬Approved ∧ ¬Sent) and:\n *\n * - If any is of an EQUAL-OR-HIGHER tier → the new report is subsumed: leave it un-queued\n * (Draft ready = false) and report `blockedBy`. (Equal-tier, e.g. a queued Launch when an\n * Announcement is drafted, keeps the existing one rather than silently replacing it.)\n * - Otherwise the new report is strictly the highest → un-queue every (strictly lower) pending\n * report for the site (superseded, not deleted — the row is kept) and queue the new one.\n *\n * Returns what happened so the caller can surface it. PURE side effects are all `setDraftReady`.\n *\n * #539 Phase 5: each flag is mirrored into Turso. The SUPERSEDED rows matter as\n * much as the new one — un-queueing them is the whole point of this function, so\n * a mirror covering only `report.id` would leave the console showing a site with\n * two queued reports until the next hourly sync.\n *\n * #646 step 4: the site's other reports are READ from Turso (`mirror.forSite`),\n * which is why `mirror` is no longer optional. The Airtable read this replaces\n * could not see a report drafted for a `site_<ULID>` site — and an empty answer\n * does not fail, it silently queues a second report for a site that already had\n * one. The Airtable `setDraftReady` stays as the shadow; it skips a report id\n * Airtable cannot hold.\n */\nexport async function queueDraft(\n base: AirtableBase,\n report: { id: string; siteId: string; reportType: ReportType },\n mirror: ReportMirror,\n): Promise<QueueOutcome> {\n const setReady = async (id: string, ready: boolean): Promise<void> => {\n await setDraftReady(base, id, ready);\n await mirror.patch(id, { draft_ready: ready ? 1 : 0 });\n };\n const newTier = reportTier(report.reportType);\n const others = (await mirror.forSite(report.siteId))\n .filter(isPendingApproval)\n .filter((r) => r.id !== report.id);\n\n const blocker = others.find((r) => reportTier(r.reportType) >= newTier);\n if (blocker) {\n // A queued report already covers this one. Make sure the new draft is NOT queued (the reuse\n // path may hand us a row that was Draft-ready from a prior run) and stand down.\n await setReady(report.id, false);\n return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };\n }\n\n // The new report is strictly the highest-tier pending — supersede the rest (un-queue), keep it.\n const supersededIds: string[] = [];\n for (const r of others) {\n await setReady(r.id, false);\n supersededIds.push(r.id);\n }\n await setReady(report.id, true);\n return { queued: true, supersededIds };\n}\n","/**\n * Report ids after Airtable (#646 step 4, operator decision 2026-09-17).\n *\n * The site decision (`site-id.ts`), applied to the other table Airtable used to\n * mint for us. Two shapes coexist PERMANENTLY:\n *\n * - `rec…` every report drafted before this. Airtable's record id\n * became the Turso primary key at import (design D1) and is\n * never rewritten — `reports.id` is what the approve route,\n * the preview url, the re-render and the send all address.\n * - `report_<ULID>` every report drafted since, minted here.\n *\n * The ULID encoding itself is NOT duplicated: `encodeUlid` is the one in\n * `site-id.ts`, which carries the reasoning for implementing it locally. This\n * module is the report half of the same decision, kept separate only so the two\n * prefixes cannot be confused at a call site.\n *\n * Unlike a site, a report has no slug: nothing outside the database addresses a\n * report by anything but this id, so the id IS the external handle (the preview\n * link, the approve POST, the `--rerender` argument). That is why the three\n * Netlify routes validate its shape, and why {@link isReportId} exists — the\n * routes must accept both shapes, and a shape check that only knew `rec` would\n * 404 every report drafted from now on.\n */\nimport { encodeUlid } from \"./site-id.js\";\n\n/** Mint a new report id: `report_<ULID>`. Time-sortable, like the site ids. */\nexport function mintReportId(now: number = Date.now()): string {\n const random = new Uint8Array(10);\n globalThis.crypto.getRandomValues(random);\n return `report_${encodeUlid(now, random)}`;\n}\n\n/** A report id minted by {@link mintReportId}. */\nexport function isMintedReportId(id: string): boolean {\n return /^report_[0-9A-HJKMNP-TV-Z]{26}$/.test(id);\n}\n\n/**\n * Either shape a report id can legitimately take — the predicate the request\n * routes validate with.\n *\n * Deliberately NOT a \"looks roughly like an id\" test: these values come off a\n * query string, so the route answers 404 for anything that is not one of the two\n * minters' output shapes. The `rec` half is the routes' own `/^rec[A-Za-z0-9]+$/`\n * kept EXACTLY as it was — widening it here would quietly relax three request\n * paths that this change is only supposed to teach a second shape. That is also\n * why it is stricter than `isAirtableRecordId`, which is the SHADOW-WRITE\n * question (\"could Airtable hold this row at all\") and answers it for readable\n * test fixtures like `rec_report_1` too.\n */\nexport function isReportId(id: string): boolean {\n return /^rec[A-Za-z0-9]+$/.test(id) || isMintedReportId(id);\n}\n","/**\n * Creating a report, Turso-native (#539 Phase 6 step 4, #646; operator decision\n * 2026-09-17).\n *\n * Until now Airtable MINTED the report id: `createDraft` posted to the Reports\n * table and whatever record id came back became `reports.id` in Turso. That is why\n * a `site_<ULID>` site could not receive a report at all — Airtable has no\n * Websites record for the `Site` link to point at, so `createDraft` refused the\n * site by name (#646 step 3). Turso mints `report_<ULID>` here instead and owns\n * the row, exactly as `ensure-site` does for sites.\n *\n * ## The Airtable shadow for a NEW report: none\n *\n * Airtable cannot be told what record id to use. A shadow record would therefore\n * carry an id that is NOT the report id, which every later write addresses — the\n * approve stamp, the queue flag, `Sent at`, the delivery status — so the shadow\n * would be unreachable from the moment it was written and would drift on its\n * first update. Every Airtable report writer now skips a non-`rec` id with the\n * same `AIRTABLE_SHADOW skipped=non-rec-id` line the site writers log, and a\n * minted report id is never a `rec` id, so the create skips too. This function\n * logs that skip rather than staying silent about it, the same way\n * `ensureSite` does for a new site.\n *\n * Pre-existing `rec…` reports are untouched: both shapes coexist permanently, and\n * a `rec` report's Airtable row still receives its shadow writes until step 6.\n */\nimport { draftFields, type DraftInput } from \"./draft-fields.js\";\nimport { mintReportId } from \"../fleet/report-id.js\";\nimport { skipsAirtableShadow } from \"../fleet/site-id.js\";\nimport type { ReportRow } from \"./report-row.js\";\nimport type { ReportType } from \"./types.js\";\n\n/** The one Turso operation creating a report needs: insert the row and hand back\n * what was STORED. Implemented by the report writer (`makeReportMirror().create`),\n * which owns the db handle and the freeze's error semantics. Reading the row back\n * rather than mapping the input is the same rule the create mirror already\n * followed: the caller gets what the store holds, so a coercion can never\n * diverge the returned row from the persisted one. */\nexport type ReportInserter = (rec: {\n id: string;\n fields: Record<string, unknown>;\n}) => Promise<ReportRow>;\n\nexport type CreateReportDeps = {\n create: ReportInserter;\n /** Injectable for tests; defaults to {@link mintReportId}. */\n mintId?: () => string;\n};\n\n/** Mint a report id, write the row to Turso, and return the stored row. */\nexport async function createReportDraft(\n input: DraftInput,\n deps: CreateReportDeps,\n): Promise<ReportRow> {\n const id = (deps.mintId ?? mintReportId)();\n const row = await deps.create({ id, fields: draftFields(input) });\n // Logged, not attempted — see \"The Airtable shadow for a NEW report\" above.\n skipsAirtableShadow(\"createReportDraft\", id);\n return row;\n}\n\n/**\n * The `(site, type, period)` idempotency lookup behind search-before-create\n * drafting — the re-run dedupe the `launch` and `announce` recipes make before\n * they draft.\n *\n * It replaces the Airtable `findReportByPeriod`, whose whole design was a\n * workaround: Airtable's formula layer renders a linked-record field as the\n * linked rows' primary-field NAMES, so no formula could filter by site id and\n * the site had to be matched client-side after fetching every row of the\n * (type, period). Here the site scope IS the query (`forSite`, served by\n * idx_reports_site) and the triple is matched in memory over one site's handful\n * of reports.\n */\nexport async function findReportForPeriod(\n store: { forSite: (siteId: string) => Promise<ReportRow[]> },\n siteId: string,\n reportType: ReportType,\n period: string,\n): Promise<ReportRow | null> {\n const rows = await store.forSite(siteId);\n return rows.find((r) => r.reportType === reportType && r.period === period) ?? null;\n}\n"],"mappings":";;;;;;;;;;;;;AAUO,IAAM,cAA0C;AAAA,EACrD,aAAa;AAAA,EACb,SAAS;AAAA,EACT,cAAc;AAAA,EACd,QAAQ;AACV;AAEO,SAAS,WAAW,MAA0B;AACnD,SAAO,YAAY,IAAI;AACzB;AAoCA,eAAsB,WACpB,MACA,QACA,QACuB;AACvB,QAAM,WAAW,OAAO,IAAY,UAAkC;AACpE,UAAM,cAAc,MAAM,IAAI,KAAK;AACnC,UAAM,OAAO,MAAM,IAAI,EAAE,aAAa,QAAQ,IAAI,EAAE,CAAC;AAAA,EACvD;AACA,QAAM,UAAU,WAAW,OAAO,UAAU;AAC5C,QAAM,UAAU,MAAM,OAAO,QAAQ,OAAO,MAAM,GAC/C,OAAO,iBAAiB,EACxB,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO,EAAE;AAEnC,QAAM,UAAU,OAAO,KAAK,CAAC,MAAM,WAAW,EAAE,UAAU,KAAK,OAAO;AACtE,MAAI,SAAS;AAGX,UAAM,SAAS,OAAO,IAAI,KAAK;AAC/B,WAAO,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,eAAe,CAAC,EAAE;AAAA,EAC3E;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,KAAK,QAAQ;AACtB,UAAM,SAAS,EAAE,IAAI,KAAK;AAC1B,kBAAc,KAAK,EAAE,EAAE;AAAA,EACzB;AACA,QAAM,SAAS,OAAO,IAAI,IAAI;AAC9B,SAAO,EAAE,QAAQ,MAAM,cAAc;AACvC;;;AC1DO,SAAS,aAAa,MAAc,KAAK,IAAI,GAAW;AAC7D,QAAM,SAAS,IAAI,WAAW,EAAE;AAChC,aAAW,OAAO,gBAAgB,MAAM;AACxC,SAAO,UAAU,WAAW,KAAK,MAAM,CAAC;AAC1C;;;ACmBA,eAAsB,kBACpB,OACA,MACoB;AACpB,QAAM,MAAM,KAAK,UAAU,cAAc;AACzC,QAAM,MAAM,MAAM,KAAK,OAAO,EAAE,IAAI,QAAQ,YAAY,KAAK,EAAE,CAAC;AAEhE,sBAAoB,qBAAqB,EAAE;AAC3C,SAAO;AACT;AAeA,eAAsB,oBACpB,OACA,QACA,YACA,QAC2B;AAC3B,QAAM,OAAO,MAAM,MAAM,QAAQ,MAAM;AACvC,SAAO,KAAK,KAAK,CAAC,MAAM,EAAE,eAAe,cAAc,EAAE,WAAW,MAAM,KAAK;AACjF;","names":[]}
@@ -5,22 +5,14 @@ import {
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-P4A6D6HO.js";
9
- import {
10
- listAllReports
11
- } from "./chunk-F3JWOPTG.js";
8
+ } from "./chunk-LDHRIQUW.js";
12
9
  import {
13
10
  gatingHealth,
14
11
  isSendOverridden
15
12
  } from "./chunk-VAD43MOR.js";
16
13
  import {
17
- openBase,
18
- readAirtableConfig
19
- } from "./chunk-HN6I2XWI.js";
20
- import {
21
- listWebsites,
22
14
  siteSlug
23
- } from "./chunk-KN52PM63.js";
15
+ } from "./chunk-ICSRWW5Q.js";
24
16
 
25
17
  // src/reports/preflight.ts
26
18
  var OPERATOR_DOMAINS = ["reddoorla.com", "tuckerlemos.com"];
@@ -150,13 +142,13 @@ function preflightSite(site, reports, type, now) {
150
142
  findings.push({
151
143
  level: "warn",
152
144
  check: "scores-missing",
153
- message: "Websites row is missing Lighthouse scores \u2014 `announce` will skip this site; run `audit lighthouse --write-airtable` first"
145
+ message: "Websites row is missing Lighthouse scores \u2014 `announce` will skip this site; run `audit lighthouse --write-back` first"
154
146
  });
155
147
  } else {
156
148
  findings.push({
157
149
  level: "fail",
158
150
  check: "scores-missing",
159
- message: "Websites row is missing Lighthouse scores \u2014 drafting will throw (scoresFromWebsite); run `audit lighthouse --write-airtable` first"
151
+ message: "Websites row is missing Lighthouse scores \u2014 drafting will throw (scoresFromWebsite); run `audit lighthouse --write-back` first"
160
152
  });
161
153
  }
162
154
  }
@@ -240,12 +232,11 @@ function preflightFleet(sites) {
240
232
  return findings;
241
233
  }
242
234
  async function preflight(deps) {
243
- const base = deps?.base ?? openBase(readAirtableConfig());
244
235
  const type = deps?.type ?? "Announcement";
245
236
  const now = deps?.now ?? /* @__PURE__ */ new Date();
246
- const websites = await listWebsites(base);
237
+ const websites = await deps.roster();
247
238
  const selected = deps?.site ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site)) : type === "Announcement" ? websites.filter((w) => w.status === "maintained") : websites.filter((w) => w.status === null || ELIGIBLE_STATUSES.has(w.status));
248
- const allReports = selected.length > 0 ? await listAllReports(base) : [];
239
+ const allReports = selected.length > 0 ? await deps.allReports() : [];
249
240
  const results = selected.map(
250
241
  (site) => preflightSite(
251
242
  site,
@@ -341,4 +332,4 @@ export {
341
332
  preflight,
342
333
  approveBlockers
343
334
  };
344
- //# sourceMappingURL=chunk-OTEVKRAH.js.map
335
+ //# sourceMappingURL=chunk-VMGHQUTV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/preflight.ts"],"sourcesContent":["// #646 step 4: preflight reads through the readers its caller hands it, so this\n// file no longer touches the Airtable layer at all — its row helpers come from\n// the vendor-neutral modules step 1 moved them to.\nimport { siteSlug } from \"../fleet/site-row.js\";\nimport type { WebsiteRow } from \"../fleet/site-row.js\";\nimport type { ReportRow } from \"./report-row.js\";\nimport { parseAddresses, isProbablyEmail } from \"./send/orchestrate.js\";\nimport { ELIGIBLE_STATUSES, reportPeriodKey } from \"./due.js\";\nimport type { ReportType } from \"./types.js\";\nimport { gatingHealth, isSendOverridden } from \"./checklist.js\";\n\nexport type PreflightLevel = \"fail\" | \"warn\" | \"info\";\n\nexport type PreflightFinding = {\n level: PreflightLevel;\n /** Stable machine-readable check id, e.g. \"recipients-missing\". */\n check: string;\n message: string;\n};\n\nexport type PreflightSiteResult = {\n site: string;\n findings: PreflightFinding[];\n};\n\n/** Domains whose addresses are the OPERATOR's, not a client's. A client site whose\n * resolved To contains one of these is almost always a test-send leftover — the\n * exact misconfiguration that would silently divert a client announcement. */\nconst OPERATOR_DOMAINS = [\"reddoorla.com\", \"tuckerlemos.com\"];\n\n/** Anchors older than this, WHEN the anchor is actually the schedule's base date\n * (no newer Sent-at — see nextDueDate's `lastSent ?? fallback`), mean the next\n * `report --due` run drafts a surprise back-dated report. */\nconst STALE_ANCHOR_DAYS = 396; // ~13 months\n\nfunction domainOf(addr: string): string {\n const at = addr.lastIndexOf(\"@\");\n return at === -1 ? \"\" : addr.slice(at + 1).toLowerCase();\n}\n\nfunction isOperatorSite(site: WebsiteRow): boolean {\n if (!site.url) return false;\n try {\n // Airtable `url` cells aren't guaranteed a scheme; retry with one before giving up.\n const host = new URL(\n /^[a-z][a-z0-9+.-]*:\\/\\//i.test(site.url) ? site.url : `https://${site.url}`,\n ).hostname.toLowerCase();\n return OPERATOR_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));\n } catch {\n return false;\n }\n}\n\nfunction lastSentForType(reports: ReportRow[], type: ReportType): string | null {\n const sent = reports\n .filter((r) => r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return sent[sent.length - 1] ?? null;\n}\n\nfunction checkFrequency(\n site: WebsiteRow,\n which: \"maintenance\" | \"testing\",\n reports: ReportRow[],\n findings: PreflightFinding[],\n now: Date,\n): void {\n // Validate the RAW Airtable cell, not the coerced Frequency: toFrequency trims\n // whitespace (so \"Monthly \" schedules), then coerces any still-unrecognized value\n // (typo, renamed option) to \"None\" with a console.warn. That warn only lands in\n // logs — this finding is the structured, surfaced version of the same failure.\n const raw = which === \"maintenance\" ? site.maintenanceFreqRaw : site.testingFreqRaw;\n const coerced = which === \"maintenance\" ? site.maintenanceFreq : site.testingFreq;\n if (raw !== null && raw !== \"\" && coerced === \"None\" && raw.trim() !== \"None\") {\n findings.push({\n level: \"fail\",\n check: \"frequency-unrecognized\",\n message: `${which} frequency cell is '${raw}' — not a recognized Monthly/Quarterly/Yearly/None (even after trimming), so the scheduler treats it as None and the site drops off the calendar; fix the Airtable value`,\n });\n return;\n }\n if (coerced === \"None\") return;\n\n const anchor = which === \"maintenance\" ? site.maintenanceDay : site.testingDay;\n if (!anchor) return;\n // nextDueDate uses `lastSent ?? anchor`: once anything has been sent, the anchor\n // is inert and an old one is normal on every healthy mature site — only warn when\n // the anchor is genuinely the base date the scheduler would use.\n const type: ReportType = which === \"maintenance\" ? \"Maintenance\" : \"Testing\";\n const lastSent = lastSentForType(reports, type);\n if (lastSent && new Date(lastSent).getTime() >= new Date(anchor).getTime()) return;\n const ageDays = (now.getTime() - new Date(anchor).getTime()) / 86_400_000;\n if (ageDays > STALE_ANCHOR_DAYS) {\n findings.push({\n level: \"warn\",\n check: \"anchor-stale\",\n message: `${which} day anchor is ${anchor} (>13 months old) and nothing newer has been sent — \\`report --due\\` will draft a back-dated overdue report; reset the anchor or clear the schedule`,\n });\n }\n}\n\n/**\n * Pure per-site preflight: every check that, left unfixed, makes a report send fail,\n * go to the wrong inbox, or surprise the operator. Read-only over data already\n * fetched; no Airtable handle, no network — trivially testable.\n *\n * Mirrors the send/draft-time validation (recipients + header image in\n * send/orchestrate.ts, Websites-row scores in draft.ts) so problems surface BEFORE\n * draft/approve instead of exploding at `report --send-ready`, and adds the checks\n * send time cannot do: operator-address leftovers, To-override shadowing,\n * pending-draft races, schedule hygiene against the RAW Airtable values.\n */\nexport function preflightSite(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n now: Date,\n): PreflightSiteResult {\n const findings: PreflightFinding[] = [];\n\n // A site that isn't on the checked calendar never drafts, so its send\n // requirements (recipients / header image / scores) can't hurt anyone —\n // checking them anyway would drown the fleet run in fails for legacy hosting\n // rows. Frequency hygiene and pending-draft checks still run: a typo'd cell\n // is exactly HOW a site falls off the calendar, and an already-drafted report\n // can still be approved + sent regardless of schedule.\n const scheduledForType =\n type === \"Announcement\"\n ? true\n : type === \"Maintenance\"\n ? site.maintenanceFreq !== \"None\"\n : site.testingFreq !== \"None\";\n if (!scheduledForType) {\n findings.push({\n level: \"info\",\n check: \"not-scheduled\",\n message: `no ${type} schedule (frequency is None/blank) — send-requirement checks skipped; schedule + pending-draft checks still apply`,\n });\n }\n\n // --- Recipients: same resolution order as the send path (To override, else contact).\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = scheduledForType ? (explicitTo ?? fallbackTo ?? []) : [];\n if (scheduledForType && to.length === 0) {\n findings.push({\n level: \"fail\",\n check: \"recipients-missing\",\n message:\n \"no recipients — Report recipients (To) and point of contact are both empty; the send will throw\",\n });\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `recipient '${addr}' is malformed — use bare addresses only in Report recipients (To) / point of contact`,\n });\n }\n }\n const cc = scheduledForType ? (parseAddresses(site.reportRecipientsCc) ?? []) : [];\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `CC '${addr}' is malformed — fix Report recipients (CC)`,\n });\n }\n }\n if (!isOperatorSite(site)) {\n const operatorAddrs = to.filter((a) => OPERATOR_DOMAINS.includes(domainOf(a)));\n if (operatorAddrs.length > 0) {\n const allOperator = operatorAddrs.length === to.length;\n findings.push({\n level: \"warn\",\n check: \"recipient-operator-address\",\n message: allOperator\n ? `resolved To is ONLY operator address(es) ${operatorAddrs.join(\", \")} — probably a test-send leftover; the client will NOT receive this report`\n : `resolved To includes operator address(es) ${operatorAddrs.join(\", \")} alongside the client's — confirm that's intentional (the ops inbox is already CC'd on every send)`,\n });\n }\n }\n if (scheduledForType && explicitTo && fallbackTo) {\n const a = [...explicitTo]\n .map((x) => x.toLowerCase())\n .sort()\n .join(\",\");\n const b = [...fallbackTo]\n .map((x) => x.toLowerCase())\n .sort()\n .join(\",\");\n if (a !== b) {\n findings.push({\n level: \"info\",\n check: \"to-override-shadows-contact\",\n message: `Report recipients (To) [${explicitTo.join(\", \")}] overrides point of contact [${fallbackTo.join(\", \")}] — the contact won't receive this unless also listed`,\n });\n }\n }\n\n // --- Hard requirements of the draft/send paths, surfaced early.\n if (scheduledForType && !site.headerImage) {\n findings.push({\n level: \"fail\",\n check: \"header-image-missing\",\n message: \"no Header image on the Websites row — the send will throw\",\n });\n } else if (scheduledForType && site.headerImage && !site.headerImage.type.startsWith(\"image/\")) {\n findings.push({\n level: \"fail\",\n check: \"header-image-not-image\",\n message: `Header image attachment is '${site.headerImage.type}' (${site.headerImage.filename}) — not a decodable image; the send throws in prepareHeaderImage`,\n });\n }\n const scores = [site.pScore, site.rScore, site.bpScore, site.seoScore];\n if (scheduledForType && scores.some((s) => s === null)) {\n if (type === \"Announcement\") {\n // announce() skips score-less sites (skipped-no-scores) rather than throwing.\n findings.push({\n level: \"warn\",\n check: \"scores-missing\",\n message:\n \"Websites row is missing Lighthouse scores — `announce` will skip this site; run `audit lighthouse --write-back` first\",\n });\n } else {\n // report <site> / report --due HARD-THROW here (scoresFromWebsite in draft.ts).\n findings.push({\n level: \"fail\",\n check: \"scores-missing\",\n message:\n \"Websites row is missing Lighthouse scores — drafting will throw (scoresFromWebsite); run `audit lighthouse --write-back` first\",\n });\n }\n }\n if (type === \"Announcement\" && site.status !== \"maintained\") {\n findings.push({\n level: \"info\",\n check: \"status-not-maintenance\",\n message: `status is '${site.status ?? \"(blank)\"}' — \\`announce --all\\` only drafts for maintenance sites`,\n });\n }\n\n // --- Pending drafts. For an Announcement these always race (the queue tier rule\n // supersedes or blocks). For Maintenance/Testing, the CURRENT cycle's own draft\n // sitting ready on send day IS the payload — that's the steady state, not a\n // problem — so only genuinely stale/foreign drafts get the warn.\n const pending = reports.filter((r) => r.draftReady && r.sentAt === null);\n if (pending.length > 0) {\n const currentPeriod = reportPeriodKey(now);\n const isExpectedPayload = (r: ReportRow): boolean =>\n type !== \"Announcement\" && r.reportType === type && r.period === currentPeriod;\n const expected = pending.filter(isExpectedPayload);\n const stale = pending.filter((r) => !isExpectedPayload(r));\n if (expected.length > 0) {\n findings.push({\n level: \"info\",\n check: \"pending-drafts\",\n message: `current-cycle ${type} draft is queued${expected[0]!.approvedToSend ? \" and approved\" : \" (awaiting approval)\"} — this is the report \\`--send-ready\\` will deliver`,\n });\n }\n if (stale.length > 0) {\n const summary = stale\n .map(\n (r) =>\n `${r.reportType} ${r.period ?? \"(no period)\"}${r.approvedToSend ? \" [APPROVED]\" : \"\"}`,\n )\n .join(\", \");\n findings.push({\n level: \"warn\",\n check: \"pending-drafts\",\n message: `${stale.length} unsent draft(s) queued: ${summary} — resolve before sending so the client doesn't get the wrong report first`,\n });\n }\n }\n\n // --- Schedule hygiene (validates the RAW Airtable frequency cells).\n checkFrequency(site, \"maintenance\", reports, findings, now);\n checkFrequency(site, \"testing\", reports, findings, now);\n if (type === \"Announcement\") {\n const anySent = lastSentForType(reports, \"Maintenance\") ?? lastSentForType(reports, \"Testing\");\n if (!site.maintenanceDay && !site.testingDay && !anySent) {\n findings.push({\n level: \"info\",\n check: \"anchor-missing\",\n message:\n \"no maintenance/testing day anchors and nothing ever sent — set both anchors to the announcement send date (the Sonder playbook) so recurrence starts from the announcement\",\n });\n }\n }\n\n return { site: site.name, findings };\n}\n\n/**\n * Fleet-level heuristics that need the whole selection. Airtable column renames\n * don't error — the mapper reads null forever (its own header comment admits this).\n * A load-bearing column empty on EVERY selected site is far more likely a rename\n * than N coincidences; say so. Likewise two different sites resolving to one\n * recipient is worth one eyeball (it can be legitimate — same owner, two sites).\n */\nexport function preflightFleet(sites: WebsiteRow[]): PreflightFinding[] {\n if (sites.length < 3) return []; // too few rows to distinguish rename from data\n const findings: PreflightFinding[] = [];\n const allEmpty = (get: (s: WebsiteRow) => unknown, column: string): void => {\n if (sites.every((s) => get(s) === null || get(s) === \"\")) {\n findings.push({\n level: \"warn\",\n check: \"column-possibly-renamed\",\n message: `'${column}' is empty on all ${sites.length} selected sites — if that column was renamed in Airtable the code reads null silently; verify the column name`,\n });\n }\n };\n allEmpty((s) => s.pointOfContact, \"point of contact\");\n allEmpty((s) => s.maintenanceFreqRaw, \"maintenence freq\");\n allEmpty((s) => s.headerImage, \"Header image\");\n\n const byContact = new Map<string, string[]>();\n for (const s of sites) {\n const to = parseAddresses(s.reportRecipientsTo) ?? parseAddresses(s.pointOfContact) ?? [];\n for (const addr of to) {\n const key = addr.toLowerCase();\n byContact.set(key, [...(byContact.get(key) ?? []), s.name]);\n }\n }\n for (const [addr, names] of byContact) {\n if (names.length > 1 && !OPERATOR_DOMAINS.includes(domainOf(addr))) {\n findings.push({\n level: \"info\",\n check: \"duplicate-contact\",\n message: `${names.join(\" and \")} resolve to the same recipient (${addr}) — fine if one person owns both; otherwise a copy-paste error on one row`,\n });\n }\n }\n return findings;\n}\n\nexport type PreflightDeps = {\n /** The fleet roster. Since #646 step 4 preflight reads it from TURSO — read by\n * the command that calls this — so a `site_<ULID>` site is checked like any\n * other. Required, so nothing can reach a store this was not handed. */\n roster: () => Promise<WebsiteRow[]>;\n /** Every report row, on the same contract as `roster`. Called only when the\n * selection is non-empty (it was one whole-table read before, and still is). */\n allReports: () => Promise<ReportRow[]>;\n /** Slug of a single site (matched via siteSlug). Mutually exclusive with `all`. */\n site?: string;\n /** Fleet mode: for Announcement, maintenance-status sites (announce's own filter);\n * for Maintenance/Testing, everything `report --due` schedules — ELIGIBLE_STATUSES\n * plus null-status rows (due.ts treats null as active for legacy rows). */\n all?: boolean;\n type?: ReportType;\n now?: Date;\n};\n\nexport type PreflightResult = {\n results: PreflightSiteResult[];\n fleet: PreflightFinding[];\n};\n\n/**\n * Read-only rollout preflight over the fleet store. One roster read, one reports\n * read (the --due path does the same, and for the same reason: the whole table), then\n * {@link preflightSite} per selected site plus {@link preflightFleet} across the\n * selection. NEVER writes and NEVER sends.\n */\nexport async function preflight(deps: PreflightDeps): Promise<PreflightResult> {\n const type: ReportType = deps?.type ?? \"Announcement\";\n const now = deps?.now ?? new Date();\n\n const websites = await deps.roster();\n const selected = deps?.site\n ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site!))\n : type === \"Announcement\"\n ? websites.filter((w) => w.status === \"maintained\")\n : websites.filter((w) => w.status === null || ELIGIBLE_STATUSES.has(w.status));\n\n const allReports = selected.length > 0 ? await deps.allReports() : [];\n const results = selected.map((site) =>\n preflightSite(\n site,\n allReports.filter((r) => r.siteId === site.id),\n type,\n now,\n ),\n );\n return { results, fleet: deps?.site ? [] : preflightFleet(selected) };\n}\n\n/**\n * Health-gate blockers for one report: every GATING field whose evidence is not `pass`/`n/a`\n * (i.e. `fail`, `unknown`, or absent) becomes a fail-level finding, keyed `health-gate`. PURE.\n * Folded into {@link approveBlockers} so health failures ride the existing send-blocked reason +\n * 409 + dashboard chip (the second gate — no third gate is added).\n *\n * A logged send-anyway override ({@link isSendOverridden}) suppresses these findings — but never\n * the REAL send blockers (recipients/header/scores), which are computed outside this function and\n * still gate an overridden send.\n */\nexport function healthBlockers(report: ReportRow): PreflightFinding[] {\n if (isSendOverridden(report)) return [];\n const findings: PreflightFinding[] = [];\n for (const { field, status } of gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })) {\n if (status === \"pass\" || status === \"n/a\") continue;\n // `||`, not `??`: an explicit empty-string note (no message recorded) must\n // also fall back to the placeholder, not render as a trailing blank.\n const note = report.autoEvidence?.[field]?.note || \"no signal yet\";\n findings.push({\n level: \"fail\",\n check: \"health-gate\",\n message:\n status === \"fail\"\n ? `${field}: failing — ${note}`\n : `${field}: not yet green (${status}) — ${note}`,\n });\n }\n return findings;\n}\n\n/**\n * The send-blocking subset for ONE report, at approve time: exactly the\n * conditions that make `sendOne` throw (no recipients, malformed To/CC, no\n * header image, no report-level Lighthouse scores) plus the wrong-inbox warn\n * (operator address resolved as a client's To) plus every {@link healthBlockers}\n * finding (a gating checklist item whose auto-evidence isn't `pass`/`n/a` fails\n * the health gate). PURE — the approve gate, the dashboard's pending-row chip,\n * and the digest collector all call this one function so \"approvable\" can't\n * drift from \"sendable\".\n *\n * Deliberately narrower than {@link preflightSite}: schedule hygiene and\n * pending-draft races are fleet/site concerns — blocking THIS report's approval\n * on them would be over-gating.\n */\nexport function approveBlockers(site: WebsiteRow, report: ReportRow): PreflightFinding[] {\n const findings: PreflightFinding[] = [];\n\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n findings.push({\n level: \"fail\",\n check: \"recipients-missing\",\n message:\n \"no recipients — Report recipients (To) and point of contact are both empty; the send will throw\",\n });\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `recipient '${addr}' is malformed — fix Report recipients (To) / point of contact in Airtable`,\n });\n }\n }\n for (const addr of parseAddresses(site.reportRecipientsCc) ?? []) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `CC '${addr}' is malformed — fix Report recipients (CC) in Airtable`,\n });\n }\n }\n if (!site.headerImage) {\n findings.push({\n level: \"fail\",\n check: \"header-image-missing\",\n message: \"no Header image on the Websites row — the send will throw\",\n });\n } else if (!site.headerImage.type.startsWith(\"image/\")) {\n findings.push({\n level: \"fail\",\n check: \"header-image-not-image\",\n message: `Header image attachment is '${site.headerImage.type}' (${site.headerImage.filename}) — not a decodable image; the send throws in prepareHeaderImage`,\n });\n }\n // sendOne throws on a null report.lighthouse (one blank/non-numeric cell nulls\n // all four) — the REPORT's snapshot, not the Websites-row scores.\n if (report.lighthouse === null) {\n findings.push({\n level: \"fail\",\n check: \"report-scores-missing\",\n message:\n \"the Reports row has no Lighthouse scores (one blank/non-numeric cell nulls all four) — the send will throw\",\n });\n }\n if (!isOperatorSite(site)) {\n const operatorAddrs = to.filter((a) => OPERATOR_DOMAINS.includes(domainOf(a)));\n if (operatorAddrs.length > 0 && operatorAddrs.length === to.length) {\n findings.push({\n level: \"warn\",\n check: \"recipient-operator-address\",\n message: `resolved To is ONLY operator address(es) ${operatorAddrs.join(\", \")} — the client will NOT receive this report`,\n });\n }\n }\n findings.push(...healthBlockers(report));\n return findings;\n}\n\n/** Convenience: just the fail-level blockers, formatted for gate messages. */\nexport function formatBlockers(findings: PreflightFinding[]): string[] {\n return findings.filter((f) => f.level === \"fail\").map((f) => `${f.check}: ${f.message}`);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,53 @@
1
+ import {
2
+ ACTIVE_STATUSES,
3
+ isPreLaunch,
4
+ siteSlug
5
+ } from "./chunk-ICSRWW5Q.js";
6
+ import {
7
+ isHttpUrl
8
+ } from "./chunk-RARREDQE.js";
9
+
10
+ // src/inventory/select.ts
11
+ function selectFleetSites(websites, workdir) {
12
+ return websites.filter(
13
+ (w) => w.status !== null && ACTIVE_STATUSES.has(w.status) && !isPreLaunch(w.status) && w.url.length > 0
14
+ ).flatMap((w) => {
15
+ const slug = siteSlug(w.name);
16
+ if (slug.length === 0) {
17
+ console.warn(
18
+ `[inventory] skipping "${w.name}" (row ${w.id}): Name has no slug-able characters (empty slug)`
19
+ );
20
+ return [];
21
+ }
22
+ const site = {
23
+ path: `${workdir}/${slug}`,
24
+ name: slug,
25
+ meta: { siteId: w.id, displayName: w.name }
26
+ };
27
+ if (isHttpUrl(w.url)) {
28
+ site.deployedUrl = w.url;
29
+ } else {
30
+ console.warn(
31
+ `[inventory] skipping deployed audit for "${w.name}": url is not http(s): ${JSON.stringify(w.url)}`
32
+ );
33
+ }
34
+ if (w.gitRepo) site.gitRepo = w.gitRepo;
35
+ if (w.netlifyId) site.netlifyId = w.netlifyId;
36
+ return [site];
37
+ });
38
+ }
39
+ function requireFleetWorkdir(explicit, provider) {
40
+ const workdir = explicit ?? process.env.REDDOOR_FLEET_WORKDIR;
41
+ if (!workdir) {
42
+ throw new Error(
43
+ `${provider} requires \`workdir\` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)`
44
+ );
45
+ }
46
+ return workdir;
47
+ }
48
+
49
+ export {
50
+ selectFleetSites,
51
+ requireFleetWorkdir
52
+ };
53
+ //# sourceMappingURL=chunk-VQMAAQ5M.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/inventory/select.ts"],"sourcesContent":["import type { Site } from \"../types.js\";\nimport { siteSlug, ACTIVE_STATUSES, isPreLaunch, type WebsiteRow } from \"../fleet/site-row.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\n/**\n * THE fleet-sweep selection rule, over rows from either store (#646 step 4).\n *\n * Every `--fleet` sweep — the four nightly audits, the Prismic drift sweep,\n * `prismic-ci`, `init`, `sync-configs` and the rest — visits exactly the sites this\n * returns. It lived inside `fromAirtableBase` until step 4 moved the roster to\n * Turso; it is shared rather than copied so the Airtable and Turso providers\n * cannot drift, and `tests/inventory/selection-parity.test.ts` proves that the\n * two stores, fed the same rows, hand it identical input.\n *\n * Only LIVE `maintained` sites that have a `url` are included — pre-launch\n * stages (\"launching\" / \"building\", via isPreLaunch) are excluded so a\n * not-yet-live site is never audited as production (its deploy/domain/uptime/CMS\n * audits would fail against nothing and red its row) nor swept by other `--fleet`\n * ops; it re-enters the fleet when a Launch report flips its Status to\n * \"maintained\". The production URL is exposed as `Site.deployedUrl` so the\n * lighthouse audit can run against it with no checkout. `repoUrl` is\n * intentionally NOT set from `url` — a clone source must come from `gitRepo`\n * (`owner/repo`), never the production URL.\n *\n * The id is carried as `meta.siteId` and never interpreted: a `rec…` id and a\n * `site_<ULID>` id (#646 step 3) select identically.\n */\nexport function selectFleetSites(websites: readonly WebsiteRow[], workdir: string): Site[] {\n return websites\n .filter(\n (w) =>\n w.status !== null &&\n ACTIVE_STATUSES.has(w.status) &&\n !isPreLaunch(w.status) &&\n w.url.length > 0,\n )\n .flatMap((w) => {\n const slug = siteSlug(w.name);\n // An empty slug (a Name with no slug-able characters) can't form a stable\n // path and — fatally — can't be matched back to its row on write-back:\n // every empty-slug site would collapse under the \"\" key and mis-write or\n // fail. Skip it loudly rather than silently mis-map it.\n if (slug.length === 0) {\n console.warn(\n `[inventory] skipping \"${w.name}\" (row ${w.id}): Name has no slug-able characters (empty slug)`,\n );\n return [];\n }\n const site: Site = {\n path: `${workdir}/${slug}`,\n name: slug,\n meta: { siteId: w.id, displayName: w.name },\n };\n // Scheme-allowlist the `url` before exposing it as the deployed-audit\n // target (it's handed straight to Chrome/lhci). A `file://`/`gopher://`/\n // internal-host value would be a local-file read or SSRF — skip the\n // deployed audit for that site rather than trust it.\n if (isHttpUrl(w.url)) {\n site.deployedUrl = w.url;\n } else {\n console.warn(\n `[inventory] skipping deployed audit for \"${w.name}\": url is not http(s): ${JSON.stringify(w.url)}`,\n );\n }\n if (w.gitRepo) site.gitRepo = w.gitRepo;\n // Expose the Netlify site id (operator-set identity column) so the\n // netlify-deploy audit can query the API with no checkout. Absent → that\n // audit skips for this site. Not derived from the URL.\n if (w.netlifyId) site.netlifyId = w.netlifyId;\n return [site];\n });\n}\n\n/** The workdir every store-backed provider needs: sites carry a local path, and\n * no store holds checkout paths. */\nexport function requireFleetWorkdir(explicit: string | undefined, provider: string): string {\n const workdir = explicit ?? process.env.REDDOOR_FLEET_WORKDIR;\n if (!workdir) {\n throw new Error(\n `${provider} requires \\`workdir\\` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)`,\n );\n }\n return workdir;\n}\n"],"mappings":";;;;;;;;;;AA2BO,SAAS,iBAAiB,UAAiC,SAAyB;AACzF,SAAO,SACJ;AAAA,IACC,CAAC,MACC,EAAE,WAAW,QACb,gBAAgB,IAAI,EAAE,MAAM,KAC5B,CAAC,YAAY,EAAE,MAAM,KACrB,EAAE,IAAI,SAAS;AAAA,EACnB,EACC,QAAQ,CAAC,MAAM;AACd,UAAM,OAAO,SAAS,EAAE,IAAI;AAK5B,QAAI,KAAK,WAAW,GAAG;AACrB,cAAQ;AAAA,QACN,yBAAyB,EAAE,IAAI,UAAU,EAAE,EAAE;AAAA,MAC/C;AACA,aAAO,CAAC;AAAA,IACV;AACA,UAAM,OAAa;AAAA,MACjB,MAAM,GAAG,OAAO,IAAI,IAAI;AAAA,MACxB,MAAM;AAAA,MACN,MAAM,EAAE,QAAQ,EAAE,IAAI,aAAa,EAAE,KAAK;AAAA,IAC5C;AAKA,QAAI,UAAU,EAAE,GAAG,GAAG;AACpB,WAAK,cAAc,EAAE;AAAA,IACvB,OAAO;AACL,cAAQ;AAAA,QACN,4CAA4C,EAAE,IAAI,0BAA0B,KAAK,UAAU,EAAE,GAAG,CAAC;AAAA,MACnG;AAAA,IACF;AACA,QAAI,EAAE,QAAS,MAAK,UAAU,EAAE;AAIhC,QAAI,EAAE,UAAW,MAAK,YAAY,EAAE;AACpC,WAAO,CAAC,IAAI;AAAA,EACd,CAAC;AACL;AAIO,SAAS,oBAAoB,UAA8B,UAA0B;AAC1F,QAAM,UAAU,YAAY,QAAQ,IAAI;AACxC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI;AAAA,MACR,GAAG,QAAQ;AAAA,IACb;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -184,4 +184,4 @@ export {
184
184
  resolveOwnerRepo,
185
185
  push
186
186
  };
187
- //# sourceMappingURL=chunk-EASYCD6F.js.map
187
+ //# sourceMappingURL=chunk-WMF5S2MB.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/util/git.ts"],"sourcesContent":["import { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { realpath } from \"node:fs/promises\";\nimport type { Site } from \"../types.js\";\n\nconst exec = promisify(execFile);\n\nasync function git(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {\n return exec(\"git\", args, { cwd, env: process.env });\n}\n\nexport function branchName(recipe: string, when: Date = new Date()): string {\n // ISO with millisecond precision: 2026-05-20T10:30:00.123Z → 20260520T103000123Z.\n // Millis (vs. second-precision) shrinks the collision window for parallel runs.\n const compact = when.toISOString().replace(/[-:.]/g, \"\");\n return `maint/${recipe}-${compact}`;\n}\n\nexport async function currentBranch(cwd: string): Promise<string> {\n const { stdout } = await git(cwd, [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"]);\n return stdout.trim();\n}\n\nexport async function isWorkingTreeClean(cwd: string): Promise<boolean> {\n const { stdout } = await git(cwd, [\"status\", \"--porcelain\"]);\n return stdout.trim().length === 0;\n}\n\nexport async function createBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", \"-b\", name]);\n}\n\n/** Check out an existing branch. Throws (via git) if the branch is missing or\n * the checkout is blocked (e.g. uncommitted changes that would be overwritten). */\nexport async function checkoutBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", name]);\n}\n\n/**\n * Force-check-out an existing branch, DISCARDING any uncommitted changes on the\n * current branch. Used by the recipe failure path to return the operator to\n * their original branch even when a recipe left the work-in-progress branch\n * dirty. Only ever called with the operator's ORIGINAL branch as `name`. Does\n * not run `git clean`, so untracked operator files are left untouched.\n */\nexport async function forceCheckoutBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", \"-f\", name]);\n}\n\n/**\n * Delete a local branch with `-D` (force). Used by the recipe failure path to\n * remove the branch the recipe itself created so a re-run starts clean. Callers\n * MUST only ever pass the recipe-created branch here, never the operator's\n * original branch.\n */\nexport async function deleteBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"branch\", \"-D\", name]);\n}\n\nexport async function stageAll(cwd: string): Promise<void> {\n await git(cwd, [\"add\", \"-A\"]);\n}\n\nexport async function listTrackedFiles(cwd: string): Promise<string[]> {\n const { stdout } = await git(cwd, [\"ls-files\"]);\n return stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n}\n\nexport async function removeFromIndex(cwd: string, paths: string[]): Promise<void> {\n if (paths.length === 0) return;\n await git(cwd, [\"rm\", \"-r\", \"--cached\", \"--\", ...paths]);\n}\n\n/**\n * The subset of `paths` that is NOT in HEAD's tree — what a fresh clone would\n * NOT get. POSITIVE evidence: a path counts as installed only when it is FOUND\n * in `git ls-tree`'s output. `git add -A` honours .gitignore and exits 0 either\n * way, so \"staging did not error\" proves nothing about what landed.\n *\n * `-z`, because `ls-tree` C-quotes any path outside core.quotePath's safe set\n * (`\"caf\\303\\251.txt\"`), which would read as missing. NOT `--full-tree`:\n * without it `ls-tree` reports paths relative to `cwd`, the same base the\n * caller's relative paths use.\n *\n * A git failure (no HEAD yet, not a repo) yields an EMPTY tree, so every path\n * comes back missing. An error may only ever DENY.\n */\nexport async function pathsMissingFromHead(\n cwd: string,\n paths: readonly string[],\n): Promise<string[]> {\n if (paths.length === 0) return [];\n let inTree: Set<string>;\n try {\n const { stdout } = await git(cwd, [\"ls-tree\", \"-r\", \"-z\", \"--name-only\", \"HEAD\"]);\n inTree = new Set(stdout.split(\"\\0\").filter((p) => p.length > 0));\n } catch {\n inTree = new Set();\n }\n return paths.filter((p) => !inTree.has(p));\n}\n\n/**\n * Why git is excluding `paths`: `<source>:<line>:<pattern>\\t<path>` rows from\n * `git check-ignore -v --no-index`. A path with no row is absent for some other\n * reason (a nested repository, say) and the caller must say so rather than\n * guess. Never throws: check-ignore exits 1 when NOTHING matches and 128 on\n * error, and both must read as \"no rule found\" — the caller is already\n * reporting a failure and must not lose it to a second one.\n */\nexport async function ignoreRulesFor(cwd: string, paths: readonly string[]): Promise<string[]> {\n if (paths.length === 0) return [];\n let out: string;\n try {\n ({ stdout: out } = await git(cwd, [\"check-ignore\", \"-v\", \"--no-index\", \"--\", ...paths]));\n } catch (err) {\n out = (err as { stdout?: string }).stdout ?? \"\";\n }\n return out.split(\"\\n\").filter((l) => l.length > 0);\n}\n\n/**\n * Stages all current changes and commits with `message`. Returns the commit SHA,\n * or `null` if there was nothing to commit.\n */\nexport async function commit(cwd: string, message: string): Promise<string | null> {\n await stageAll(cwd);\n const { stdout: status } = await git(cwd, [\"status\", \"--porcelain\"]);\n if (status.trim().length === 0) return null;\n await git(cwd, [\"commit\", \"-m\", message]);\n const { stdout: sha } = await git(cwd, [\"rev-parse\", \"HEAD\"]);\n return sha.trim();\n}\n\n/**\n * Strict GitHub repo identity: exactly two `[A-Za-z0-9._-]` segments separated\n * by a single slash. Rejects a scheme, host, extra path segment, traversal\n * (`..`), whitespace, or an argv flag — anything that could retarget a `gh`\n * write at an unintended (attacker/typo-controlled) repo.\n */\nexport const OWNER_REPO_RE = /^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$/;\n\n/** True when `repo` is a clean `owner/repo` (see {@link OWNER_REPO_RE}). The\n * explicit `..` reject covers traversal segments the char-class would otherwise\n * admit (`.` is a legal repo char, so `../evil` matches the shape regex). */\nexport function isOwnerRepo(repo: string): boolean {\n if (repo.includes(\"..\")) return false;\n return OWNER_REPO_RE.test(repo);\n}\n\n/**\n * True when two repo references name the same `owner/repo`. Each side may be a\n * full remote URL (https or scp-style), or already an `owner/repo`. Used to\n * verify an existing checkout's `origin` matches the site's expected repo\n * before reusing it. Returns false if either side is unparseable.\n */\nexport function sameOwnerRepo(a: string, b: string): boolean {\n const na = isOwnerRepo(a.trim()) ? a.trim() : parseOwnerRepo(a);\n const nb = isOwnerRepo(b.trim()) ? b.trim() : parseOwnerRepo(b);\n if (na === null || nb === null) return false;\n return na.toLowerCase() === nb.toLowerCase();\n}\n\n/**\n * The git remote hosts this project will derive a GitHub WRITE identity from.\n * The host is part of the identity: `https://gitlab.com/acme/site.git` names a\n * repository that exists on GitLab, and answering `acme/site` to a caller about\n * to write a repo secret on GitHub is a wrong-repo write, not a parse (#712).\n *\n * `ssh.github.com` is GitHub's documented port-443 SSH endpoint for networks\n * that block 22 — an origin real checkouts really carry; `www.github.com`\n * redirects to the apex and git follows it. Every other host resolves to\n * `null`, deliberately, so the caller refuses instead of guessing.\n */\nconst GITHUB_REMOTE_HOSTS = new Set([\"github.com\", \"www.github.com\", \"ssh.github.com\"]);\n\n/**\n * Derive `owner/repo` from a GITHUB git remote URL (https, ssh, git:// or\n * scp-style). `null` when the remote is not a GitHub repository URL: another\n * host, a traversal segment, a nested or too-short path, a local filesystem\n * path, anything unparseable.\n *\n * PARSED, not pattern-matched (#712). The previous implementation stripped\n * `^https?://[^/]+/` — throwing the HOST away — and returned the last two path\n * segments of whatever remained, so each of these yielded a confident,\n * correctly-shaped GitHub write target for a repository the code was not in:\n * `https://gitlab.com/acme/site.git` -> `acme/site`;\n * `https://user@evil.example.com/attacker/target` -> `attacker/target`;\n * `https://github.com/ok/repo/../../evil/target` -> `evil/target`;\n * `https://github.com/ok/repo?x=/evil/other` -> `evil/other`;\n * `/Users/me/Documents/GitHub/reddoor-starter` -> `GitHub/reddoor-starter`.\n *\n * A `..` anywhere in the raw remote is REFUSED rather than resolved: `new URL`\n * (like curl, and so git-over-https) collapses `ok/repo/../../evil/target` to\n * `/evil/target`, while ssh hands the literal path to the server. The two\n * transports disagree about which repository the remote names, so neither\n * answer is proven — and `..` is already inadmissible in an identity this\n * project accepts (see {@link isOwnerRepo}).\n */\nexport function parseOwnerRepo(remoteUrl: string): string | null {\n const raw = remoteUrl.trim();\n if (raw.length === 0) return null;\n if (raw.includes(\"..\")) return null;\n // scp-style `[user@]host:path` (git@github.com:owner/repo.git). The negative\n // lookahead keeps `https://…`, `ssh://…` and friends out of this branch —\n // their colon is followed by a slash.\n const scp = raw.match(/^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9._-]+):(?!\\/)(.+)$/);\n let url: URL;\n try {\n url = new URL(scp ? `ssh://${scp[1]!}/${scp[2]!}` : raw);\n } catch {\n return null; // not a URL at all — a local path, a typo, free text\n }\n if (!GITHUB_REMOTE_HOSTS.has(url.hostname.toLowerCase())) return null;\n // EXACTLY two segments. `/org/team/repo` is not a GitHub repository path, and\n // its last two name a repository that does not exist. `url.pathname` is the\n // path git resolves — a query string or fragment is not part of it.\n const segments = url.pathname\n .replace(/\\.git$/, \"\")\n .split(\"/\")\n .filter(Boolean);\n if (segments.length !== 2) return null;\n const ownerRepo = `${segments[0]!}/${segments[1]!}`;\n // Percent-encoding and anything else outside the identity's character class\n // ends here rather than reaching a `gh` path.\n return isOwnerRepo(ownerRepo) ? ownerRepo : null;\n}\n\n/** `origin` remote URL for a checkout, trimmed. Throws (via git) if there's no origin. */\nexport async function getRemoteUrl(cwd: string): Promise<string> {\n const { stdout } = await git(cwd, [\"remote\", \"get-url\", \"origin\"]);\n return stdout.trim();\n}\n\n/** What `execFile` rejects with: `code` is git's exit status, or a string errno\n * (`ENOENT`, `EACCES`) when the process could not be run at all. */\ntype GitFailure = { code?: number | string; stderr?: string };\n\n/** No `origin` remote is configured — exit 2, `error: No such remote 'origin'`\n * (measured, git 2.54). The one benign \"nothing wired\" failure. */\nfunction isNoSuchRemote(err: unknown): boolean {\n const e = err as GitFailure;\n return e.code === 2 && /No such remote/i.test(e.stderr ?? \"\");\n}\n\n/** `cwd` is not inside a git work tree — exit 128, `fatal: not a git repository`\n * (measured). Distinct from a dubious-ownership refusal, which also exits 128\n * but says something else and is NOT benign. */\nfunction isNotAWorkTree(err: unknown): boolean {\n const e = err as GitFailure;\n return e.code === 128 && /not a git repository/i.test(e.stderr ?? \"\");\n}\n\n/** Realpath'd `git rev-parse --show-toplevel` for `cwd`; `null` when `cwd` is\n * not inside a work tree. Every OTHER git failure throws — see\n * {@link resolveOwnerRepo} for why that distinction is load-bearing. */\nasync function repoToplevel(cwd: string): Promise<string | null> {\n try {\n const { stdout } = await git(cwd, [\"rev-parse\", \"--show-toplevel\"]);\n return await realpath(stdout.trim());\n } catch (err) {\n if (isNotAWorkTree(err)) return null;\n throw err;\n }\n}\n\n/**\n * Resolve the `owner/repo` a recipe will act on, and PROVE it rather than infer\n * it. An explicit `site.gitRepo` (Airtable, or a JSON inventory) wins;\n * otherwise it is derived from the checkout's `origin`.\n *\n * Returns `null` for the two states that genuinely mean \"nothing is wired\n * here\": `site.path` is not inside a git work tree, and the work tree has no\n * `origin`. Everything else THROWS, naming both sides, because callers write\n * repo secrets, branch-protection rulesets and pull requests at this identity\n * — a wrong one is not cosmetic (#712):\n *\n * - a value that is present but is not a clean `owner/repo`;\n * - an `origin` that is not a GitHub repository URL. A GitLab or impostor host\n * answered with a GitHub write target is the core of this class;\n * - a `site.path` that is not the ROOT of its work tree. `git remote get-url`\n * walks UP, so a site directory with no clone in it yet — exactly what the\n * positional `/new-site` route points at — resolved to whatever repository\n * happened to enclose it. Run a recipe against such a directory from under\n * the maintenance checkout and the token secret plus a ruleset landed on\n * `reddoorla/reddoor-maintenance`. No attacker required; a wrong `cd` was\n * enough. The cost is that \"run me from a subdirectory\" now refuses instead\n * of guessing — the refusal names the root, so the fix is one `cd`, or an\n * explicit identity;\n * - any git failure that is not one of the two benign shapes above. \"There is\n * no remote\" and \"git could not run\" used to be the same answer to the\n * caller, so `selfUpdating` printed \"add an origin remote\" for a missing\n * binary, an unreadable path and a dubious-ownership refusal alike.\n *\n * Extracted from `self-updating`'s private `resolveRepo` because `prismic-ci`\n * read `site.gitRepo` directly and therefore refused every POSITIONAL run with\n * \"no Git repo on this site\" — `localPath()` (src/inventory/local.ts:11) builds\n * `{ path, name }` and nothing else. A site being bootstrapped is exactly the\n * case that has no Airtable row yet (`--fleet airtable` filters pre-launch\n * statuses), so the positional path is the ONLY one `/new-site` can use.\n */\nexport async function resolveOwnerRepo(site: Site): Promise<string | null> {\n // Trimmed: a hand-typed Airtable cell with a trailing space is a typo, not a\n // malformed identity, and a blank cell means \"unset\" — fall through to origin\n // rather than fail closed with an alarming refusal.\n const declared = site.gitRepo?.trim();\n if (declared) {\n if (!isOwnerRepo(declared)) {\n throw new Error(\n `refusing to act on malformed repo identity: expected \"owner/repo\", got ${JSON.stringify(site.gitRepo)}`,\n );\n }\n return declared;\n }\n\n const toplevel = await repoToplevel(site.path);\n if (toplevel === null) return null; // not a work tree — nothing here to derive from\n if ((await realpath(site.path)) !== toplevel) {\n let enclosing = \"\";\n try {\n const id = parseOwnerRepo(await getRemoteUrl(site.path));\n if (id !== null) enclosing = ` — ${id}`;\n } catch {\n // Naming the enclosing repository is a courtesy. Failing to name it is\n // not a reason to stop refusing.\n }\n throw new Error(\n `refusing to derive a write identity from ${JSON.stringify(site.path)}: it is not the root ` +\n `of a git repository. git walks up to the checkout at ${toplevel}${enclosing}, which is ` +\n `not the directory that was asked about — cd to the repository root, or set the repo ` +\n `identity explicitly.`,\n );\n }\n\n let originUrl: string;\n try {\n originUrl = await getRemoteUrl(site.path);\n } catch (err) {\n if (isNoSuchRemote(err)) return null; // the one benign \"nothing wired\" failure\n throw err;\n }\n const fromOrigin = parseOwnerRepo(originUrl);\n if (fromOrigin === null) {\n throw new Error(\n `could not determine a GitHub owner/repo from origin ${JSON.stringify(originUrl)} — ` +\n `expected https://github.com/<owner>/<repo>, git@github.com:<owner>/<repo> or ` +\n `ssh://git@github.com/<owner>/<repo>; refusing to guess a write identity`,\n );\n }\n return fromOrigin;\n}\n\n/** Push a branch to origin, setting upstream. Throws on non-zero (execFile rejects). */\nexport async function push(cwd: string, branch: string): Promise<void> {\n await git(cwd, [\"push\", \"-u\", \"origin\", branch]);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/util/git.ts"],"sourcesContent":["import { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { realpath } from \"node:fs/promises\";\nimport type { Site } from \"../types.js\";\n\nconst exec = promisify(execFile);\n\nasync function git(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {\n return exec(\"git\", args, { cwd, env: process.env });\n}\n\nexport function branchName(recipe: string, when: Date = new Date()): string {\n // ISO with millisecond precision: 2026-05-20T10:30:00.123Z → 20260520T103000123Z.\n // Millis (vs. second-precision) shrinks the collision window for parallel runs.\n const compact = when.toISOString().replace(/[-:.]/g, \"\");\n return `maint/${recipe}-${compact}`;\n}\n\nexport async function currentBranch(cwd: string): Promise<string> {\n const { stdout } = await git(cwd, [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"]);\n return stdout.trim();\n}\n\nexport async function isWorkingTreeClean(cwd: string): Promise<boolean> {\n const { stdout } = await git(cwd, [\"status\", \"--porcelain\"]);\n return stdout.trim().length === 0;\n}\n\nexport async function createBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", \"-b\", name]);\n}\n\n/** Check out an existing branch. Throws (via git) if the branch is missing or\n * the checkout is blocked (e.g. uncommitted changes that would be overwritten). */\nexport async function checkoutBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", name]);\n}\n\n/**\n * Force-check-out an existing branch, DISCARDING any uncommitted changes on the\n * current branch. Used by the recipe failure path to return the operator to\n * their original branch even when a recipe left the work-in-progress branch\n * dirty. Only ever called with the operator's ORIGINAL branch as `name`. Does\n * not run `git clean`, so untracked operator files are left untouched.\n */\nexport async function forceCheckoutBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"checkout\", \"-f\", name]);\n}\n\n/**\n * Delete a local branch with `-D` (force). Used by the recipe failure path to\n * remove the branch the recipe itself created so a re-run starts clean. Callers\n * MUST only ever pass the recipe-created branch here, never the operator's\n * original branch.\n */\nexport async function deleteBranch(cwd: string, name: string): Promise<void> {\n await git(cwd, [\"branch\", \"-D\", name]);\n}\n\nexport async function stageAll(cwd: string): Promise<void> {\n await git(cwd, [\"add\", \"-A\"]);\n}\n\nexport async function listTrackedFiles(cwd: string): Promise<string[]> {\n const { stdout } = await git(cwd, [\"ls-files\"]);\n return stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n}\n\nexport async function removeFromIndex(cwd: string, paths: string[]): Promise<void> {\n if (paths.length === 0) return;\n await git(cwd, [\"rm\", \"-r\", \"--cached\", \"--\", ...paths]);\n}\n\n/**\n * The subset of `paths` that is NOT in HEAD's tree — what a fresh clone would\n * NOT get. POSITIVE evidence: a path counts as installed only when it is FOUND\n * in `git ls-tree`'s output. `git add -A` honours .gitignore and exits 0 either\n * way, so \"staging did not error\" proves nothing about what landed.\n *\n * `-z`, because `ls-tree` C-quotes any path outside core.quotePath's safe set\n * (`\"caf\\303\\251.txt\"`), which would read as missing. NOT `--full-tree`:\n * without it `ls-tree` reports paths relative to `cwd`, the same base the\n * caller's relative paths use.\n *\n * A git failure (no HEAD yet, not a repo) yields an EMPTY tree, so every path\n * comes back missing. An error may only ever DENY.\n */\nexport async function pathsMissingFromHead(\n cwd: string,\n paths: readonly string[],\n): Promise<string[]> {\n if (paths.length === 0) return [];\n let inTree: Set<string>;\n try {\n const { stdout } = await git(cwd, [\"ls-tree\", \"-r\", \"-z\", \"--name-only\", \"HEAD\"]);\n inTree = new Set(stdout.split(\"\\0\").filter((p) => p.length > 0));\n } catch {\n inTree = new Set();\n }\n return paths.filter((p) => !inTree.has(p));\n}\n\n/**\n * Why git is excluding `paths`: `<source>:<line>:<pattern>\\t<path>` rows from\n * `git check-ignore -v --no-index`. A path with no row is absent for some other\n * reason (a nested repository, say) and the caller must say so rather than\n * guess. Never throws: check-ignore exits 1 when NOTHING matches and 128 on\n * error, and both must read as \"no rule found\" — the caller is already\n * reporting a failure and must not lose it to a second one.\n */\nexport async function ignoreRulesFor(cwd: string, paths: readonly string[]): Promise<string[]> {\n if (paths.length === 0) return [];\n let out: string;\n try {\n ({ stdout: out } = await git(cwd, [\"check-ignore\", \"-v\", \"--no-index\", \"--\", ...paths]));\n } catch (err) {\n out = (err as { stdout?: string }).stdout ?? \"\";\n }\n return out.split(\"\\n\").filter((l) => l.length > 0);\n}\n\n/**\n * Stages all current changes and commits with `message`. Returns the commit SHA,\n * or `null` if there was nothing to commit.\n */\nexport async function commit(cwd: string, message: string): Promise<string | null> {\n await stageAll(cwd);\n const { stdout: status } = await git(cwd, [\"status\", \"--porcelain\"]);\n if (status.trim().length === 0) return null;\n await git(cwd, [\"commit\", \"-m\", message]);\n const { stdout: sha } = await git(cwd, [\"rev-parse\", \"HEAD\"]);\n return sha.trim();\n}\n\n/**\n * Strict GitHub repo identity: exactly two `[A-Za-z0-9._-]` segments separated\n * by a single slash. Rejects a scheme, host, extra path segment, traversal\n * (`..`), whitespace, or an argv flag — anything that could retarget a `gh`\n * write at an unintended (attacker/typo-controlled) repo.\n */\nexport const OWNER_REPO_RE = /^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$/;\n\n/** True when `repo` is a clean `owner/repo` (see {@link OWNER_REPO_RE}). The\n * explicit `..` reject covers traversal segments the char-class would otherwise\n * admit (`.` is a legal repo char, so `../evil` matches the shape regex). */\nexport function isOwnerRepo(repo: string): boolean {\n if (repo.includes(\"..\")) return false;\n return OWNER_REPO_RE.test(repo);\n}\n\n/**\n * True when two repo references name the same `owner/repo`. Each side may be a\n * full remote URL (https or scp-style), or already an `owner/repo`. Used to\n * verify an existing checkout's `origin` matches the site's expected repo\n * before reusing it. Returns false if either side is unparseable.\n */\nexport function sameOwnerRepo(a: string, b: string): boolean {\n const na = isOwnerRepo(a.trim()) ? a.trim() : parseOwnerRepo(a);\n const nb = isOwnerRepo(b.trim()) ? b.trim() : parseOwnerRepo(b);\n if (na === null || nb === null) return false;\n return na.toLowerCase() === nb.toLowerCase();\n}\n\n/**\n * The git remote hosts this project will derive a GitHub WRITE identity from.\n * The host is part of the identity: `https://gitlab.com/acme/site.git` names a\n * repository that exists on GitLab, and answering `acme/site` to a caller about\n * to write a repo secret on GitHub is a wrong-repo write, not a parse (#712).\n *\n * `ssh.github.com` is GitHub's documented port-443 SSH endpoint for networks\n * that block 22 — an origin real checkouts really carry; `www.github.com`\n * redirects to the apex and git follows it. Every other host resolves to\n * `null`, deliberately, so the caller refuses instead of guessing.\n */\nconst GITHUB_REMOTE_HOSTS = new Set([\"github.com\", \"www.github.com\", \"ssh.github.com\"]);\n\n/**\n * Derive `owner/repo` from a GITHUB git remote URL (https, ssh, git:// or\n * scp-style). `null` when the remote is not a GitHub repository URL: another\n * host, a traversal segment, a nested or too-short path, a local filesystem\n * path, anything unparseable.\n *\n * PARSED, not pattern-matched (#712). The previous implementation stripped\n * `^https?://[^/]+/` — throwing the HOST away — and returned the last two path\n * segments of whatever remained, so each of these yielded a confident,\n * correctly-shaped GitHub write target for a repository the code was not in:\n * `https://gitlab.com/acme/site.git` -> `acme/site`;\n * `https://user@evil.example.com/attacker/target` -> `attacker/target`;\n * `https://github.com/ok/repo/../../evil/target` -> `evil/target`;\n * `https://github.com/ok/repo?x=/evil/other` -> `evil/other`;\n * `/Users/me/Documents/GitHub/reddoor-starter` -> `GitHub/reddoor-starter`.\n *\n * A `..` anywhere in the raw remote is REFUSED rather than resolved: `new URL`\n * (like curl, and so git-over-https) collapses `ok/repo/../../evil/target` to\n * `/evil/target`, while ssh hands the literal path to the server. The two\n * transports disagree about which repository the remote names, so neither\n * answer is proven — and `..` is already inadmissible in an identity this\n * project accepts (see {@link isOwnerRepo}).\n */\nexport function parseOwnerRepo(remoteUrl: string): string | null {\n const raw = remoteUrl.trim();\n if (raw.length === 0) return null;\n if (raw.includes(\"..\")) return null;\n // scp-style `[user@]host:path` (git@github.com:owner/repo.git). The negative\n // lookahead keeps `https://…`, `ssh://…` and friends out of this branch —\n // their colon is followed by a slash.\n const scp = raw.match(/^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9._-]+):(?!\\/)(.+)$/);\n let url: URL;\n try {\n url = new URL(scp ? `ssh://${scp[1]!}/${scp[2]!}` : raw);\n } catch {\n return null; // not a URL at all — a local path, a typo, free text\n }\n if (!GITHUB_REMOTE_HOSTS.has(url.hostname.toLowerCase())) return null;\n // EXACTLY two segments. `/org/team/repo` is not a GitHub repository path, and\n // its last two name a repository that does not exist. `url.pathname` is the\n // path git resolves — a query string or fragment is not part of it.\n const segments = url.pathname\n .replace(/\\.git$/, \"\")\n .split(\"/\")\n .filter(Boolean);\n if (segments.length !== 2) return null;\n const ownerRepo = `${segments[0]!}/${segments[1]!}`;\n // Percent-encoding and anything else outside the identity's character class\n // ends here rather than reaching a `gh` path.\n return isOwnerRepo(ownerRepo) ? ownerRepo : null;\n}\n\n/** `origin` remote URL for a checkout, trimmed. Throws (via git) if there's no origin. */\nexport async function getRemoteUrl(cwd: string): Promise<string> {\n const { stdout } = await git(cwd, [\"remote\", \"get-url\", \"origin\"]);\n return stdout.trim();\n}\n\n/** What `execFile` rejects with: `code` is git's exit status, or a string errno\n * (`ENOENT`, `EACCES`) when the process could not be run at all. */\ntype GitFailure = { code?: number | string; stderr?: string };\n\n/** No `origin` remote is configured — exit 2, `error: No such remote 'origin'`\n * (measured, git 2.54). The one benign \"nothing wired\" failure. */\nfunction isNoSuchRemote(err: unknown): boolean {\n const e = err as GitFailure;\n return e.code === 2 && /No such remote/i.test(e.stderr ?? \"\");\n}\n\n/** `cwd` is not inside a git work tree — exit 128, `fatal: not a git repository`\n * (measured). Distinct from a dubious-ownership refusal, which also exits 128\n * but says something else and is NOT benign. */\nfunction isNotAWorkTree(err: unknown): boolean {\n const e = err as GitFailure;\n return e.code === 128 && /not a git repository/i.test(e.stderr ?? \"\");\n}\n\n/** Realpath'd `git rev-parse --show-toplevel` for `cwd`; `null` when `cwd` is\n * not inside a work tree. Every OTHER git failure throws — see\n * {@link resolveOwnerRepo} for why that distinction is load-bearing. */\nasync function repoToplevel(cwd: string): Promise<string | null> {\n try {\n const { stdout } = await git(cwd, [\"rev-parse\", \"--show-toplevel\"]);\n return await realpath(stdout.trim());\n } catch (err) {\n if (isNotAWorkTree(err)) return null;\n throw err;\n }\n}\n\n/**\n * Resolve the `owner/repo` a recipe will act on, and PROVE it rather than infer\n * it. An explicit `site.gitRepo` (Airtable, or a JSON inventory) wins;\n * otherwise it is derived from the checkout's `origin`.\n *\n * Returns `null` for the two states that genuinely mean \"nothing is wired\n * here\": `site.path` is not inside a git work tree, and the work tree has no\n * `origin`. Everything else THROWS, naming both sides, because callers write\n * repo secrets, branch-protection rulesets and pull requests at this identity\n * — a wrong one is not cosmetic (#712):\n *\n * - a value that is present but is not a clean `owner/repo`;\n * - an `origin` that is not a GitHub repository URL. A GitLab or impostor host\n * answered with a GitHub write target is the core of this class;\n * - a `site.path` that is not the ROOT of its work tree. `git remote get-url`\n * walks UP, so a site directory with no clone in it yet — exactly what the\n * positional `/new-site` route points at — resolved to whatever repository\n * happened to enclose it. Run a recipe against such a directory from under\n * the maintenance checkout and the token secret plus a ruleset landed on\n * `reddoorla/reddoor-maintenance`. No attacker required; a wrong `cd` was\n * enough. The cost is that \"run me from a subdirectory\" now refuses instead\n * of guessing — the refusal names the root, so the fix is one `cd`, or an\n * explicit identity;\n * - any git failure that is not one of the two benign shapes above. \"There is\n * no remote\" and \"git could not run\" used to be the same answer to the\n * caller, so `selfUpdating` printed \"add an origin remote\" for a missing\n * binary, an unreadable path and a dubious-ownership refusal alike.\n *\n * Extracted from `self-updating`'s private `resolveRepo` because `prismic-ci`\n * read `site.gitRepo` directly and therefore refused every POSITIONAL run with\n * \"no Git repo on this site\" — `localPath()` (src/inventory/local.ts:11) builds\n * `{ path, name }` and nothing else. A site being bootstrapped is exactly the\n * case that is not in the fleet roster yet (`--fleet turso` filters pre-launch\n * statuses), so the positional path is the ONLY one `/new-site` can use.\n */\nexport async function resolveOwnerRepo(site: Site): Promise<string | null> {\n // Trimmed: a hand-typed Airtable cell with a trailing space is a typo, not a\n // malformed identity, and a blank cell means \"unset\" — fall through to origin\n // rather than fail closed with an alarming refusal.\n const declared = site.gitRepo?.trim();\n if (declared) {\n if (!isOwnerRepo(declared)) {\n throw new Error(\n `refusing to act on malformed repo identity: expected \"owner/repo\", got ${JSON.stringify(site.gitRepo)}`,\n );\n }\n return declared;\n }\n\n const toplevel = await repoToplevel(site.path);\n if (toplevel === null) return null; // not a work tree — nothing here to derive from\n if ((await realpath(site.path)) !== toplevel) {\n let enclosing = \"\";\n try {\n const id = parseOwnerRepo(await getRemoteUrl(site.path));\n if (id !== null) enclosing = ` — ${id}`;\n } catch {\n // Naming the enclosing repository is a courtesy. Failing to name it is\n // not a reason to stop refusing.\n }\n throw new Error(\n `refusing to derive a write identity from ${JSON.stringify(site.path)}: it is not the root ` +\n `of a git repository. git walks up to the checkout at ${toplevel}${enclosing}, which is ` +\n `not the directory that was asked about — cd to the repository root, or set the repo ` +\n `identity explicitly.`,\n );\n }\n\n let originUrl: string;\n try {\n originUrl = await getRemoteUrl(site.path);\n } catch (err) {\n if (isNoSuchRemote(err)) return null; // the one benign \"nothing wired\" failure\n throw err;\n }\n const fromOrigin = parseOwnerRepo(originUrl);\n if (fromOrigin === null) {\n throw new Error(\n `could not determine a GitHub owner/repo from origin ${JSON.stringify(originUrl)} — ` +\n `expected https://github.com/<owner>/<repo>, git@github.com:<owner>/<repo> or ` +\n `ssh://git@github.com/<owner>/<repo>; refusing to guess a write identity`,\n );\n }\n return fromOrigin;\n}\n\n/** Push a branch to origin, setting upstream. Throws on non-zero (execFile rejects). */\nexport async function push(cwd: string, branch: string): Promise<void> {\n await git(cwd, [\"push\", \"-u\", \"origin\", branch]);\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  siteSlug
3
- } from "./chunk-KN52PM63.js";
3
+ } from "./chunk-ICSRWW5Q.js";
4
4
 
5
5
  // src/audits/lighthouse-airtable.ts
6
6
  import { readFile } from "fs/promises";
@@ -37,7 +37,7 @@ async function resolveSlugFromCwd(cwd) {
37
37
  return siteSlug(pkg.name);
38
38
  } catch (e) {
39
39
  throw new Error(
40
- `Could not derive site slug from ${cwd}/package.json: ${e.message}. Pass --write-airtable=<slug> explicitly.`,
40
+ `Could not derive site slug from ${cwd}/package.json: ${e.message}. Pass --write-back=<slug> explicitly.`,
41
41
  { cause: e }
42
42
  );
43
43
  }
@@ -48,4 +48,4 @@ export {
48
48
  lighthouseScoresFromResult,
49
49
  resolveSlugFromCwd
50
50
  };
51
- //# sourceMappingURL=chunk-37B4LVB3.js.map
51
+ //# sourceMappingURL=chunk-X56LAKPR.js.map