@reddoorla/maintenance 0.96.0 → 0.98.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.
- package/README.md +5 -6
- package/dist/{announce-45HVP4J4.js → announce-2FH2KBQQ.js} +39 -31
- package/dist/announce-2FH2KBQQ.js.map +1 -0
- package/dist/attachments-JFGFQRXQ.js +10 -0
- package/dist/{blux-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
- package/dist/{bump-deps-EL3TKMIL.js → bump-deps-54K4ECP3.js} +6 -6
- package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
- package/dist/{chunk-EQQ4H6L3.js → chunk-3YALLSBF.js} +2 -2
- package/dist/{chunk-LAJ3FF54.js → chunk-5UI4FERC.js} +16 -5
- package/dist/chunk-5UI4FERC.js.map +1 -0
- package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
- package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
- package/dist/chunk-ASLDCWBF.js +41 -0
- package/dist/chunk-ASLDCWBF.js.map +1 -0
- package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
- package/dist/{chunk-OTEVKRAH.js → chunk-B2BRFWJY.js} +7 -16
- package/dist/chunk-B2BRFWJY.js.map +1 -0
- package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
- package/dist/chunk-BINFZQ2T.js.map +1 -0
- package/dist/{chunk-T76MA2FH.js → chunk-CP6MVZ5X.js} +27 -1
- package/dist/{chunk-T76MA2FH.js.map → chunk-CP6MVZ5X.js.map} +1 -1
- package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
- package/dist/chunk-DRNZVORX.js.map +1 -0
- package/dist/{chunk-XY7YV2YQ.js → chunk-DXZRWW2F.js} +65 -10
- package/dist/chunk-DXZRWW2F.js.map +1 -0
- package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
- package/dist/chunk-DZZ5KRTB.js +23 -0
- package/dist/chunk-DZZ5KRTB.js.map +1 -0
- package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
- package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
- package/dist/chunk-FHFXJ3EM.js.map +1 -0
- package/dist/{chunk-3EA43S3R.js → chunk-GRF2RYEO.js} +12 -12
- package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
- package/dist/chunk-ICSRWW5Q.js +148 -0
- package/dist/chunk-ICSRWW5Q.js.map +1 -0
- package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
- package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
- package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
- package/dist/{chunk-QFSL2L2B.js → chunk-L5F3WYIY.js} +81 -31
- package/dist/chunk-L5F3WYIY.js.map +1 -0
- package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
- package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
- package/dist/chunk-LF2TJN4X.js.map +1 -0
- package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
- package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
- package/dist/chunk-LZMAG73U.js.map +1 -0
- package/dist/{chunk-6INOBNRO.js → chunk-MNRWHSFS.js} +2 -2
- package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
- package/dist/chunk-MVKNU4QD.js.map +1 -0
- package/dist/{chunk-62YWOFVN.js → chunk-ORTOMEYR.js} +3 -3
- package/dist/{chunk-62YWOFVN.js.map → chunk-ORTOMEYR.js.map} +1 -1
- package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
- package/dist/chunk-RQJFGK3A.js +51 -0
- package/dist/chunk-RQJFGK3A.js.map +1 -0
- package/dist/{chunk-H445EH33.js → chunk-S2PLX4XK.js} +10 -7
- package/dist/chunk-S2PLX4XK.js.map +1 -0
- package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
- package/dist/chunk-TC4GANMX.js.map +1 -0
- package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
- package/dist/chunk-TEMQPSCM.js.map +1 -0
- package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
- package/dist/{chunk-A5QKNOXW.js → chunk-TTT6HYPW.js} +47 -10
- package/dist/chunk-TTT6HYPW.js.map +1 -0
- package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
- package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
- package/dist/chunk-UPE6D4AT.js +69 -0
- package/dist/chunk-UPE6D4AT.js.map +1 -0
- package/dist/{chunk-BIYQN7FH.js → chunk-VCPTOZ56.js} +31 -1
- package/dist/{chunk-BIYQN7FH.js.map → chunk-VCPTOZ56.js.map} +1 -1
- package/dist/chunk-VQMAAQ5M.js +53 -0
- package/dist/chunk-VQMAAQ5M.js.map +1 -0
- package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
- package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
- package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
- package/dist/chunk-X56LAKPR.js.map +1 -0
- package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
- package/dist/chunk-YJBSB4LP.js.map +1 -0
- package/dist/{chunk-P4A6D6HO.js → chunk-ZEE5WQYU.js} +32 -18
- package/dist/chunk-ZEE5WQYU.js.map +1 -0
- package/dist/cli/bin.js +92 -50
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +33 -3
- package/dist/cli/commands/audit.js +26 -23
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-3XGSZMLU.js +11 -0
- package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-U2CRNKNY.js} +6 -6
- package/dist/{db-5K4CDEGJ.js → db-7XYTBIPH.js} +25 -18
- package/dist/db-7XYTBIPH.js.map +1 -0
- package/dist/{deadletter-G4M3HSVM.js → deadletter-A7RQVNG3.js} +8 -4
- package/dist/{digest-5AJGWS33.js → digest-AMXRGWZA.js} +34 -33
- package/dist/digest-AMXRGWZA.js.map +1 -0
- package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CX2NCGS7.js} +15 -12
- package/dist/ensure-site-F2WFCWWA.js +70 -0
- package/dist/ensure-site-F2WFCWWA.js.map +1 -0
- package/dist/ensure-site-JEKV2ODE.js +105 -0
- package/dist/ensure-site-JEKV2ODE.js.map +1 -0
- package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
- package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
- package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
- package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
- package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-DK5R326N.js} +17 -13
- package/dist/forms-notify-target-DK5R326N.js.map +1 -0
- package/dist/{github-signals-DMU74SAX.js → github-signals-RGRPYTUB.js} +20 -15
- package/dist/github-signals-RGRPYTUB.js.map +1 -0
- package/dist/{header-image-BRTOLHJF.js → header-image-43ZIEJKP.js} +13 -17
- package/dist/header-image-43ZIEJKP.js.map +1 -0
- package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-K5LXDPGE.js} +7 -7
- package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-WZUHWCCV.js} +10 -10
- package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
- package/dist/index.d.ts +102 -494
- package/dist/index.js +62 -52
- package/dist/index.js.map +1 -1
- package/dist/{init-3LMZTCVD.js → init-OFITGMOP.js} +16 -16
- package/dist/{launch-XTDMC6LD.js → launch-AJY3TGTQ.js} +37 -35
- package/dist/launch-AJY3TGTQ.js.map +1 -0
- package/dist/{lighthouse-52WSPZ5D.js → lighthouse-RFMFREEX.js} +2 -2
- package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
- package/dist/{chunk-IHHAYXKY.js → match-harness-CFELJA57.js} +70 -4
- package/dist/match-harness-CFELJA57.js.map +1 -0
- package/dist/migrate-EZL55AWA.js +7 -0
- package/dist/notify-K3WVH3DM.js +1 -1
- package/dist/{onboard-CZYBWVWH.js → onboard-F7RTLMDZ.js} +6 -6
- package/dist/{orchestrate-TGDFGY52.js → orchestrate-SAIFXZIW.js} +13 -10
- package/dist/parity-2JGVUCZD.js +14 -0
- package/dist/{pipeline-UUPXOS2J.js → pipeline-TCSNVDQZ.js} +3 -3
- package/dist/{preflight-POZZI3GN.js → preflight-6XGVVO2U.js} +35 -11
- package/dist/preflight-6XGVVO2U.js.map +1 -0
- package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-XMX7A5E4.js} +11 -11
- package/dist/prismic-ci-XMX7A5E4.js.map +1 -0
- package/dist/{prismic-models-URYGODNY.js → prismic-models-7GKFAAFA.js} +19 -16
- package/dist/prismic-models-7GKFAAFA.js.map +1 -0
- package/dist/prospect/types.d.ts +44 -2
- package/dist/prospect/types.js +1 -1
- package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-KWP5P5U2.js} +37 -5
- package/dist/prospect-audit-KWP5P5U2.js.map +1 -0
- package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
- package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{render-MHUUHPLN.js → render-NWUAX6RN.js} +3 -3
- package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
- package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-ZZQXT7DA.js} +13 -8
- package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-ZZQXT7DA.js.map} +1 -1
- package/dist/replay-HB44TAII.js +77 -0
- package/dist/replay-HB44TAII.js.map +1 -0
- package/dist/{report-RC5LGPU3.js → report-MVPWS7WP.js} +102 -58
- package/dist/report-MVPWS7WP.js.map +1 -0
- package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-O6IDFDWU.js} +28 -10
- package/dist/report-mirror-O6IDFDWU.js.map +1 -0
- package/dist/roster-EJ3ILR52.js +19 -0
- package/dist/roster-EJ3ILR52.js.map +1 -0
- package/dist/{screenouts-7P5GOZMV.js → screenouts-7NQUWHOJ.js} +14 -1
- package/dist/screenouts-7NQUWHOJ.js.map +1 -0
- package/dist/{self-updating-RKKIYNV4.js → self-updating-KX5FMGEO.js} +7 -7
- package/dist/{selftest-AFLR7YV7.js → selftest-KABY2CYJ.js} +21 -17
- package/dist/{selftest-AFLR7YV7.js.map → selftest-KABY2CYJ.js.map} +1 -1
- package/dist/site-create-ZU3ACBTO.js +45 -0
- package/dist/site-create-ZU3ACBTO.js.map +1 -0
- package/dist/{site-mirror-H2FYMN55.js → site-mirror-TKTDWYP3.js} +10 -10
- package/dist/site-mirror-TKTDWYP3.js.map +1 -0
- package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-QQMV7TNA.js} +7 -7
- package/dist/{submissions-JDZP35JM.js → submissions-4I66W6QK.js} +11 -1
- package/dist/{submissions-JDZP35JM.js.map → submissions-4I66W6QK.js.map} +1 -1
- package/dist/{submissions-II5J35QM.js → submissions-K5UXGZUN.js} +12 -7
- package/dist/submissions-K5UXGZUN.js.map +1 -0
- package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-YFN4TEGV.js} +6 -6
- package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
- package/dist/{sync-configs-HGLQV55H.js → sync-configs-HBXS3D7C.js} +6 -6
- package/dist/turso-KQKBBQCK.js +24 -0
- package/dist/turso-KQKBBQCK.js.map +1 -0
- package/dist/{upgrade-WZ44IARG.js → upgrade-XOJMDIT4.js} +6 -6
- package/dist/util/git.d.ts +1 -1
- package/dist/util/git.js +1 -1
- package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
- package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
- package/package.json +1 -1
- package/dist/airtable-SJV4ZSMS.js +0 -9
- package/dist/announce-45HVP4J4.js.map +0 -1
- package/dist/attachments-NLZIJ5NU.js +0 -9
- package/dist/chunk-37B4LVB3.js.map +0 -1
- package/dist/chunk-72FJZ6LO.js.map +0 -1
- package/dist/chunk-74QRWE6G.js +0 -53
- package/dist/chunk-74QRWE6G.js.map +0 -1
- package/dist/chunk-A5QKNOXW.js.map +0 -1
- package/dist/chunk-CGQZFQAY.js.map +0 -1
- package/dist/chunk-F3JWOPTG.js.map +0 -1
- package/dist/chunk-GEUC5VGM.js.map +0 -1
- package/dist/chunk-H445EH33.js.map +0 -1
- package/dist/chunk-IHHAYXKY.js.map +0 -1
- package/dist/chunk-IY6256GK.js.map +0 -1
- package/dist/chunk-KN52PM63.js.map +0 -1
- package/dist/chunk-LAJ3FF54.js.map +0 -1
- package/dist/chunk-MZZDMBDT.js.map +0 -1
- package/dist/chunk-OTEVKRAH.js.map +0 -1
- package/dist/chunk-P4A6D6HO.js.map +0 -1
- package/dist/chunk-QFSL2L2B.js.map +0 -1
- package/dist/chunk-UIITN37J.js.map +0 -1
- package/dist/chunk-XY7YV2YQ.js.map +0 -1
- package/dist/chunk-YFAXOD7Z.js +0 -42
- package/dist/chunk-YFAXOD7Z.js.map +0 -1
- package/dist/chunk-ZH5LOQET.js.map +0 -1
- package/dist/client-L7NAUG6J.js +0 -11
- package/dist/db-5K4CDEGJ.js.map +0 -1
- package/dist/digest-5AJGWS33.js.map +0 -1
- package/dist/ensure-site-XESZHZEB.js.map +0 -1
- package/dist/fleet-events-cF2Om88w.d.ts +0 -24
- package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
- package/dist/github-signals-DMU74SAX.js.map +0 -1
- package/dist/header-image-BRTOLHJF.js.map +0 -1
- package/dist/launch-XTDMC6LD.js.map +0 -1
- package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
- package/dist/match-harness-OW45CB5H.js +0 -77
- package/dist/match-harness-OW45CB5H.js.map +0 -1
- package/dist/migrate-NE3DUMTM.js +0 -7
- package/dist/parity-AEKYSLN6.js +0 -14
- package/dist/preflight-POZZI3GN.js.map +0 -1
- package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
- package/dist/prismic-models-URYGODNY.js.map +0 -1
- package/dist/prospect-audit-AJEE3Z4L.js.map +0 -1
- package/dist/replay-4FVFOMHH.js +0 -54
- package/dist/replay-4FVFOMHH.js.map +0 -1
- package/dist/report-RC5LGPU3.js.map +0 -1
- package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
- package/dist/screenouts-7P5GOZMV.js.map +0 -1
- package/dist/site-mirror-H2FYMN55.js.map +0 -1
- package/dist/submissions-II5J35QM.js.map +0 -1
- package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
- /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
- /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
- /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-54K4ECP3.js.map} +0 -0
- /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
- /package/dist/{chunk-EQQ4H6L3.js.map → chunk-3YALLSBF.js.map} +0 -0
- /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
- /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
- /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
- /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
- /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
- /package/dist/{chunk-3EA43S3R.js.map → chunk-GRF2RYEO.js.map} +0 -0
- /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
- /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
- /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
- /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
- /package/dist/{chunk-6INOBNRO.js.map → chunk-MNRWHSFS.js.map} +0 -0
- /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
- /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
- /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
- /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
- /package/dist/{attachments-NLZIJ5NU.js.map → client-3XGSZMLU.js.map} +0 -0
- /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-U2CRNKNY.js.map} +0 -0
- /package/dist/{client-L7NAUG6J.js.map → deadletter-A7RQVNG3.js.map} +0 -0
- /package/dist/{deadletter-G4M3HSVM.js.map → digest-collectors-CX2NCGS7.js.map} +0 -0
- /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
- /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-K5LXDPGE.js.map} +0 -0
- /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-WZUHWCCV.js.map} +0 -0
- /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
- /package/dist/{init-3LMZTCVD.js.map → init-OFITGMOP.js.map} +0 -0
- /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-RFMFREEX.js.map} +0 -0
- /package/dist/{lighthouse-52WSPZ5D.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
- /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-EZL55AWA.js.map} +0 -0
- /package/dist/{onboard-CZYBWVWH.js.map → onboard-F7RTLMDZ.js.map} +0 -0
- /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-SAIFXZIW.js.map} +0 -0
- /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
- /package/dist/{parity-AEKYSLN6.js.map → pipeline-TCSNVDQZ.js.map} +0 -0
- /package/dist/{render-MHUUHPLN.js.map → render-NWUAX6RN.js.map} +0 -0
- /package/dist/{pipeline-UUPXOS2J.js.map → render-from-row-75JTED2Z.js.map} +0 -0
- /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-KX5FMGEO.js.map} +0 -0
- /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-QQMV7TNA.js.map} +0 -0
- /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-YFN4TEGV.js.map} +0 -0
- /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
- /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-HBXS3D7C.js.map} +0 -0
- /package/dist/{upgrade-WZ44IARG.js.map → upgrade-XOJMDIT4.js.map} +0 -0
- /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
- /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/audits/lighthouse.ts","../src/audits/util/site-config.ts"],"sourcesContent":["import { readFile, writeFile, mkdtemp, rm, readdir } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AuditResult, Site } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { lighthouseConfig } from \"../configs/lighthouse.js\";\nimport { defaultSpawn } from \"./util/spawn.js\";\nimport type { SpawnFn, SpawnResult } from \"./util/spawn.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { readSiteConfig } from \"./util/site-config.js\";\nimport { findFreePort, withFreePort } from \"../util/free-port.js\";\n\ntype ManifestEntry = {\n url: string;\n summary: Record<string, number>;\n htmlPath?: string;\n jsonPath?: string;\n};\n\ntype AssertionResult = {\n name: string;\n actual: number;\n expected: number;\n operator: string;\n passed: boolean;\n level: \"warn\" | \"error\";\n auditProperty?: string;\n auditId?: string;\n};\n\ntype NormalizedLhciResult = {\n summary: Record<string, number>;\n assertionsFailed: number;\n assertions: Array<{ category: string; level: \"warn\" | \"error\"; message: string }>;\n};\n\nasync function readJsonMaybe<T>(path: string): Promise<T | null> {\n try {\n const raw = await readFile(path, \"utf-8\");\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\ntype LhrFile = {\n requestedUrl: string;\n finalUrl?: string;\n categories: Record<string, { score: number | null }>;\n};\n\n/**\n * Build manifest-equivalent entries by scanning the `.lighthouseci/` dir\n * for `lhr-*.json` files written by `lhci collect`. We used to read\n * `manifest.json` directly, but lhci 0.15+ no longer writes it — the\n * audit would silently return \"no manifest written\" against a perfectly\n * healthy run. Reproduced on caltex 2026-05-28 (0.10.5 dogfood).\n */\nasync function readLhrEntries(resultsDir: string): Promise<ManifestEntry[]> {\n const files = await readdir(resultsDir).catch(() => [] as string[]);\n const entries: ManifestEntry[] = [];\n for (const f of files) {\n if (!f.startsWith(\"lhr-\") || !f.endsWith(\".json\")) continue;\n const lhr = await readJsonMaybe<LhrFile>(join(resultsDir, f));\n if (!lhr || !lhr.categories) continue;\n const summary: Record<string, number> = {};\n for (const [k, v] of Object.entries(lhr.categories)) {\n if (typeof v?.score === \"number\") summary[k] = v.score;\n }\n entries.push({ url: lhr.requestedUrl, summary });\n }\n return entries;\n}\n\nfunction averageSummaries(entries: ManifestEntry[]): Record<string, number> {\n if (entries.length === 0) return {};\n const sums: Record<string, number> = {};\n const counts: Record<string, number> = {};\n for (const e of entries) {\n for (const [k, v] of Object.entries(e.summary ?? {})) {\n if (typeof v !== \"number\") continue;\n sums[k] = (sums[k] ?? 0) + v;\n counts[k] = (counts[k] ?? 0) + 1;\n }\n }\n const out: Record<string, number> = {};\n for (const k of Object.keys(sums)) {\n const total = sums[k] ?? 0;\n const count = counts[k] ?? 1;\n out[k] = total / count;\n }\n return out;\n}\n\nfunction categoryFromAssertion(a: AssertionResult): string {\n // `name` looks like \"categories:accessibility\" or \"audits:uses-http2\".\n const colonIdx = a.name.indexOf(\":\");\n return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;\n}\n\nfunction messageForAssertion(a: AssertionResult): string {\n // `a.actual` is parsed from external lhci/Lighthouse JSON; a malformed or\n // missing value (not a number) would make `.toFixed` throw and crash the whole\n // audit. Guard it and fall back to a readable string instead.\n const actual = typeof a.actual === \"number\" ? a.actual.toFixed(2) : \"n/a\";\n return `${a.name} ${a.operator} ${a.expected} (actual: ${actual})`;\n}\n\n/** Shared tail: scan `.lighthouseci/` for lhr-*.json + assertion-results.json and\n * build the AuditResult. Identical for the checkout and deployed paths. */\nasync function parseLhciResults(\n resultsDir: string,\n label: string,\n raw: SpawnResult,\n): Promise<AuditResult> {\n const manifest = await readLhrEntries(resultsDir);\n\n if (manifest.length === 0) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"fail\",\n summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${\n raw.stderr ? ` — ${raw.stderr.slice(0, 200)}` : \"\"\n }`,\n };\n }\n\n const assertionResults =\n (await readJsonMaybe<AssertionResult[]>(join(resultsDir, \"assertion-results.json\"))) ?? [];\n\n const failed = assertionResults.filter((a) => !a.passed);\n const assertions = failed.map((a) => ({\n category: categoryFromAssertion(a),\n level: a.level,\n message: messageForAssertion(a),\n }));\n\n const anyError = assertions.some((a) => a.level === \"error\");\n const anyWarn = assertions.some((a) => a.level === \"warn\");\n const status: AuditResult[\"status\"] = anyError ? \"fail\" : anyWarn ? \"warn\" : \"pass\";\n\n const normalized: NormalizedLhciResult = {\n summary: averageSummaries(manifest),\n assertionsFailed: failed.length,\n assertions,\n };\n\n const summary =\n status === \"pass\"\n ? \"lighthouse: all categories passing\"\n : `lighthouse: ${failed.length} assertion(s) failed`;\n\n return { audit: \"lighthouse\", site: label, status, summary, details: normalized };\n}\n\n/** Checkout mode (unchanged behavior): boot the site's vite dev server on a\n * pinned free port and audit the local fixtures/override URL. */\nasync function checkoutLighthouse(spawn: SpawnFn, site: Site, label: string): Promise<AuditResult> {\n const siteCfg = await readSiteConfig(site.path);\n // Allocate a free port + force vite to `--strictPort` so the spawned dev\n // server either binds the port we picked or fails loudly (caltex 2026-05-28\n // zombie-vite incident).\n const port = await findFreePort();\n const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];\n const resolvedConfig = {\n ...lighthouseConfig,\n ci: {\n ...lighthouseConfig.ci,\n collect: {\n ...lighthouseConfig.ci.collect,\n url: [withFreePort(baseUrl, port)],\n startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`,\n },\n },\n };\n\n const configDir = await mkdtemp(join(tmpdir(), \"reddoor-lhci-\"));\n const configPath = join(configDir, \"lighthouserc.json\");\n await writeFile(configPath, JSON.stringify(resolvedConfig), \"utf-8\");\n\n const resultsDir = join(site.path, \".lighthouseci\");\n await rm(resultsDir, { recursive: true, force: true });\n\n let raw: SpawnResult;\n try {\n raw = await spawn(\"npx\", [\"--yes\", \"@lhci/cli\", \"autorun\", `--config=${configPath}`], {\n cwd: site.path,\n timeoutMs: 5 * 60_000,\n });\n } catch (err) {\n await rm(configDir, { recursive: true, force: true });\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"skip\",\n summary: \"npx/@lhci/cli not available\",\n };\n }\n throw err;\n }\n await rm(configDir, { recursive: true, force: true });\n\n return parseLhciResults(resultsDir, label, raw);\n}\n\n/** Deployed mode: audit a production URL directly — no checkout, no dev server.\n * Runs in a throwaway tmp cwd; uploads to the filesystem so fleet runs never\n * push 200 public reports to temporary-public-storage. */\nasync function deployedLighthouse(\n spawn: SpawnFn,\n deployedUrl: string,\n label: string,\n): Promise<AuditResult> {\n const workDir = await mkdtemp(join(tmpdir(), \"reddoor-lh-deployed-\"));\n const resolvedConfig = {\n ci: {\n // Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode\n // must omit startServerCommand and the dev-server settings entirely.\n collect: {\n url: [deployedUrl],\n // 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults\n // averages the lhr files. (Median is a tracked future refinement.)\n numberOfRuns: 3,\n // Use devtools (applied) throttling instead of the desktop preset's\n // default `simulate` (Lantern). Lantern estimates LCP from the\n // dependency graph and can't model an LCP element revealed by a\n // JS-load opacity fade (e.g. gallerysonder's GridImage): on a fast\n // observed load it finds no LCP node and throws NO_LCP, nulling the\n // perf score, which the cockpit then coerces to a misleading 0.\n // devtools reads the real LCP paint event from the throttled trace\n // instead. Trade-off: slightly noisier run-to-run, damped by the\n // numberOfRuns:3 average above.\n settings: { preset: \"desktop\", throttlingMethod: \"devtools\", skipAudits: [\"uses-http2\"] },\n },\n assert: lighthouseConfig.ci.assert,\n upload: { target: \"filesystem\", outputDir: join(workDir, \"lhci-report\") },\n },\n };\n\n const configPath = join(workDir, \"lighthouserc.json\");\n await writeFile(configPath, JSON.stringify(resolvedConfig), \"utf-8\");\n\n const resultsDir = join(workDir, \".lighthouseci\");\n\n let raw: SpawnResult;\n try {\n raw = await spawn(\"npx\", [\"--yes\", \"@lhci/cli\", \"autorun\", `--config=${configPath}`], {\n cwd: workDir,\n // 3 serial cold runs of a slow deployed site (lhci's own maxWaitForLoad\n // ~45-60s each) + first-use Chrome download can plausibly exceed 3 min →\n // SIGTERM → no lhr-*.json → spurious \"no scores\". Match the 5-min budget\n // the checkout path already gives (erp-industrials nightly flake,\n // morning-brief 2026-06-10 MEDIUM-F).\n timeoutMs: 5 * 60_000,\n });\n } catch (err) {\n await rm(workDir, { recursive: true, force: true });\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"skip\",\n summary: \"npx/@lhci/cli not available\",\n };\n }\n throw err;\n }\n\n try {\n return await parseLhciResults(resultsDir, label, raw);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\nexport async function lighthouseAudit(ctx: AuditContext): Promise<AuditResult> {\n const spawn = ctx.spawn ?? defaultSpawn;\n const site = ctx.site;\n const label = siteLabel(site);\n\n return site.deployedUrl\n ? deployedLighthouse(spawn, site.deployedUrl, label)\n : checkoutLighthouse(spawn, site, label);\n}\n","import { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nexport type SiteConfig = {\n /** Override URL the lighthouse audit hits. Sites without the default\n * `/dev/a11y-fixtures` dev route set this to their homepage. */\n lighthouseUrl?: string;\n /**\n * Real site routes the a11y audit should axe-scan, IN ADDITION to the two\n * synthetic fixture pages it always covers. Opt-in per site and absent by\n * default: the audit runs with `--fail-on-violations` in the shared CI\n * workflow, and most of the fleet carries pre-existing a11y debt, so scanning\n * real routes everywhere at once would red every repo. A site adopts this once\n * its routes are clean. Omitted (never `[]`) when unset or unusable.\n */\n a11yRoutes?: string[];\n /**\n * Which server the browser gates run against: `\"dev\"` (the default — `vite\n * dev`) or `\"preview\"` (a real `vite build`, served by `vite preview`).\n *\n * Both gates measured the dev server, so the production bundle was built in\n * CI and then never opened by a browser — and dev does not merely fail to\n * reproduce some defects, it hides them. Module graph, code splitting,\n * minification and asset hashing are most of what \"hydration works\" means,\n * and a stylesheet can even be fetched under a different CSP directive in\n * each (#700).\n *\n * Opt-in, because a preview costs a `pnpm build` per run and because the\n * `/dev/*` fixture routes the axe scan targets are not guaranteed to survive\n * one — a site that flipped this without checking would trade a working gate\n * for one reporting every fixture as a missing route. Omitted (never\n * defaulted here) so each caller states its own default.\n *\n * Requires a `preview` script in the site's package.json; the starter has one.\n */\n gateServer?: \"dev\" | \"preview\";\n};\n\n/**\n * Read per-site overrides from `package.json#reddoor`. Returns `{}` on any\n * failure (missing file, malformed JSON, missing key, wrong type) so every\n * caller can safely fall back to its built-in default. Never throws.\n */\nexport async function readSiteConfig(sitePath: string): Promise<SiteConfig> {\n let raw: string;\n try {\n raw = await readFile(join(sitePath, \"package.json\"), \"utf-8\");\n } catch {\n return {};\n }\n let pkg: unknown;\n try {\n pkg = JSON.parse(raw);\n } catch {\n return {};\n }\n if (!pkg || typeof pkg !== \"object\") return {};\n const cfg = (pkg as { reddoor?: unknown }).reddoor;\n if (!cfg || typeof cfg !== \"object\") return {};\n\n const out: SiteConfig = {};\n const url = (cfg as { lighthouseUrl?: unknown }).lighthouseUrl;\n if (typeof url === \"string\" && url.length > 0) {\n out.lighthouseUrl = url;\n }\n const routes = (cfg as { a11yRoutes?: unknown }).a11yRoutes;\n if (Array.isArray(routes)) {\n // Junk entries are dropped rather than passed through — a `null` in the list\n // would become `page.goto(null)` inside the generated spec and fail the whole\n // audit for a typo. An all-junk list leaves the key omitted, so the caller's\n // \"no routes configured\" branch is the same shape as \"key absent\".\n const clean = routes\n .filter((r): r is string => typeof r === \"string\")\n .map((r) => r.trim())\n .filter((r) => r.length > 0);\n if (clean.length > 0) out.a11yRoutes = clean;\n }\n // Anything but the two known values reads as absent. This string reaches a\n // shell as part of a `webServer.command`, so a typo must fall back to the\n // default rather than be forwarded — `\"prod\"` would become `npm run prod`.\n const gate = (cfg as { gateServer?: unknown }).gateServer;\n if (gate === \"dev\" || gate === \"preview\") out.gateServer = gate;\n\n return out;\n}\n"],"mappings":"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","names":["readFile","join","readFile","join"]}
|
|
1
|
+
{"version":3,"sources":["../src/audits/lighthouse.ts","../src/audits/util/site-config.ts"],"sourcesContent":["import { readFile, writeFile, mkdtemp, rm, readdir } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AuditResult, Site } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { lighthouseConfig } from \"../configs/lighthouse.js\";\nimport { defaultSpawn } from \"./util/spawn.js\";\nimport type { SpawnFn, SpawnResult } from \"./util/spawn.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { readSiteConfig } from \"./util/site-config.js\";\nimport { findFreePort, withFreePort } from \"../util/free-port.js\";\n\ntype ManifestEntry = {\n url: string;\n summary: Record<string, number>;\n htmlPath?: string;\n jsonPath?: string;\n};\n\ntype AssertionResult = {\n name: string;\n actual: number;\n expected: number;\n operator: string;\n passed: boolean;\n level: \"warn\" | \"error\";\n auditProperty?: string;\n auditId?: string;\n};\n\ntype NormalizedLhciResult = {\n summary: Record<string, number>;\n assertionsFailed: number;\n assertions: Array<{ category: string; level: \"warn\" | \"error\"; message: string }>;\n};\n\nasync function readJsonMaybe<T>(path: string): Promise<T | null> {\n try {\n const raw = await readFile(path, \"utf-8\");\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\ntype LhrFile = {\n requestedUrl: string;\n finalUrl?: string;\n categories: Record<string, { score: number | null }>;\n};\n\n/**\n * Build manifest-equivalent entries by scanning the `.lighthouseci/` dir\n * for `lhr-*.json` files written by `lhci collect`. We used to read\n * `manifest.json` directly, but lhci 0.15+ no longer writes it — the\n * audit would silently return \"no manifest written\" against a perfectly\n * healthy run. Reproduced on caltex 2026-05-28 (0.10.5 dogfood).\n */\nasync function readLhrEntries(resultsDir: string): Promise<ManifestEntry[]> {\n const files = await readdir(resultsDir).catch(() => [] as string[]);\n const entries: ManifestEntry[] = [];\n for (const f of files) {\n if (!f.startsWith(\"lhr-\") || !f.endsWith(\".json\")) continue;\n const lhr = await readJsonMaybe<LhrFile>(join(resultsDir, f));\n if (!lhr || !lhr.categories) continue;\n const summary: Record<string, number> = {};\n for (const [k, v] of Object.entries(lhr.categories)) {\n if (typeof v?.score === \"number\") summary[k] = v.score;\n }\n entries.push({ url: lhr.requestedUrl, summary });\n }\n return entries;\n}\n\nfunction averageSummaries(entries: ManifestEntry[]): Record<string, number> {\n if (entries.length === 0) return {};\n const sums: Record<string, number> = {};\n const counts: Record<string, number> = {};\n for (const e of entries) {\n for (const [k, v] of Object.entries(e.summary ?? {})) {\n if (typeof v !== \"number\") continue;\n sums[k] = (sums[k] ?? 0) + v;\n counts[k] = (counts[k] ?? 0) + 1;\n }\n }\n const out: Record<string, number> = {};\n for (const k of Object.keys(sums)) {\n const total = sums[k] ?? 0;\n const count = counts[k] ?? 1;\n out[k] = total / count;\n }\n return out;\n}\n\nfunction categoryFromAssertion(a: AssertionResult): string {\n // `name` looks like \"categories:accessibility\" or \"audits:uses-http2\".\n const colonIdx = a.name.indexOf(\":\");\n return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;\n}\n\nfunction messageForAssertion(a: AssertionResult): string {\n // `a.actual` is parsed from external lhci/Lighthouse JSON; a malformed or\n // missing value (not a number) would make `.toFixed` throw and crash the whole\n // audit. Guard it and fall back to a readable string instead.\n const actual = typeof a.actual === \"number\" ? a.actual.toFixed(2) : \"n/a\";\n return `${a.name} ${a.operator} ${a.expected} (actual: ${actual})`;\n}\n\n/** Shared tail: scan `.lighthouseci/` for lhr-*.json + assertion-results.json and\n * build the AuditResult. Identical for the checkout and deployed paths. */\nasync function parseLhciResults(\n resultsDir: string,\n label: string,\n raw: SpawnResult,\n): Promise<AuditResult> {\n const manifest = await readLhrEntries(resultsDir);\n\n if (manifest.length === 0) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"fail\",\n summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${\n raw.stderr ? ` — ${raw.stderr.slice(0, 200)}` : \"\"\n }`,\n };\n }\n\n const assertionResults =\n (await readJsonMaybe<AssertionResult[]>(join(resultsDir, \"assertion-results.json\"))) ?? [];\n\n const failed = assertionResults.filter((a) => !a.passed);\n const assertions = failed.map((a) => ({\n category: categoryFromAssertion(a),\n level: a.level,\n message: messageForAssertion(a),\n }));\n\n const anyError = assertions.some((a) => a.level === \"error\");\n const anyWarn = assertions.some((a) => a.level === \"warn\");\n const status: AuditResult[\"status\"] = anyError ? \"fail\" : anyWarn ? \"warn\" : \"pass\";\n\n const normalized: NormalizedLhciResult = {\n summary: averageSummaries(manifest),\n assertionsFailed: failed.length,\n assertions,\n };\n\n const summary =\n status === \"pass\"\n ? \"lighthouse: all categories passing\"\n : `lighthouse: ${failed.length} assertion(s) failed`;\n\n return { audit: \"lighthouse\", site: label, status, summary, details: normalized };\n}\n\n/** Checkout mode (unchanged behavior): boot the site's vite dev server on a\n * pinned free port and audit the local fixtures/override URL. */\nasync function checkoutLighthouse(spawn: SpawnFn, site: Site, label: string): Promise<AuditResult> {\n const siteCfg = await readSiteConfig(site.path);\n // Allocate a free port + force vite to `--strictPort` so the spawned dev\n // server either binds the port we picked or fails loudly (caltex 2026-05-28\n // zombie-vite incident).\n const port = await findFreePort();\n const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];\n const resolvedConfig = {\n ...lighthouseConfig,\n ci: {\n ...lighthouseConfig.ci,\n collect: {\n ...lighthouseConfig.ci.collect,\n url: [withFreePort(baseUrl, port)],\n startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`,\n },\n },\n };\n\n const configDir = await mkdtemp(join(tmpdir(), \"reddoor-lhci-\"));\n const configPath = join(configDir, \"lighthouserc.json\");\n await writeFile(configPath, JSON.stringify(resolvedConfig), \"utf-8\");\n\n const resultsDir = join(site.path, \".lighthouseci\");\n await rm(resultsDir, { recursive: true, force: true });\n\n let raw: SpawnResult;\n try {\n raw = await spawn(\"npx\", [\"--yes\", \"@lhci/cli\", \"autorun\", `--config=${configPath}`], {\n cwd: site.path,\n timeoutMs: 5 * 60_000,\n });\n } catch (err) {\n await rm(configDir, { recursive: true, force: true });\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"skip\",\n summary: \"npx/@lhci/cli not available\",\n };\n }\n throw err;\n }\n await rm(configDir, { recursive: true, force: true });\n\n return parseLhciResults(resultsDir, label, raw);\n}\n\n/** Deployed mode: audit a production URL directly — no checkout, no dev server.\n * Runs in a throwaway tmp cwd; uploads to the filesystem so fleet runs never\n * push 200 public reports to temporary-public-storage. */\nasync function deployedLighthouse(\n spawn: SpawnFn,\n deployedUrl: string,\n label: string,\n): Promise<AuditResult> {\n const workDir = await mkdtemp(join(tmpdir(), \"reddoor-lh-deployed-\"));\n const resolvedConfig = {\n ci: {\n // Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode\n // must omit startServerCommand and the dev-server settings entirely.\n collect: {\n url: [deployedUrl],\n // 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults\n // averages the lhr files. (Median is a tracked future refinement.)\n numberOfRuns: 3,\n // Use devtools (applied) throttling instead of the desktop preset's\n // default `simulate` (Lantern). Lantern estimates LCP from the\n // dependency graph and can't model an LCP element revealed by a\n // JS-load opacity fade (e.g. gallerysonder's GridImage): on a fast\n // observed load it finds no LCP node and throws NO_LCP, nulling the\n // perf score, which the cockpit then coerces to a misleading 0.\n // devtools reads the real LCP paint event from the throttled trace\n // instead. Trade-off: slightly noisier run-to-run, damped by the\n // numberOfRuns:3 average above.\n settings: { preset: \"desktop\", throttlingMethod: \"devtools\", skipAudits: [\"uses-http2\"] },\n },\n assert: lighthouseConfig.ci.assert,\n upload: { target: \"filesystem\", outputDir: join(workDir, \"lhci-report\") },\n },\n };\n\n const configPath = join(workDir, \"lighthouserc.json\");\n await writeFile(configPath, JSON.stringify(resolvedConfig), \"utf-8\");\n\n const resultsDir = join(workDir, \".lighthouseci\");\n\n let raw: SpawnResult;\n try {\n raw = await spawn(\"npx\", [\"--yes\", \"@lhci/cli\", \"autorun\", `--config=${configPath}`], {\n cwd: workDir,\n // 3 serial cold runs of a slow deployed site (lhci's own maxWaitForLoad\n // ~45-60s each) + first-use Chrome download can plausibly exceed 3 min →\n // SIGTERM → no lhr-*.json → spurious \"no scores\". Match the 5-min budget\n // the checkout path already gives (erp-industrials nightly flake,\n // morning-brief 2026-06-10 MEDIUM-F).\n timeoutMs: 5 * 60_000,\n });\n } catch (err) {\n await rm(workDir, { recursive: true, force: true });\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"skip\",\n summary: \"npx/@lhci/cli not available\",\n };\n }\n throw err;\n }\n\n try {\n return await parseLhciResults(resultsDir, label, raw);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\nexport async function lighthouseAudit(ctx: AuditContext): Promise<AuditResult> {\n const spawn = ctx.spawn ?? defaultSpawn;\n const site = ctx.site;\n const label = siteLabel(site);\n\n return site.deployedUrl\n ? deployedLighthouse(spawn, site.deployedUrl, label)\n : checkoutLighthouse(spawn, site, label);\n}\n","import { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nexport type SiteConfig = {\n /** Override URL the lighthouse audit hits. Sites without the default\n * `/dev/a11y-fixtures` dev route set this to their homepage. */\n lighthouseUrl?: string;\n /**\n * Real site routes the a11y audit should axe-scan, IN ADDITION to the two\n * synthetic fixture pages it always covers. Opt-in per site and absent by\n * default: the audit runs with `--fail-on-violations` in the shared CI\n * workflow, and most of the fleet carries pre-existing a11y debt, so scanning\n * real routes everywhere at once would red every repo. A site adopts this once\n * its routes are clean. Omitted (never `[]`) when unset or unusable.\n */\n a11yRoutes?: string[];\n /**\n * Which server the browser gates run against: `\"dev\"` (the default — `vite\n * dev`) or `\"preview\"` (a real `vite build`, served by `vite preview`).\n *\n * Both gates measured the dev server, so the production bundle was built in\n * CI and then never opened by a browser — and dev does not merely fail to\n * reproduce some defects, it hides them. Module graph, code splitting,\n * minification and asset hashing are most of what \"hydration works\" means,\n * and a stylesheet can even be fetched under a different CSP directive in\n * each (#700).\n *\n * Opt-in, because a preview costs a `pnpm build` per run and because the\n * `/dev/*` fixture routes the axe scan targets are not guaranteed to survive\n * one — a site that flipped this without checking would trade a working gate\n * for one reporting every fixture as a missing route. Omitted (never\n * defaulted here) so each caller states its own default.\n *\n * Requires a `preview` script in the site's package.json; the starter has one.\n */\n gateServer?: \"dev\" | \"preview\";\n};\n\n/**\n * Read per-site overrides from `package.json#reddoor`. Returns `{}` on any\n * failure (missing file, malformed JSON, missing key, wrong type) so every\n * caller can safely fall back to its built-in default. Never throws.\n */\nexport async function readSiteConfig(sitePath: string): Promise<SiteConfig> {\n let raw: string;\n try {\n raw = await readFile(join(sitePath, \"package.json\"), \"utf-8\");\n } catch {\n return {};\n }\n let pkg: unknown;\n try {\n pkg = JSON.parse(raw);\n } catch {\n return {};\n }\n if (!pkg || typeof pkg !== \"object\") return {};\n const cfg = (pkg as { reddoor?: unknown }).reddoor;\n if (!cfg || typeof cfg !== \"object\") return {};\n\n const out: SiteConfig = {};\n const url = (cfg as { lighthouseUrl?: unknown }).lighthouseUrl;\n if (typeof url === \"string\" && url.length > 0) {\n out.lighthouseUrl = url;\n }\n const routes = (cfg as { a11yRoutes?: unknown }).a11yRoutes;\n if (Array.isArray(routes)) {\n // Junk entries are dropped rather than passed through — a `null` in the list\n // would become `page.goto(null)` inside the generated spec and fail the whole\n // audit for a typo. An all-junk list leaves the key omitted, so the caller's\n // \"no routes configured\" branch is the same shape as \"key absent\".\n const clean = routes\n .filter((r): r is string => typeof r === \"string\")\n .map((r) => r.trim())\n .filter((r) => r.length > 0);\n if (clean.length > 0) out.a11yRoutes = clean;\n }\n // Anything but the two known values reads as absent. This string reaches a\n // shell as part of a `webServer.command`, so a typo must fall back to the\n // default rather than be forwarded — `\"prod\"` would become `npm run prod`.\n const gate = (cfg as { gateServer?: unknown }).gateServer;\n if (gate === \"dev\" || gate === \"preview\") out.gateServer = gate;\n\n return out;\n}\n\n/**\n * The starter's unreplaced Prismic repository name. A clone still carrying it\n * has no content model of its own: every Prismic-backed route resolves nothing\n * and `getByUID(\"page\",\"home\")` throws, so the site's own `/` answers **404 by\n * design** until `/new-site` step 6 swaps this for a real repository.\n *\n * Exactly the same string the site side already reads in four places —\n * `src/lib/prismicio.ts` (`isPlaceholderRepo`), `svelte.config.js`, the home\n * route's `entries()` prerender guard, and `tests/smoke/routes.ts`, whose\n * committed smoke manifest flips `/`'s expected status on it. This is that fact\n * arriving in the audit, not a new convention.\n */\nexport const PLACEHOLDER_PRISMIC_REPO = \"your-prismic-repo-name\";\n\n/** Slice Machine's filename, and the name the Prismic CLI's migration renames\n * it to, in the same preference order `src/prismic/models/config.ts` reads\n * them. Both are checked so a half-migrated repo is read correctly. */\nconst PRISMIC_CONFIG_FILES = [\"slicemachine.config.json\", \"prismic.config.json\"] as const;\n\n/**\n * True when this site is still pointed at the starter placeholder — i.e. when a\n * 404 on one of its own content routes is the designed answer rather than a\n * broken route.\n *\n * DELIBERATELY NARROWER than `PLACEHOLDER_REPOSITORY_NAMES` in\n * `src/prismic/models/config.ts`, and this is the trap worth naming. That list\n * also holds `reddoor-wireframer`, which is there precisely because IT\n * RESOLVES: data-dynamiq names it, the repository really exists with published\n * documents, and the site really renders from it. It earns a place on a list\n * that means \"mint no token for this\" and must never reach a list that means\n * \"a 404 here is expected\" — folding the two together would buy a LIVE\n * production site permanent, silent tolerance of a broken homepage. Only the\n * starter sentinel, which 404s at Prismic itself and so can never serve a page,\n * belongs here.\n *\n * Reads the COMMITTED config only, never `VITE_PRISMIC_ENVIRONMENT`. The site\n * side honours that override, but `tests/smoke/routes.ts` throws when it names\n * the sentinel under CI — \"it would make this smoke run expect no home page and\n * pass\" — and an audit that honoured it would hand that same false green to any\n * real site whose environment happened to carry it.\n *\n * Never throws, and every unclear answer is `false`: a missing, unreadable or\n * malformed Prismic config buys a site no 404 tolerance at all. The failure\n * this returns to is the one that reports too much, not the one that reports\n * nothing (#680).\n */\nexport async function readsPlaceholderPrismicRepo(sitePath: string): Promise<boolean> {\n let sawPlaceholder = false;\n for (const name of PRISMIC_CONFIG_FILES) {\n let raw: string;\n try {\n raw = await readFile(join(sitePath, name), \"utf-8\");\n } catch {\n continue;\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n continue;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) continue;\n const repo = (parsed as { repositoryName?: unknown }).repositoryName;\n if (typeof repo !== \"string\") continue;\n const trimmed = repo.trim();\n if (trimmed === PLACEHOLDER_PRISMIC_REPO) {\n // Not an early return: the CLI migration renames slicemachine.config.json\n // to prismic.config.json, so a half-migrated repo can hold a stale\n // sentinel in the first file and its real repository in the second. A\n // real name anywhere wins.\n sawPlaceholder = true;\n continue;\n }\n if (trimmed.length > 0) return false;\n }\n return sawPlaceholder;\n}\n"],"mappings":"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","names":["readFile","join","readFile","join"]}
|
|
@@ -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":[]}
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
51
|
+
//# sourceMappingURL=chunk-X56LAKPR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/audits/lighthouse-airtable.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { AuditResult } from \"../types.js\";\nimport type { LighthouseScoreWriteback } from \"../reports/types.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\n\nconst LIGHTHOUSE_CATEGORIES = [\"performance\", \"accessibility\", \"best-practices\", \"seo\"] as const;\n\n/**\n * True when the result carries real lighthouse scores worth persisting.\n *\n * The `audit --write-back` policy used to refuse on any `status: \"fail\"`,\n * but that conflates two very different failure modes:\n * 1. Infrastructure failure (no lhr-*.json written, spawn timeout, etc.)\n * → `details.summary` empty → all-zeros would corrupt the dashboard\n * 2. Assertion failure (scores below threshold, e.g. best-practices < 0.9)\n * → `details.summary` has real numbers → tracking these IS the point\n *\n * The dashboard exists to surface drift over time. Refusing to write the\n * very numbers the dashboard wants to plot — just because one assertion\n * tripped — defeats the purpose. Write whenever real scores exist.\n */\nexport function hasRealScores(result: AuditResult): boolean {\n if (result.audit !== \"lighthouse\") return false;\n const details = (result.details ?? {}) as { summary?: Record<string, number> };\n const summary = details.summary ?? {};\n return LIGHTHOUSE_CATEGORIES.some(\n (k) => typeof summary[k] === \"number\" && !Number.isNaN(summary[k]),\n );\n}\n\n/**\n * Extract the four Lighthouse scores (as integer percentages) from a\n * `lighthouse` AuditResult. LHCI manifest summaries are floats in [0,1];\n * multiply + round. A category absent from the summary — e.g. Lighthouse\n * errored its audit (NO_LCP nulls the whole performance category) — yields\n * `null`, NOT 0, so the write path can clear the Airtable cell (\"—\") instead\n * of persisting a misleading zero. (`hasRealScores` still gates the write on\n * at least one real number, so an all-null infra failure is refused upstream.)\n */\nexport function lighthouseScoresFromResult(result: AuditResult): LighthouseScoreWriteback {\n if (result.audit !== \"lighthouse\") {\n throw new Error(`Expected a 'lighthouse' AuditResult, got '${result.audit}'`);\n }\n const details = (result.details ?? {}) as { summary?: Record<string, number> };\n const summary = details.summary ?? {};\n const toPct = (n: number | undefined): number | null =>\n typeof n === \"number\" && !Number.isNaN(n) ? Math.round(n * 100) : null;\n return {\n performance: toPct(summary[\"performance\"]),\n accessibility: toPct(summary[\"accessibility\"]),\n bestPractices: toPct(summary[\"best-practices\"]),\n seo: toPct(summary[\"seo\"]),\n };\n}\n\n/**\n * Derive a site slug from the cwd's package.json#name. Used by\n * `audit lighthouse --write-back` when the operator doesn't pass an\n * explicit slug — the cwd is the site checkout, so package.json#name is\n * usually the canonical site name.\n */\nexport async function resolveSlugFromCwd(cwd: string): Promise<string> {\n try {\n const pkgPath = join(cwd, \"package.json\");\n const raw = await readFile(pkgPath, \"utf-8\");\n const pkg = JSON.parse(raw) as { name?: string };\n if (!pkg.name) throw new Error(\"package.json has no 'name' field\");\n return siteSlug(pkg.name);\n } catch (e) {\n throw new Error(\n `Could not derive site slug from ${cwd}/package.json: ${(e as Error).message}. ` +\n `Pass --write-back=<slug> explicitly.`,\n { cause: e },\n );\n }\n}\n"],"mappings":";;;;;AAAA,SAAS,gBAAgB;AACzB,SAAS,YAAY;AAKrB,IAAM,wBAAwB,CAAC,eAAe,iBAAiB,kBAAkB,KAAK;AAgB/E,SAAS,cAAc,QAA8B;AAC1D,MAAI,OAAO,UAAU,aAAc,QAAO;AAC1C,QAAM,UAAW,OAAO,WAAW,CAAC;AACpC,QAAM,UAAU,QAAQ,WAAW,CAAC;AACpC,SAAO,sBAAsB;AAAA,IAC3B,CAAC,MAAM,OAAO,QAAQ,CAAC,MAAM,YAAY,CAAC,OAAO,MAAM,QAAQ,CAAC,CAAC;AAAA,EACnE;AACF;AAWO,SAAS,2BAA2B,QAA+C;AACxF,MAAI,OAAO,UAAU,cAAc;AACjC,UAAM,IAAI,MAAM,6CAA6C,OAAO,KAAK,GAAG;AAAA,EAC9E;AACA,QAAM,UAAW,OAAO,WAAW,CAAC;AACpC,QAAM,UAAU,QAAQ,WAAW,CAAC;AACpC,QAAM,QAAQ,CAAC,MACb,OAAO,MAAM,YAAY,CAAC,OAAO,MAAM,CAAC,IAAI,KAAK,MAAM,IAAI,GAAG,IAAI;AACpE,SAAO;AAAA,IACL,aAAa,MAAM,QAAQ,aAAa,CAAC;AAAA,IACzC,eAAe,MAAM,QAAQ,eAAe,CAAC;AAAA,IAC7C,eAAe,MAAM,QAAQ,gBAAgB,CAAC;AAAA,IAC9C,KAAK,MAAM,QAAQ,KAAK,CAAC;AAAA,EAC3B;AACF;AAQA,eAAsB,mBAAmB,KAA8B;AACrE,MAAI;AACF,UAAM,UAAU,KAAK,KAAK,cAAc;AACxC,UAAM,MAAM,MAAM,SAAS,SAAS,OAAO;AAC3C,UAAM,MAAM,KAAK,MAAM,GAAG;AAC1B,QAAI,CAAC,IAAI,KAAM,OAAM,IAAI,MAAM,kCAAkC;AACjE,WAAO,SAAS,IAAI,IAAI;AAAA,EAC1B,SAAS,GAAG;AACV,UAAM,IAAI;AAAA,MACR,mCAAmC,GAAG,kBAAmB,EAAY,OAAO;AAAA,MAE5E,EAAE,OAAO,EAAE;AAAA,IACb;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
skipsAirtableShadow
|
|
3
|
+
} from "./chunk-RQJFGK3A.js";
|
|
4
|
+
|
|
1
5
|
// src/reports/airtable/attachments.ts
|
|
2
6
|
function looksLikeHtml(bytes) {
|
|
3
7
|
const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
|
|
@@ -23,6 +27,7 @@ async function fetchAttachmentBytes(url) {
|
|
|
23
27
|
return { bytes, contentType };
|
|
24
28
|
}
|
|
25
29
|
async function uploadAttachment(recordId, fieldName, body, filename, contentType, opts = {}) {
|
|
30
|
+
if (skipsAirtableShadow("uploadAttachment", recordId)) return;
|
|
26
31
|
const apiKey = process.env.AIRTABLE_PAT;
|
|
27
32
|
const baseId = process.env.AIRTABLE_BASE_ID;
|
|
28
33
|
if (!apiKey || !baseId) {
|
|
@@ -79,4 +84,4 @@ export {
|
|
|
79
84
|
fetchAttachmentBytes,
|
|
80
85
|
uploadAttachment
|
|
81
86
|
};
|
|
82
|
-
//# sourceMappingURL=chunk-
|
|
87
|
+
//# sourceMappingURL=chunk-YJBSB4LP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/airtable/attachments.ts"],"sourcesContent":["import { skipsAirtableShadow } from \"../../fleet/site-id.js\";\n\n/** Cheap HTML sniff: an Airtable signed-URL \"200\" that is really a login/error page\n * starts with `<!doctype html`, `<html`, or `<head` after an optional UTF-8 BOM /\n * leading whitespace. We only need to catch the common error-page case, not parse\n * HTML. */\nfunction looksLikeHtml(bytes: Uint8Array): boolean {\n // Inspect the first ~64 bytes as ASCII (1 byte → 1 char; enough for a doctype /\n // opening tag). Skip a leading UTF-8 BOM (bytes EF BB BF) by index, then strip any\n // leading ASCII whitespace, and match the common HTML openers case-insensitively.\n const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;\n const head = Buffer.from(bytes.slice(start, start + 64))\n .toString(\"ascii\")\n .replace(/^[\\s]+/, \"\")\n .toLowerCase();\n return head.startsWith(\"<!doctype html\") || head.startsWith(\"<html\") || head.startsWith(\"<head\");\n}\n\nexport async function fetchAttachmentBytes(\n url: string,\n): Promise<{ bytes: Uint8Array; contentType: string }> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(\n `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`,\n );\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n const ab = await res.arrayBuffer();\n const bytes = new Uint8Array(ab);\n // Sanity-gate the body: a 200 that is actually an HTML error/login page (expired\n // signed URL, auth wall) would otherwise be attached as the \"image\" and ship a\n // broken header. Accept an explicit image/* content-type; otherwise reject anything\n // that sniffs as HTML — so the send fails loudly rather than emailing a broken image.\n const isImageType = contentType.toLowerCase().startsWith(\"image/\");\n if (!isImageType && looksLikeHtml(bytes)) {\n throw new Error(\n `Airtable attachment did not return image data (content-type=\"${contentType}\", ` +\n `body looks like an HTML page — the signed URL may have expired) (url=${url})`,\n );\n }\n return { bytes, contentType };\n}\n\n/**\n * Upload bytes (or a string) as an attachment to a specific record + field.\n * Uses Airtable's content.airtable.com upload endpoint (base64 body) because\n * the standard SDK only accepts public URLs for attachments, and we don't\n * host the generated content anywhere public.\n *\n * Docs: https://airtable.com/developers/web/api/upload-attachment\n *\n * Requires AIRTABLE_PAT + AIRTABLE_BASE_ID in env (same as the rest of the\n * reports module). The fieldName is URL-encoded for the request path.\n */\n/**\n * Upload one file into an Airtable attachment field.\n *\n * ⚠️ Airtable's uploadAttachment endpoint APPENDS — it never replaces. Every\n * re-upload therefore stacks another file in the field while readers\n * (`websites.ts`, `db/header-images.ts`) take attachment [0], the OLDEST. Left\n * alone that means a field silently serves a stale image forever: beachfront\n * accumulated four headers and kept sending the first, which is how a\n * pre-clean-plate header reached a 2026-08-24 announcement and got a second\n * headline printed over its baked one.\n *\n * Pass `{ replaceIn: \"<table>\" }` for a field that should hold exactly one\n * current file (the site header). Omit it where history is wanted (per-period\n * report previews, which are one row per period anyway).\n *\n * The table is part of the option rather than a separate flag because the prune\n * PATCHes `/v0/{baseId}/{table}/{recordId}` — an earlier version omitted the\n * table segment entirely and 403'd on every call, so `replace` cannot be asked\n * for without supplying it.\n */\nexport async function uploadAttachment(\n recordId: string,\n fieldName: string,\n body: Uint8Array | string,\n filename: string,\n contentType: string,\n opts: { replaceIn?: string } = {},\n): Promise<void> {\n // #646 step 3: a `site_<ULID>` site was created in Turso and has no Airtable\n // record to attach to. Checked BEFORE the credential gate — skipping needs no\n // creds, and a run without them must not fail on a write it was never going to make.\n if (skipsAirtableShadow(\"uploadAttachment\", recordId)) return;\n const apiKey = process.env.AIRTABLE_PAT;\n const baseId = process.env.AIRTABLE_BASE_ID;\n if (!apiKey || !baseId) {\n throw new Error(\"AIRTABLE_PAT and AIRTABLE_BASE_ID must be set\");\n }\n const base64 =\n typeof body === \"string\"\n ? Buffer.from(body, \"utf-8\").toString(\"base64\")\n : Buffer.from(body).toString(\"base64\");\n const payload = { contentType, file: base64, filename };\n const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok) {\n throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);\n }\n if (!opts.replaceIn) return;\n\n // The upload response carries the record with the field's full, post-append\n // attachment list. Keep only the last entry (the one just uploaded) by\n // PATCHing the field to that id — Airtable preserves an attachment referenced\n // by id alone and drops any omitted. Best-effort: the file IS uploaded by this\n // point, so a prune failure must not fail the caller; it only leaves the\n // field over-full, which the next successful run tidies.\n try {\n const body = (await res.json()) as {\n fields?: Record<string, Array<{ id?: string }> | undefined>;\n };\n // Airtable keys this response's `fields` by FIELD ID, not field name — verified\n // live 2026-08-24: {\"id\":\"rec…\",\"createdTime\":…,\"fields\":{\"fldBUAW180p8MIpvh\":[…]}}.\n // Looking it up by NAME therefore yielded undefined, `list` was [], and the\n // `length <= 1` guard returned silently — so `replace` never pruned anything on a\n // real call, while a name-keyed test stub kept it green. Eleven site header fields\n // stacked before the discrepancy surfaced. The endpoint writes exactly ONE field,\n // so its response carries exactly one entry: take it whatever it is keyed by, and\n // fall back to the name in case Airtable ever switches.\n const lists = Object.values(body.fields ?? {}).filter((v) => Array.isArray(v));\n const list = body.fields?.[fieldName] ?? (lists.length === 1 ? lists[0] : undefined);\n if (!list) {\n // Never return quietly here: silence is what let the no-op ship.\n console.warn(\n `⚠ attachment prune skipped for \"${fieldName}\": upload response carried no ` +\n `attachment list (fields keys: ${Object.keys(body.fields ?? {}).join(\", \") || \"none\"})`,\n );\n return;\n }\n if (list.length <= 1) return;\n const newest = list[list.length - 1]?.id;\n if (!newest) {\n console.warn(`⚠ kept ${list.length} attachments in \"${fieldName}\": newest entry has no id`);\n return;\n }\n // /v0/{baseId}/{table}/{recordId} — the table segment is REQUIRED. Without it\n // Airtable answers 403 Forbidden (not 404), which reads like a token-scope\n // problem and sent this in the wrong direction once already.\n const patchUrl = `https://api.airtable.com/v0/${baseId}/${encodeURIComponent(opts.replaceIn)}/${recordId}`;\n const patch = await fetch(patchUrl, {\n method: \"PATCH\",\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ fields: { [fieldName]: [{ id: newest }] } }),\n });\n if (!patch.ok) {\n console.warn(\n `⚠ kept ${list.length} attachments in \"${fieldName}\" — prune failed: ${patch.status} ${patch.statusText}`,\n );\n }\n } catch (e) {\n console.warn(`⚠ attachment prune skipped for \"${fieldName}\": ${(e as Error).message}`);\n }\n}\n"],"mappings":"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","names":["body"]}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
listSendableReports,
|
|
3
2
|
stampSent
|
|
4
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-TEMQPSCM.js";
|
|
5
4
|
import {
|
|
6
5
|
gatingHealth,
|
|
7
6
|
isHealthGateClear,
|
|
@@ -9,7 +8,7 @@ import {
|
|
|
9
8
|
} from "./chunk-VAD43MOR.js";
|
|
10
9
|
import {
|
|
11
10
|
fetchAttachmentBytes
|
|
12
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-YJBSB4LP.js";
|
|
13
12
|
import {
|
|
14
13
|
openBase,
|
|
15
14
|
readAirtableConfig
|
|
@@ -17,17 +16,19 @@ import {
|
|
|
17
16
|
import {
|
|
18
17
|
renderReportFromRow,
|
|
19
18
|
requireLighthouse
|
|
20
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-6THEZQLA.js";
|
|
21
20
|
import {
|
|
22
|
-
listWebsites,
|
|
23
21
|
updateLaunched
|
|
24
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-TC4GANMX.js";
|
|
23
|
+
import {
|
|
24
|
+
siteSlug
|
|
25
|
+
} from "./chunk-ICSRWW5Q.js";
|
|
25
26
|
import {
|
|
26
27
|
defaultResendClient
|
|
27
28
|
} from "./chunk-WEKL2HYJ.js";
|
|
28
29
|
import {
|
|
29
30
|
recordFleetEventsBestEffort
|
|
30
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-3YALLSBF.js";
|
|
31
32
|
|
|
32
33
|
// src/reports/send/idempotency.ts
|
|
33
34
|
function isIdempotencyConflict(err) {
|
|
@@ -49,12 +50,12 @@ function withGlobalCc(perSiteCc, to) {
|
|
|
49
50
|
if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);
|
|
50
51
|
return cc;
|
|
51
52
|
}
|
|
52
|
-
async function sendApprovedReports(options
|
|
53
|
+
async function sendApprovedReports(options) {
|
|
53
54
|
const base = openBase(readAirtableConfig());
|
|
54
55
|
const client = options.resend ?? defaultResendClient();
|
|
55
|
-
const sendable = await
|
|
56
|
+
const sendable = await options.sendable();
|
|
56
57
|
if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
|
|
57
|
-
const websites = await
|
|
58
|
+
const websites = await options.roster();
|
|
58
59
|
const sites = new Map(websites.map((w) => [w.id, w]));
|
|
59
60
|
const lines = [];
|
|
60
61
|
let anyFailed = false;
|
|
@@ -66,7 +67,7 @@ async function sendApprovedReports(options = {}) {
|
|
|
66
67
|
continue;
|
|
67
68
|
}
|
|
68
69
|
try {
|
|
69
|
-
const sent = await sendOne(client, base, site, report);
|
|
70
|
+
const sent = await sendOne(client, base, site, report, options.loadHeaderPlate);
|
|
70
71
|
lines.push(`\u2713 sent: ${report.reportId} (${sent.display})`);
|
|
71
72
|
try {
|
|
72
73
|
await options.reportSentMirror?.(report.id, sent.sentAt, sent.messageId);
|
|
@@ -125,7 +126,22 @@ async function sendApprovedReports(options = {}) {
|
|
|
125
126
|
}
|
|
126
127
|
return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
|
|
127
128
|
}
|
|
128
|
-
async function
|
|
129
|
+
async function headerPlateFor(base, site, loadHeaderPlate) {
|
|
130
|
+
const stored = await loadHeaderPlate(site.id);
|
|
131
|
+
if (stored) return { bytes: stored, source: "turso" };
|
|
132
|
+
const slug = siteSlug(site.name);
|
|
133
|
+
const { findWebsiteRecordBySlug } = await import("./ensure-site-OO4UPWNQ.js");
|
|
134
|
+
const { mapRow } = await import("./websites-Y6FN2XFD.js");
|
|
135
|
+
const rec = slug ? await findWebsiteRecordBySlug(base, slug) : null;
|
|
136
|
+
const url = rec ? mapRow(rec).headerImage?.url : void 0;
|
|
137
|
+
if (!url) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Site '${site.name}' has no Header image: no header plate in Turso and no Airtable attachment to fall back to \u2014 run \`reddoor-maint header-image ${slug || site.name} --write-back\``
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { bytes: (await fetchAttachmentBytes(url)).bytes, source: "airtable" };
|
|
143
|
+
}
|
|
144
|
+
async function sendOne(client, base, site, report, loadHeaderPlate) {
|
|
129
145
|
const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };
|
|
130
146
|
if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {
|
|
131
147
|
const failing = gatingHealth(gateReport).filter((h) => h.status !== "pass" && h.status !== "n/a").map((h) => {
|
|
@@ -134,9 +150,6 @@ async function sendOne(client, base, site, report) {
|
|
|
134
150
|
}).join("; ");
|
|
135
151
|
throw new Error(`Report ${report.reportId} health gate not clear \u2014 ${failing}`);
|
|
136
152
|
}
|
|
137
|
-
if (!site.headerImage) {
|
|
138
|
-
throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
|
|
139
|
-
}
|
|
140
153
|
requireLighthouse(report);
|
|
141
154
|
const explicitTo = parseAddresses(site.reportRecipientsTo);
|
|
142
155
|
const fallbackTo = parseAddresses(site.pointOfContact);
|
|
@@ -163,8 +176,9 @@ async function sendOne(client, base, site, report) {
|
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
179
|
+
const header = await headerPlateFor(base, site, loadHeaderPlate);
|
|
180
|
+
console.log(`REPORT_SEND report=${report.reportId} site=${site.name} header=${header.source}`);
|
|
181
|
+
const { html, attachments, subject } = await renderReportFromRow(site, report, header.bytes);
|
|
168
182
|
const payload = {
|
|
169
183
|
from: FROM_ADDRESS,
|
|
170
184
|
to,
|
|
@@ -227,4 +241,4 @@ export {
|
|
|
227
241
|
parseAddresses,
|
|
228
242
|
isProbablyEmail
|
|
229
243
|
};
|
|
230
|
-
//# sourceMappingURL=chunk-
|
|
244
|
+
//# sourceMappingURL=chunk-ZEE5WQYU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/send/idempotency.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["/**\n * True when a thrown send error is Resend's same-key + DIFFERENT-body 409\n * (`invalid_idempotent_request`). The ResendClient (send/resend.ts) discards the\n * status/name and only surfaces the message string, so we match defensively on the\n * stable message substring \"idempotency key has been used\" (case-insensitive); a\n * `name`/`statusCode` of 409/`invalid_idempotent_request` is also accepted if a\n * future client happens to preserve it. A same-key + SAME-body re-send is deduped\n * by Resend (returns the original id) and never reaches here.\n *\n * Shared by both send surfaces that key into Resend's idempotency window:\n * `runDigest` (digest.ts, `digest-<date>` key) and `sendOne` (orchestrate.ts,\n * `report:<id>` key). Both treat a 409 as \"the email already went out under this\n * key on a prior run\" — a no-op for the digest, an already-done success for sendOne.\n */\nexport function isIdempotencyConflict(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n if (/idempotency key has been used/i.test(message)) return true;\n const e = err as { name?: unknown; statusCode?: unknown };\n if (e.name === \"invalid_idempotent_request\") return true;\n if (e.statusCode === 409) return true;\n return false;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../airtable/client.js\";\nimport { stampSent } from \"../airtable/reports.js\";\nimport { updateLaunched } from \"../airtable/websites.js\";\nimport { siteSlug, type WebsiteRow } from \"../../fleet/site-row.js\";\nimport type { ReportRow } from \"../report-row.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { renderReportFromRow, requireLighthouse } from \"./render-from-row.js\";\nimport { defaultResendClient, type ResendClient } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { gatingHealth, isHealthGateClear, isSendOverridden } from \"../checklist.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\n/** Operations inbox CC'd on every outgoing report so there's always an internal\n * copy on file alongside the client recipients. */\nexport const GLOBAL_REPORT_CC = \"info@reddoorla.com\";\n\n/**\n * Append {@link GLOBAL_REPORT_CC} to a site's per-site CC list. The per-site CC is\n * passed through unchanged (preserving prior behavior); the global address is added\n * only when it isn't already present in the CC or To lists (case-insensitive), so a\n * report is never double-addressed to the ops inbox. Returns the final CC list (may\n * be empty if the global address is already the sole To recipient — the caller omits\n * an empty CC).\n */\nexport function withGlobalCc(perSiteCc: string[] | null, to: string[]): string[] {\n const cc = [...(perSiteCc ?? [])];\n const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));\n if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);\n return cc;\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n /**\n * #646 step 4: the send queue and the fleet roster, read from TURSO.\n *\n * Required rather than defaulted, for the reason the mirrors below are: this\n * function is called directly by tests, and a default would let one reach a\n * live store. The CLI wires `listSendableReports`/`listSites` over one\n * connection.\n */\n sendable: () => Promise<ReportRow[]>;\n roster: () => Promise<WebsiteRow[]>;\n /**\n * The site's header plate, from `sites.header_image*` (design D5 made Turso its\n * source; the re-render path has read it from there since #643). `null` means\n * Turso holds no bytes for that site, and the send falls back to the site's\n * Airtable attachment — which is the ONLY reason this path still reads\n * Airtable, and why the fallback fetches ONE site's record rather than the\n * roster it used to get the signed url from.\n */\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>;\n /** #539 Phase 5: Turso write-through for the Websites row a Launch send\n * updates. Injected rather than defaulted — this function is called directly\n * by tests, and a default would open a real libSQL handle inside the suite. */\n siteMirror?: SiteMirror;\n /** #643 (the freeze): Turso write-through for the `Sent at` / `Resend message\n * ID` stamp. stampSent is what removes a row from listSendableReports, so\n * once it lands there is no replay left to converge a lost mirror — and the\n * console's already-sent guards (approve, the commentary lock, re-render)\n * read `sent_at` from Turso. Injected like siteMirror; the CLI wires it\n * through `mirrorWrite` so the freeze switch owns the error semantics. */\n reportSentMirror?: (reportId: string, sentAt: Date, messageId: string | null) => Promise<void>;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions,\n): Promise<{ output: string; code: number }> {\n // Airtable is still opened on this path: `stampSent`, `updateLaunched` and the\n // header fallback below are all Airtable calls. What moved in #646 step 4 are\n // the two READS that decided what the run does at all.\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await options.sendable();\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await options.roster();\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const sent = await sendOne(client, base, site, report, options.loadHeaderPlate);\n lines.push(`✓ sent: ${report.reportId} (${sent.display})`);\n // Mirror the stamp into Turso. Caught here rather than thrown so one\n // report's lost mirror still lets the batch continue AND still runs this\n // report's Launch flip below — but it reds the run (`anyFailed`), because\n // post-freeze nothing converges the miss; `db sync --force` is the manual\n // converge during the rollback window.\n try {\n await options.reportSentMirror?.(report.id, sent.sentAt, sent.messageId);\n } catch (e) {\n lines.push(` ✗ sent-stamp mirror failed for ${report.reportId}: ${(e as Error).message}`);\n anyFailed = true;\n }\n if (report.sendOverride) {\n const failing = gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => h.field);\n await recordFleetEventsBestEffort(\n [\n {\n id: `report_sent_with_override:${report.id}`,\n ts: new Date().toISOString(),\n type: \"report_sent_with_override\",\n siteId: site.id,\n siteName: site.name,\n summary: `sent with override — ${report.overrideReason ?? \"\"}`,\n data: { reportId: report.reportId, reason: report.overrideReason, failing },\n },\n ],\n new Date(),\n );\n }\n if (report.reportType === \"Launch\") {\n try {\n const fields = await updateLaunched(base, site.id, new Date().toISOString());\n // Status and `Launched at` travel together — mirroring them as two\n // updates would open a window where Turso says a site is maintained\n // but never launched.\n await options.siteMirror?.site(site.id, fields);\n lines.push(` ↳ launched: ${site.name} flipped to maintained`);\n await recordFleetEventsBestEffort(\n [\n {\n id: `site_launched:${site.id}`,\n ts: new Date().toISOString(),\n type: \"site_launched\",\n siteId: site.id,\n siteName: site.name,\n summary: \"launched — now in maintenance\",\n data: null,\n },\n ],\n new Date(),\n );\n } catch (e) {\n // Post-freeze a swallowed flip failure is permanent divergence: Turso\n // — the store lead routing reads — would keep the site in\n // launch-period forever (its leads go operator-only). Red the run\n // instead of shrugging; the email itself already went out.\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\n/**\n * The header plate for one send: Turso first, the site's Airtable attachment as\n * the fallback (#646 step 4).\n *\n * Turso first for the reason the re-render path already had: the bytes are local,\n * and an Airtable attachment url is SIGNED and expiring, so fetching one when the\n * same image is already in hand is latency plus a dependency on a url that may be\n * dead. The fallback exists because the plate columns were backfilled, not\n * enforced — a site whose image never made it across must still be able to send,\n * exactly as it did before this change.\n *\n * The fallback is a ONE-SITE Airtable lookup, not the whole Websites table: the\n * roster no longer comes from Airtable, so the signed url is not already in hand.\n * A site with no record there (every `site_<ULID>` site) and no plate gets a named\n * error with the command that fixes it — not a silent unillustrated report.\n */\nasync function headerPlateFor(\n base: AirtableBase,\n site: WebsiteRow,\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>,\n): Promise<{ bytes: Uint8Array; source: \"turso\" | \"airtable\" }> {\n const stored = await loadHeaderPlate(site.id);\n if (stored) return { bytes: stored, source: \"turso\" };\n const slug = siteSlug(site.name);\n const { findWebsiteRecordBySlug } = await import(\"../airtable/ensure-site.js\");\n const { mapRow } = await import(\"../airtable/websites.js\");\n const rec = slug ? await findWebsiteRecordBySlug(base, slug) : null;\n const url = rec ? mapRow(rec).headerImage?.url : undefined;\n if (!url) {\n throw new Error(\n `Site '${site.name}' has no Header image: no header plate in Turso and no Airtable ` +\n `attachment to fall back to — run \\`reddoor-maint header-image ${slug || site.name} --write-back\\``,\n );\n }\n return { bytes: (await fetchAttachmentBytes(url)).bytes, source: \"airtable\" };\n}\n\n/** What the caller needs to mirror the stamp: the exact values stampSent wrote\n * (`messageId` null on the 409 path, where Airtable's field is left untouched\n * too), plus the display string for the ✓ line. */\ntype SentStamp = { display: string; sentAt: Date; messageId: string | null };\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>,\n): Promise<SentStamp> {\n // Hard health gate: a Maintenance/Testing report whose gating evidence isn't all pass/n/a must\n // never go out — even if \"Approved to send\" was set directly in Airtable. Throw so the row is\n // skipped and `Sent at` stays null (at-least-once retry preserved). Launch/Announcement have no\n // gating fields → vacuously clear. A logged send-anyway override (Phase 10) bypasses the gate.\n const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };\n if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {\n const failing = gatingHealth(gateReport)\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => {\n const note = report.autoEvidence?.[h.field]?.note;\n return `${h.field} (${h.status}${note ? `: ${note}` : \"\"})`;\n })\n .join(\"; \");\n throw new Error(`Report ${report.reportId} health gate not clear — ${failing}`);\n }\n // Fail fast, before the header fetch and the sharp downscale. Same rule and\n // same message the renderer enforces — shared, not restated.\n requireLighthouse(report);\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const header = await headerPlateFor(base, site, loadHeaderPlate);\n console.log(`REPORT_SEND report=${report.reportId} site=${site.name} header=${header.source}`);\n // ONE render path, shared with the console's on-demand re-render — so a\n // preview cannot drift from what the client actually receives. The assembly\n // that used to live here moved into renderReportFromRow unchanged.\n const { html, attachments, subject } = await renderReportFromRow(site, report, header.bytes);\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n // Always CC the ops inbox (info@reddoorla.com), in addition to any per-site CC.\n const finalCc = withGlobalCc(cc, to);\n if (finalCc.length > 0) payload.cc = finalCc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n const when = new Date();\n await stampSent(base, report.id, when, null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return { display: \"idempotent-conflict\", sentAt: when, messageId: null };\n }\n throw err;\n }\n const when = new Date();\n await stampSent(base, report.id, when, result.messageId);\n return { display: result.messageId, sentAt: when, messageId: result.messageId };\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":["when"]}
|