@krelborn/markdown-review 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +134 -2
- package/dist/cli.js +3322 -0
- package/dist/web/assets/abap-CLvhMVsD.js +1 -0
- package/dist/web/assets/abnfDiagram-YUKMZFIV-CK8RfoBl.js +1 -0
- package/dist/web/assets/actionscript-3-Cs6JJXT5.js +1 -0
- package/dist/web/assets/ada-X3Y2mlq2.js +1 -0
- package/dist/web/assets/ahk-DwADkxyd.js +1 -0
- package/dist/web/assets/ahk2-_6rzW9BS.js +1 -0
- package/dist/web/assets/andromeeda-vGVdxbeo.js +1 -0
- package/dist/web/assets/angular-html-DeswjFqG.js +1 -0
- package/dist/web/assets/angular-ts-0_RRcACO.js +1 -0
- package/dist/web/assets/apache-U0d_L8uA.js +1 -0
- package/dist/web/assets/apex-KFGFyKwG.js +1 -0
- package/dist/web/assets/apl-CCY4uyn_.js +1 -0
- package/dist/web/assets/applescript-CCn79oCD.js +1 -0
- package/dist/web/assets/ara-4CJ0cIlV.js +1 -0
- package/dist/web/assets/arc-BG4zBWWH.js +1 -0
- package/dist/web/assets/architecture-WOLXFQ4H-Dd-YnaSX.js +1 -0
- package/dist/web/assets/architectureDiagram-47P4ROYG-DiUIBppB.js +36 -0
- package/dist/web/assets/array-BifhSqXX.js +1 -0
- package/dist/web/assets/asciidoc-SCjQUq34.js +1 -0
- package/dist/web/assets/asm-Cmm7eHzH.js +1 -0
- package/dist/web/assets/astro-Cwc-oo5s.js +1 -0
- package/dist/web/assets/aurora-x-CDeNXAV0.js +1 -0
- package/dist/web/assets/awk-BWXHIvNe.js +1 -0
- package/dist/web/assets/ayu-dark-DluEY0Gj.js +1 -0
- package/dist/web/assets/ayu-light-C3h-C4tm.js +1 -0
- package/dist/web/assets/ayu-mirage-Bqwy1Gya.js +1 -0
- package/dist/web/assets/ballerina-B7ZEbQpA.js +1 -0
- package/dist/web/assets/bat-qdxAdBWQ.js +1 -0
- package/dist/web/assets/beancount-C2IN4Vk8.js +1 -0
- package/dist/web/assets/berry-DKpUyyne.js +1 -0
- package/dist/web/assets/bibtex-Ci_nEsc7.js +1 -0
- package/dist/web/assets/bicep-CUHmPFLl.js +1 -0
- package/dist/web/assets/bird2-CYIUgYqB.js +1 -0
- package/dist/web/assets/blade-84wcXlDd.js +1 -0
- package/dist/web/assets/blockDiagram-E7TT5TSB-D97HQ8Sw.js +140 -0
- package/dist/web/assets/bsl-_VjlB9mv.js +1 -0
- package/dist/web/assets/c-DTQBoa6Q.js +1 -0
- package/dist/web/assets/c3-D0apG41V.js +1 -0
- package/dist/web/assets/c4Diagram-CRA5TL53-ClvVKHsZ.js +38 -0
- package/dist/web/assets/cadence-CQ2zXKGN.js +1 -0
- package/dist/web/assets/cairo-DLTphjLi.js +1 -0
- package/dist/web/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
- package/dist/web/assets/catppuccin-latte-CtsyrtPq.js +1 -0
- package/dist/web/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
- package/dist/web/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
- package/dist/web/assets/channel-CPGqLD1P.js +1 -0
- package/dist/web/assets/chapel-CRMNarUY.js +1 -0
- package/dist/web/assets/chunk-2BW5OAIV-DSadrz5B.js +10 -0
- package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/web/assets/chunk-3YJQHVM4-D6ohwMAh.js +2 -0
- package/dist/web/assets/chunk-4EA7E6EY-BjuRiZLM.js +1 -0
- package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/web/assets/chunk-7TKQ45FW-CdSL2hss.js +1 -0
- package/dist/web/assets/chunk-AW2ZBBNX-BUkHB8w8.js +88 -0
- package/dist/web/assets/chunk-DBDB3WZW-BmDVHoAq.js +1 -0
- package/dist/web/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/web/assets/chunk-E2ZNV5FY-CHWL0yef.js +72 -0
- package/dist/web/assets/chunk-EU5HNXII-TzfEw3rt.js +213 -0
- package/dist/web/assets/chunk-FVRAUYC3-Dea7fNxt.js +2 -0
- package/dist/web/assets/chunk-HJ2JQQFS-CsqlPveV.js +1 -0
- package/dist/web/assets/chunk-HTAEGDNF-uOHLFUZ2.js +1 -0
- package/dist/web/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/web/assets/chunk-KQW6MTUR-DiKsme5W.js +1 -0
- package/dist/web/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/web/assets/chunk-NTY3LDVX-CmKaDw95.js +1 -0
- package/dist/web/assets/chunk-VPRB5NB3-B00Cf0rw.js +127 -0
- package/dist/web/assets/chunk-XC4XBNZT-BDz8GHcg.js +62 -0
- package/dist/web/assets/chunk-XXDRQBXY-VdpKW56f.js +1 -0
- package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/web/assets/clarity-CtfBBspg.js +1 -0
- package/dist/web/assets/classDiagram-v2-K4WV4PDN-gEKpA9iT.js +217 -0
- package/dist/web/assets/clojure-DqKBuwfJ.js +1 -0
- package/dist/web/assets/cmake-Bj61d0ZC.js +1 -0
- package/dist/web/assets/cobol-DzWF6gu0.js +1 -0
- package/dist/web/assets/codeowners-C8r90Shi.js +1 -0
- package/dist/web/assets/codeql-oeQT6MSM.js +1 -0
- package/dist/web/assets/coffee-YFNN4OQT.js +1 -0
- package/dist/web/assets/common-lisp-Cv5bFMCO.js +1 -0
- package/dist/web/assets/coq-CGaSfhwR.js +1 -0
- package/dist/web/assets/cose-bilkent-JH36ORCC-Bfuj49HT.js +1 -0
- package/dist/web/assets/cpp-B_VbYR-1.js +1 -0
- package/dist/web/assets/crystal-29rZkanC.js +1 -0
- package/dist/web/assets/csharp-dmMxLVLW.js +1 -0
- package/dist/web/assets/css-Cs9-eEZt.js +1 -0
- package/dist/web/assets/csv-Dx-8-gkx.js +1 -0
- package/dist/web/assets/cue-CE9AQfxI.js +1 -0
- package/dist/web/assets/cynefin-EF2NZ3EQ-BdejMMtt.js +1 -0
- package/dist/web/assets/cynefinDiagram-3GCD6N5R-B0Cf1Lu4.js +62 -0
- package/dist/web/assets/cypher-ClKdZ_lG.js +1 -0
- package/dist/web/assets/cytoscape.esm-CHBj4VkZ.js +321 -0
- package/dist/web/assets/d-qD-0Kul2.js +1 -0
- package/dist/web/assets/dagre-W4DXFKR2-CbG5K5pN.js +4 -0
- package/dist/web/assets/dark-plus-Cs2F2srj.js +1 -0
- package/dist/web/assets/dart-CHZY9Xhz.js +1 -0
- package/dist/web/assets/dax-BkyTk9wS.js +1 -0
- package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/web/assets/desktop-Dlh5hvp9.js +1 -0
- package/dist/web/assets/diagram-2UJZ2QOL-DnUMvh6f.js +200 -0
- package/dist/web/assets/diagram-OVF4WLC6-DWaGIEvp.js +24 -0
- package/dist/web/assets/diagram-PFPMY2P6-D5-q1mxk.js +30 -0
- package/dist/web/assets/diagram-UMYRVEAY-iaUg3gAk.js +24 -0
- package/dist/web/assets/diagram-YEKJPTXX-DJmReb1h.js +41 -0
- package/dist/web/assets/diagram-ZIFT7M5P-Sh7BjF9g.js +3 -0
- package/dist/web/assets/diff-woXpYk--.js +1 -0
- package/dist/web/assets/dist-B8fEp-qP.js +1 -0
- package/dist/web/assets/docker-IyjqRm3v.js +1 -0
- package/dist/web/assets/dotenv-CuH5my32.js +1 -0
- package/dist/web/assets/dracula-BHWKrbxM.js +1 -0
- package/dist/web/assets/dracula-soft-5eyTD99u.js +1 -0
- package/dist/web/assets/dream-maker-DW3nJb8Q.js +1 -0
- package/dist/web/assets/ebnfDiagram-VR2GEFS7-DI4sWEQu.js +1 -0
- package/dist/web/assets/edge-B_ygG8c6.js +1 -0
- package/dist/web/assets/elixir-CxJKtXuu.js +1 -0
- package/dist/web/assets/elk-IJKZMXRS-DCy8oS2y.js +27 -0
- package/dist/web/assets/elm-04O0r0pA.js +1 -0
- package/dist/web/assets/emacs-lisp-DR2vtWaj.js +1 -0
- package/dist/web/assets/erDiagram-O2IAPWRE-BEt6M0BG.js +99 -0
- package/dist/web/assets/erb-9rS1Brsj.js +1 -0
- package/dist/web/assets/erlang-Cphh6RMH.js +1 -0
- package/dist/web/assets/eventmodeling-K75KTNOO-CuoZYl3d.js +1 -0
- package/dist/web/assets/everforest-dark-sB-x3p7T.js +1 -0
- package/dist/web/assets/everforest-light-Df2xbC6M.js +1 -0
- package/dist/web/assets/fennel-DQxkIbk2.js +1 -0
- package/dist/web/assets/fish-BJitypiv.js +1 -0
- package/dist/web/assets/flowDiagram-OXPTDLAJ-LJSf5aAK.js +1 -0
- package/dist/web/assets/fluent-C03EYrpw.js +1 -0
- package/dist/web/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
- package/dist/web/assets/fortran-free-form-CYNrtFtB.js +1 -0
- package/dist/web/assets/fsharp-D13ZGOAj.js +1 -0
- package/dist/web/assets/ganttDiagram-R7TSEDQI-BGP21iH1.js +292 -0
- package/dist/web/assets/gdresource-xMC7jr7x.js +1 -0
- package/dist/web/assets/gdscript-DYvRD3hg.js +1 -0
- package/dist/web/assets/gdshader-CBce3t8t.js +1 -0
- package/dist/web/assets/genie-CV2tkWYe.js +1 -0
- package/dist/web/assets/gherkin-DExj1W_8.js +1 -0
- package/dist/web/assets/git-commit-BSykSTBG.js +1 -0
- package/dist/web/assets/git-rebase-uf5u70dP.js +1 -0
- package/dist/web/assets/gitGraph-VSP46ZUC-DLEE7ZA1.js +1 -0
- package/dist/web/assets/gitGraphDiagram-XJZIOB7I-B364Tsss.js +106 -0
- package/dist/web/assets/github-dark-C-LZuMrd.js +1 -0
- package/dist/web/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/dist/web/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
- package/dist/web/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
- package/dist/web/assets/github-light-EUqPIrTm.js +1 -0
- package/dist/web/assets/github-light-default-BXViO-2h.js +1 -0
- package/dist/web/assets/github-light-high-contrast-B68TUdTA.js +1 -0
- package/dist/web/assets/gleam-CSRkHgEL.js +1 -0
- package/dist/web/assets/glimmer-js-CiBb6A2x.js +1 -0
- package/dist/web/assets/glimmer-ts-D2lfhEnU.js +1 -0
- package/dist/web/assets/glsl-B0_NrykT.js +1 -0
- package/dist/web/assets/gn-CAXYAgV-.js +1 -0
- package/dist/web/assets/gnuplot-7GGW24-e.js +1 -0
- package/dist/web/assets/go-rLFTqkRN.js +1 -0
- package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/web/assets/graphql-CagH_fy0.js +1 -0
- package/dist/web/assets/groovy-CacY0gHj.js +1 -0
- package/dist/web/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
- package/dist/web/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
- package/dist/web/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
- package/dist/web/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
- package/dist/web/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
- package/dist/web/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
- package/dist/web/assets/hack-DZpi5MLs.js +1 -0
- package/dist/web/assets/haml-BXC9NiVp.js +1 -0
- package/dist/web/assets/handlebars-BKcHNDUs.js +1 -0
- package/dist/web/assets/haskell-D8IpX4py.js +1 -0
- package/dist/web/assets/haxe-C8oCnwJJ.js +1 -0
- package/dist/web/assets/hcl-Dh228itO.js +1 -0
- package/dist/web/assets/hjson-CxZEssPk.js +1 -0
- package/dist/web/assets/hlsl-Cvrh5tZx.js +1 -0
- package/dist/web/assets/horizon-CE9ld1lL.js +1 -0
- package/dist/web/assets/horizon-bright-DSNQnXHK.js +1 -0
- package/dist/web/assets/houston-CsvMBhTu.js +1 -0
- package/dist/web/assets/html-DZ111F_a.js +1 -0
- package/dist/web/assets/html-derivative-DwoYvsLD.js +1 -0
- package/dist/web/assets/http-6X-ZAFMk.js +1 -0
- package/dist/web/assets/hurl-CFv0vwqV.js +1 -0
- package/dist/web/assets/hxml-Dcm88srW.js +1 -0
- package/dist/web/assets/hy-CZbG8q4J.js +1 -0
- package/dist/web/assets/imba-DsUTQ-LC.js +1 -0
- package/dist/web/assets/index-DdkL6eTr.css +1 -0
- package/dist/web/assets/index-thDNjFvN.js +235 -0
- package/dist/web/assets/info-OHQRW6UA-BkLeneU8.js +1 -0
- package/dist/web/assets/infoDiagram-5W2HQ5XZ-D5sUoBy9.js +2 -0
- package/dist/web/assets/ini-B5eOa1yu.js +1 -0
- package/dist/web/assets/init-C-OQMol4.js +1 -0
- package/dist/web/assets/ishikawaDiagram-K3B6WC7H-D5Oe30A8.js +70 -0
- package/dist/web/assets/java-Dd1Ai6aF.js +1 -0
- package/dist/web/assets/javascript-C-JN5i9P.js +1 -0
- package/dist/web/assets/jinja-WZ65gNFf.js +1 -0
- package/dist/web/assets/jison-Bg9NXAdk.js +1 -0
- package/dist/web/assets/journeyDiagram-COXZFDF6-WcVKvz_j.js +139 -0
- package/dist/web/assets/json-Hnqo41ms.js +1 -0
- package/dist/web/assets/json5-BR5RXkoi.js +1 -0
- package/dist/web/assets/jsonc-CYpm1nAK.js +1 -0
- package/dist/web/assets/jsonl-CmCQp5Yx.js +1 -0
- package/dist/web/assets/jsonnet-CJTPZ8u_.js +1 -0
- package/dist/web/assets/jssm-DXw9l8Rf.js +1 -0
- package/dist/web/assets/jsx-D9aGArJK.js +1 -0
- package/dist/web/assets/julia-Bn16XnNV.js +1 -0
- package/dist/web/assets/just-KiXY5riV.js +1 -0
- package/dist/web/assets/kanagawa-dragon-Bozd7klA.js +1 -0
- package/dist/web/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
- package/dist/web/assets/kanagawa-wave-BML5mF2J.js +1 -0
- package/dist/web/assets/kanban-definition-P3RFRI5V-q-zm7dfv.js +89 -0
- package/dist/web/assets/katex-DJhrJSbw.js +257 -0
- package/dist/web/assets/kdl-CsD5j6eV.js +1 -0
- package/dist/web/assets/kotlin-DhhofPvG.js +1 -0
- package/dist/web/assets/kusto-C7mF5XQf.js +1 -0
- package/dist/web/assets/laserwave-C_8bwKvT.js +1 -0
- package/dist/web/assets/latex-i_9kRMgr.js +1 -0
- package/dist/web/assets/lean-CewbzKMR.js +1 -0
- package/dist/web/assets/less-DVTAwKKz.js +1 -0
- package/dist/web/assets/light-plus-DVQuIRkW.js +1 -0
- package/dist/web/assets/line-DDhwvqef.js +1 -0
- package/dist/web/assets/linear-c8ViwBO3.js +1 -0
- package/dist/web/assets/liquid-B1LRugeL.js +1 -0
- package/dist/web/assets/llvm-DqHOMS-s.js +1 -0
- package/dist/web/assets/log-BNLmms1o.js +1 -0
- package/dist/web/assets/logo-Cluzi2Zq.js +1 -0
- package/dist/web/assets/lua-PLqxkaI5.js +1 -0
- package/dist/web/assets/luau-VGWVLoqX.js +1 -0
- package/dist/web/assets/make-Dixweg8N.js +1 -0
- package/dist/web/assets/markdown-BYOwaDjH.js +1 -0
- package/dist/web/assets/marko-KKSzWszp.js +1 -0
- package/dist/web/assets/material-theme-Bm3Qr25_.js +1 -0
- package/dist/web/assets/material-theme-darker-2IIEA8gg.js +1 -0
- package/dist/web/assets/material-theme-lighter-uhdI0v04.js +1 -0
- package/dist/web/assets/material-theme-ocean-CHQ94UKr.js +1 -0
- package/dist/web/assets/material-theme-palenight-B5W6OYN7.js +1 -0
- package/dist/web/assets/matlab-D7qyCx1q.js +1 -0
- package/dist/web/assets/mdc-BkVjD0F4.js +1 -0
- package/dist/web/assets/mdx-DQZ5AkYe.js +1 -0
- package/dist/web/assets/mermaid-BxpfTIzt.js +1 -0
- package/dist/web/assets/mermaid-parser.core-vLmarQ1b.js +7 -0
- package/dist/web/assets/mermaid.core-CaSYqI02.js +44 -0
- package/dist/web/assets/min-dark-BSWPekZh.js +1 -0
- package/dist/web/assets/min-light-DDpmG2fV.js +1 -0
- package/dist/web/assets/mindmap-definition-LZFPQGKD-DQUlG3eq.js +96 -0
- package/dist/web/assets/mipsasm-BMqwQI7S.js +1 -0
- package/dist/web/assets/mojo-fg40_ntQ.js +1 -0
- package/dist/web/assets/monokai-BscAjiQZ.js +1 -0
- package/dist/web/assets/moonbit-D9W784E_.js +1 -0
- package/dist/web/assets/move-BYnGhZoM.js +1 -0
- package/dist/web/assets/narrat-_X_XdTYD.js +1 -0
- package/dist/web/assets/nextflow-CSgcYZcx.js +1 -0
- package/dist/web/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
- package/dist/web/assets/nginx-BCxqweXv.js +1 -0
- package/dist/web/assets/night-owl-DhmEMT88.js +1 -0
- package/dist/web/assets/night-owl-light-eJ-hLW7d.js +1 -0
- package/dist/web/assets/nim-Bn4AK1z1.js +1 -0
- package/dist/web/assets/nix-IvuFDN5E.js +1 -0
- package/dist/web/assets/nord-Cb4Vim4T.js +1 -0
- package/dist/web/assets/nsis-CjNLYCLg.js +1 -0
- package/dist/web/assets/nushell--0fT3PI1.js +1 -0
- package/dist/web/assets/objective-c-D1A_Heim.js +1 -0
- package/dist/web/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/dist/web/assets/ocaml-O90oeIOV.js +1 -0
- package/dist/web/assets/odin-B1RWQWA5.js +1 -0
- package/dist/web/assets/one-dark-pro-C6YyR8OD.js +1 -0
- package/dist/web/assets/one-light-D7Lr4KcI.js +1 -0
- package/dist/web/assets/openscad-BUDT5pXO.js +1 -0
- package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/web/assets/org-BdePQpbO.js +1 -0
- package/dist/web/assets/packet-JDAUHWVQ-B036XPve.js +1 -0
- package/dist/web/assets/pascal-4ZHwLPI5.js +1 -0
- package/dist/web/assets/path-fybaL0A-.js +1 -0
- package/dist/web/assets/pegDiagram-BGZESJAR-B5VSphc1.js +1 -0
- package/dist/web/assets/perl-DOKWME04.js +1 -0
- package/dist/web/assets/php-D1jKZSZS.js +1 -0
- package/dist/web/assets/pie-XZMESJXO-B0mUrCvT.js +1 -0
- package/dist/web/assets/pieDiagram-CAPJLFHJ-C8xmyQ-z.js +39 -0
- package/dist/web/assets/pkl-ot-7Btpt.js +1 -0
- package/dist/web/assets/plastic-DQwYfKfQ.js +1 -0
- package/dist/web/assets/plsql-DGHpHOYJ.js +1 -0
- package/dist/web/assets/po-BiJDBrnU.js +1 -0
- package/dist/web/assets/poimandres-DRFjx7u4.js +1 -0
- package/dist/web/assets/polar-C7UOKdEL.js +1 -0
- package/dist/web/assets/postcss-BXeXVLqQ.js +1 -0
- package/dist/web/assets/powerquery-DNMTfnFr.js +1 -0
- package/dist/web/assets/powershell-QtSnmXcu.js +1 -0
- package/dist/web/assets/prisma-DMgNhnYS.js +1 -0
- package/dist/web/assets/prolog-iXnhIJG7.js +1 -0
- package/dist/web/assets/proto-DB4EqR-F.js +1 -0
- package/dist/web/assets/pug-BJpuboRB.js +1 -0
- package/dist/web/assets/puppet-CDv2pdJW.js +1 -0
- package/dist/web/assets/purescript-9MfHhQsQ.js +1 -0
- package/dist/web/assets/python-gzcpVVnB.js +1 -0
- package/dist/web/assets/qml-CEJGh-Kq.js +1 -0
- package/dist/web/assets/qmldir-DCQb3MpD.js +1 -0
- package/dist/web/assets/qss-Fe1Jh2GI.js +1 -0
- package/dist/web/assets/quadrantDiagram-GDMTTRAM-D9H7_cMs.js +7 -0
- package/dist/web/assets/r-D99YzVsv.js +1 -0
- package/dist/web/assets/racket-DcIDlBhZ.js +1 -0
- package/dist/web/assets/radar-ABXABTNO-BsvigTX1.js +1 -0
- package/dist/web/assets/railroad-I3PHUGI6-BniuGKI_.js +1 -0
- package/dist/web/assets/railroad-abnf-LNEFI6M7-Dq8rfUcB.js +1 -0
- package/dist/web/assets/railroad-ebnf-DWYD2UWJ-eqGmJVi-.js +1 -0
- package/dist/web/assets/railroad-peg-JR7BVNR7-pKU4bxp7.js +1 -0
- package/dist/web/assets/railroadDiagram-SM67HX2B-BtmVYBDt.js +1 -0
- package/dist/web/assets/raku-B3gFvitq.js +1 -0
- package/dist/web/assets/razor-V3YH92c-.js +1 -0
- package/dist/web/assets/rbs-C0MpOrj4.js +1 -0
- package/dist/web/assets/red-CYlAq2b0.js +1 -0
- package/dist/web/assets/reg-CRGYupPL.js +1 -0
- package/dist/web/assets/regexp-zxy_MBYX.js +1 -0
- package/dist/web/assets/rel-BtDbiS_P.js +1 -0
- package/dist/web/assets/requirementDiagram-X7JNWC4A-BhsEMfWN.js +84 -0
- package/dist/web/assets/riscv-Ckw8ddFX.js +1 -0
- package/dist/web/assets/ron-VUp2lXgN.js +1 -0
- package/dist/web/assets/rose-pine-aMsaMD1D.js +1 -0
- package/dist/web/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
- package/dist/web/assets/rose-pine-moon-DO-qXg-I.js +1 -0
- package/dist/web/assets/rosmsg-CAekHB0j.js +1 -0
- package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/web/assets/rst-mDxKxoTF.js +1 -0
- package/dist/web/assets/ruby-2fdsFnyL.js +1 -0
- package/dist/web/assets/rust-DpiIf5JO.js +1 -0
- package/dist/web/assets/sankeyDiagram-UM26HJRW-Blo8vqk5.js +40 -0
- package/dist/web/assets/sas-B9eZjlRt.js +1 -0
- package/dist/web/assets/sass-DXrisJhu.js +1 -0
- package/dist/web/assets/scala-BiFt_7mu.js +1 -0
- package/dist/web/assets/scheme-DQCgrYNe.js +1 -0
- package/dist/web/assets/scss-CzLxdZSp.js +1 -0
- package/dist/web/assets/sdbl-bTVj8UrX.js +1 -0
- package/dist/web/assets/sequenceDiagram-ZO4K6R2Y-DrzXts2L.js +169 -0
- package/dist/web/assets/shaderlab-TOUzSsQk.js +1 -0
- package/dist/web/assets/shellscript-DjP-Cu--.js +1 -0
- package/dist/web/assets/shellsession-CwreK3FS.js +1 -0
- package/dist/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/web/assets/slack-dark-DnToyrRv.js +1 -0
- package/dist/web/assets/slack-ochin-B2OO5cIa.js +1 -0
- package/dist/web/assets/smalltalk-BHpQb5nM.js +1 -0
- package/dist/web/assets/smithy-Dgaz7aur.js +1 -0
- package/dist/web/assets/snazzy-light-4G7pJPwS.js +1 -0
- package/dist/web/assets/solarized-dark-b97czJOk.js +1 -0
- package/dist/web/assets/solarized-light-DFPxpIYQ.js +1 -0
- package/dist/web/assets/solidity-Bj7E1mRn.js +1 -0
- package/dist/web/assets/soy-C5Ng8TEx.js +1 -0
- package/dist/web/assets/sparql-D_iOobhT.js +1 -0
- package/dist/web/assets/splunk-BC2Px7Mm.js +1 -0
- package/dist/web/assets/sql-BfbMjUZp.js +1 -0
- package/dist/web/assets/src-zUDYm5ap.js +1 -0
- package/dist/web/assets/ssh-config-BgfXC-Er.js +1 -0
- package/dist/web/assets/stata-d4tyAeP_.js +1 -0
- package/dist/web/assets/stateDiagram-v2-TBUQTH76-DjZ1vBcn.js +291 -0
- package/dist/web/assets/stylus-B6D30XZt.js +1 -0
- package/dist/web/assets/surrealql-DTHkj589.js +1 -0
- package/dist/web/assets/svelte-BuYW1eGR.js +1 -0
- package/dist/web/assets/swift-CyEgAFGc.js +1 -0
- package/dist/web/assets/swimlanes-N4OXWK64-B01cawi9.js +1 -0
- package/dist/web/assets/swimlanesDiagram-K3J5GTZL-CzwG903S.js +8 -0
- package/dist/web/assets/synthwave-84-nFMaYfgc.js +1 -0
- package/dist/web/assets/system-verilog-Dh5ihWbf.js +1 -0
- package/dist/web/assets/systemd-BxMlprV5.js +1 -0
- package/dist/web/assets/talonscript-CohzipZa.js +1 -0
- package/dist/web/assets/tasl-DMoTqEGO.js +1 -0
- package/dist/web/assets/tcl-CZd0xW_V.js +1 -0
- package/dist/web/assets/templ-C2uBh9Ag.js +1 -0
- package/dist/web/assets/terraform-DswuEJGm.js +1 -0
- package/dist/web/assets/tex-CM41T9FS.js +1 -0
- package/dist/web/assets/timeline-definition-YOQAKGHF-DA3lBb9c.js +120 -0
- package/dist/web/assets/tokyo-night-oM2G3aXe.js +1 -0
- package/dist/web/assets/toml-CcmNWLt0.js +1 -0
- package/dist/web/assets/treeView-D4JQ5SDB-BLlFhQyR.js +1 -0
- package/dist/web/assets/treemap-SAJKECNS-DHDxrnYC.js +1 -0
- package/dist/web/assets/ts-tags-CubWvvW8.js +1 -0
- package/dist/web/assets/tsv-sltzmVWM.js +1 -0
- package/dist/web/assets/tsx-BOJXRLTs.js +1 -0
- package/dist/web/assets/turtle-ByJddavk.js +1 -0
- package/dist/web/assets/twig-DRJ5AsA3.js +1 -0
- package/dist/web/assets/typescript-BNgJ2FM9.js +1 -0
- package/dist/web/assets/typespec-BGhR2SqG.js +1 -0
- package/dist/web/assets/typst-DD5tA4KG.js +1 -0
- package/dist/web/assets/usecaseDiagram-VIAY4XPW-DMYZGaBl.js +328 -0
- package/dist/web/assets/v-DBj4K9d_.js +1 -0
- package/dist/web/assets/vala-zf12oZj6.js +1 -0
- package/dist/web/assets/vb-DOwzaHZ0.js +1 -0
- package/dist/web/assets/vennDiagram-BLWOH2XV-EufOoAyB.js +34 -0
- package/dist/web/assets/verilog-B2gwz-sJ.js +1 -0
- package/dist/web/assets/vesper-DdrHHSXu.js +1 -0
- package/dist/web/assets/vhdl-BroJfC0k.js +1 -0
- package/dist/web/assets/viml-DvXPmvsu.js +1 -0
- package/dist/web/assets/vitesse-black-fwtXNY1n.js +1 -0
- package/dist/web/assets/vitesse-dark-BZCL-v6S.js +1 -0
- package/dist/web/assets/vitesse-light-VbXTXTou.js +1 -0
- package/dist/web/assets/vue-DodVQd5D.js +1 -0
- package/dist/web/assets/vue-html-D8YdbzI8.js +1 -0
- package/dist/web/assets/vue-vine-Daazu5-q.js +1 -0
- package/dist/web/assets/vyper-CgoNMtux.js +1 -0
- package/dist/web/assets/wardley-7MLQ67FV-CXmW0Q5D.js +1 -0
- package/dist/web/assets/wardleyDiagram-YMQ3BMBF-B5oI1vuE.js +78 -0
- package/dist/web/assets/wasm-ByWQv1Qj.js +1 -0
- package/dist/web/assets/wenyan-C8pVoKbM.js +1 -0
- package/dist/web/assets/wgsl-BsKzXJz4.js +1 -0
- package/dist/web/assets/wikitext-ClFFjSW2.js +1 -0
- package/dist/web/assets/wit-DdvCle-K.js +1 -0
- package/dist/web/assets/wolfram-DLL8P-h_.js +1 -0
- package/dist/web/assets/xml-C00crbAK.js +1 -0
- package/dist/web/assets/xsl-DGdUfA8V.js +1 -0
- package/dist/web/assets/xychartDiagram-TAQBALBS-kVDrm0rh.js +7 -0
- package/dist/web/assets/yaml-SvAgLt0R.js +1 -0
- package/dist/web/assets/zenscript-BnlCZFoB.js +1 -0
- package/dist/web/assets/zig-CMLA9XwU.js +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +78 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { parseArgs, promisify } from "node:util";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import openInBrowser from "open";
|
|
11
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { getRequestListener } from "@hono/node-server";
|
|
14
|
+
import { Hono } from "hono";
|
|
15
|
+
import { stream, streamSSE } from "hono/streaming";
|
|
16
|
+
import { tasklist } from "@mdit/plugin-tasklist";
|
|
17
|
+
import markdownIt from "markdown-it";
|
|
18
|
+
import search from "approx-string-match";
|
|
19
|
+
import { watch } from "chokidar";
|
|
20
|
+
//#region src/cli/createProcessTerminal.ts
|
|
21
|
+
/**
|
|
22
|
+
* Connects a CLI invocation to this process's working directory, environment and standard streams
|
|
23
|
+
*/
|
|
24
|
+
function createProcessTerminal() {
|
|
25
|
+
return {
|
|
26
|
+
workingDirectory: process.cwd(),
|
|
27
|
+
env: process.env,
|
|
28
|
+
readStdin: async () => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
31
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
32
|
+
},
|
|
33
|
+
stderr: (text) => {
|
|
34
|
+
process.stderr.write(text);
|
|
35
|
+
},
|
|
36
|
+
stdout: (text) => {
|
|
37
|
+
process.stdout.write(text);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/server/runtime/packageVersion.ts
|
|
43
|
+
var packageVersion = {
|
|
44
|
+
name: "@krelborn/markdown-review",
|
|
45
|
+
version: "0.1.0",
|
|
46
|
+
description: "Review agent-written markdown in the browser and hand the comments back to the agent",
|
|
47
|
+
license: "MIT",
|
|
48
|
+
repository: {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/Krelborn/markdown-review.git"
|
|
51
|
+
},
|
|
52
|
+
bin: { "markdown-review": "dist/cli.js" },
|
|
53
|
+
files: ["dist"],
|
|
54
|
+
type: "module",
|
|
55
|
+
publishConfig: {
|
|
56
|
+
"@krelborn:registry": "https://registry.npmjs.org/",
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
scripts: {
|
|
60
|
+
"build": "vite build && vite build --config vite.web.config.mts",
|
|
61
|
+
"typecheck": "tsc -b",
|
|
62
|
+
"lint": "oxlint",
|
|
63
|
+
"format": "oxfmt",
|
|
64
|
+
"format:check": "oxfmt --check",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:coverage": "pnpm test --coverage",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"verify": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test",
|
|
69
|
+
"prepare": "husky",
|
|
70
|
+
"prepublishOnly": "pnpm build",
|
|
71
|
+
"test:e2e": "playwright test"
|
|
72
|
+
},
|
|
73
|
+
dependencies: {
|
|
74
|
+
"@hono/node-server": "^2.1.4",
|
|
75
|
+
"@mdit/plugin-tasklist": "^1.1.3",
|
|
76
|
+
"approx-string-match": "^2.0.0",
|
|
77
|
+
"chokidar": "^5.0.0",
|
|
78
|
+
"hono": "^4.13.13",
|
|
79
|
+
"markdown-it": "^15.0.2",
|
|
80
|
+
"open": "^11.0.4",
|
|
81
|
+
"zod": "^4.6.5"
|
|
82
|
+
},
|
|
83
|
+
devDependencies: {
|
|
84
|
+
"@babel/core": "^8.0.7",
|
|
85
|
+
"@krelborn/stylesui": "^0.2.1",
|
|
86
|
+
"@playwright/test": "^1.64.0",
|
|
87
|
+
"@rolldown/plugin-babel": "^0.2.4",
|
|
88
|
+
"@testing-library/dom": "^10.4.2",
|
|
89
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
90
|
+
"@testing-library/react": "^16.3.3",
|
|
91
|
+
"@testing-library/user-event": "^14.6.7",
|
|
92
|
+
"@types/node": "^26.6.4",
|
|
93
|
+
"@types/react": "^19.3.0",
|
|
94
|
+
"@types/react-dom": "^19.3.0",
|
|
95
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
96
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
97
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
98
|
+
"clsx": "^2.1.1",
|
|
99
|
+
"dompurify": "^3.4.16",
|
|
100
|
+
"fallow": "^3.32.0",
|
|
101
|
+
"husky": "^9.1.7",
|
|
102
|
+
"jsdom": "^30.1.2",
|
|
103
|
+
"lint-staged": "^17.6.0",
|
|
104
|
+
"mermaid": "^12.1.0",
|
|
105
|
+
"oxfmt": "^0.72.0",
|
|
106
|
+
"oxlint": "^1.87.0",
|
|
107
|
+
"react": "^19.3.0",
|
|
108
|
+
"react-dom": "^19.3.0",
|
|
109
|
+
"shiki": "^4.5.0",
|
|
110
|
+
"typescript": "^7.0.2",
|
|
111
|
+
"vite": "^8.3.3",
|
|
112
|
+
"vitest": "^5.0.3"
|
|
113
|
+
},
|
|
114
|
+
engines: { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
|
|
115
|
+
packageManager: "pnpm@10.18.3"
|
|
116
|
+
}.version;
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/cli/CliError.ts
|
|
119
|
+
/**
|
|
120
|
+
* A command that cannot finish, with the `next_step` that tells the agent what to do about it
|
|
121
|
+
*/
|
|
122
|
+
var CliError = class extends Error {
|
|
123
|
+
exitCode;
|
|
124
|
+
nextStep;
|
|
125
|
+
constructor(message, nextStep, exitCode = 1) {
|
|
126
|
+
super(message);
|
|
127
|
+
this.name = "CliError";
|
|
128
|
+
this.exitCode = exitCode;
|
|
129
|
+
this.nextStep = nextStep;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/server/store/storePaths.ts
|
|
134
|
+
var storeDirectoryName = ".markdown-review";
|
|
135
|
+
function storeDirectory(root) {
|
|
136
|
+
return path.join(root, storeDirectoryName);
|
|
137
|
+
}
|
|
138
|
+
function reviewFilePath(root) {
|
|
139
|
+
return path.join(storeDirectory(root), "review.json");
|
|
140
|
+
}
|
|
141
|
+
function serverFilePath(root) {
|
|
142
|
+
return path.join(storeDirectory(root), "server.json");
|
|
143
|
+
}
|
|
144
|
+
function serverLockPath(root) {
|
|
145
|
+
return path.join(storeDirectory(root), "server.lock");
|
|
146
|
+
}
|
|
147
|
+
function serverLogPath(root) {
|
|
148
|
+
return path.join(storeDirectory(root), "server.log");
|
|
149
|
+
}
|
|
150
|
+
function documentsDirectory(root) {
|
|
151
|
+
return path.join(storeDirectory(root), "documents");
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Locates the file holding a doc's threads
|
|
155
|
+
*
|
|
156
|
+
* @param root the repo root
|
|
157
|
+
* @param document a repo-relative POSIX path, e.g. "docs/plan.md"
|
|
158
|
+
* @returns where the doc's threads are stored, e.g. "<root>/.markdown-review/documents/docs/plan.md.json"
|
|
159
|
+
*/
|
|
160
|
+
function documentThreadsFilePath(root, document) {
|
|
161
|
+
return path.join(documentsDirectory(root), ...`${document}.json`.split("/"));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Locates a doc's source on disk
|
|
165
|
+
*
|
|
166
|
+
* @param root the repo root
|
|
167
|
+
* @param document a repo-relative POSIX path
|
|
168
|
+
* @returns the doc's path on disk
|
|
169
|
+
*/
|
|
170
|
+
function documentSourcePath(root, document) {
|
|
171
|
+
return path.join(root, ...document.split("/"));
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/server/runtime/serverFileSchema.ts
|
|
175
|
+
/**
|
|
176
|
+
* `.markdown-review/server.json`: how the CLI finds and authenticates to the root's running server
|
|
177
|
+
*
|
|
178
|
+
* Not strict, so a newer server that records more fields is still recognised by its protocol rather than mistaken for
|
|
179
|
+
* no server
|
|
180
|
+
*/
|
|
181
|
+
var serverFileSchema = z.object({
|
|
182
|
+
pid: z.int(),
|
|
183
|
+
port: z.int().min(1).max(65535),
|
|
184
|
+
protocol: z.int(),
|
|
185
|
+
root: z.string(),
|
|
186
|
+
startedAt: z.iso.datetime(),
|
|
187
|
+
token: z.string().regex(/^[0-9a-f]{64}$/),
|
|
188
|
+
version: z.string()
|
|
189
|
+
});
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/server/files/isFileNotFound.ts
|
|
192
|
+
/**
|
|
193
|
+
* Tells whether a file system error means the file does not exist
|
|
194
|
+
*
|
|
195
|
+
* @param error the error a file system call rejected with
|
|
196
|
+
* @returns true for ENOENT
|
|
197
|
+
*/
|
|
198
|
+
function isFileNotFound(error) {
|
|
199
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/server/files/readTextFileOrNull.ts
|
|
203
|
+
/**
|
|
204
|
+
* Reads a UTF-8 text file
|
|
205
|
+
*
|
|
206
|
+
* @param filePath the file to read
|
|
207
|
+
* @returns the contents, or null when the file does not exist
|
|
208
|
+
* @throws the file system error when the file exists but cannot be read
|
|
209
|
+
*/
|
|
210
|
+
async function readTextFileOrNull(filePath) {
|
|
211
|
+
try {
|
|
212
|
+
return await readFile(filePath, "utf8");
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (isFileNotFound(error)) return null;
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/server/store/readStoreFile.ts
|
|
220
|
+
/**
|
|
221
|
+
* Reads and validates a store file
|
|
222
|
+
*
|
|
223
|
+
* @param filePath the file to read
|
|
224
|
+
* @param schema the shape the file must have
|
|
225
|
+
* @returns the file's value, null when the file does not exist, or a description of what is wrong with it
|
|
226
|
+
* @throws the file system error when the file exists but cannot be read
|
|
227
|
+
*/
|
|
228
|
+
async function readStoreFile(filePath, schema) {
|
|
229
|
+
const contents = await readTextFileOrNull(filePath);
|
|
230
|
+
if (contents === null) return {
|
|
231
|
+
kind: "valid",
|
|
232
|
+
value: null
|
|
233
|
+
};
|
|
234
|
+
let value;
|
|
235
|
+
try {
|
|
236
|
+
value = JSON.parse(contents);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return {
|
|
239
|
+
kind: "invalid",
|
|
240
|
+
problem: `${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const result = schema.safeParse(value);
|
|
244
|
+
if (!result.success) return {
|
|
245
|
+
kind: "invalid",
|
|
246
|
+
problem: `${filePath} is not a valid store file:\n${z.prettifyError(result.error)}`
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
kind: "valid",
|
|
250
|
+
value: result.data
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/shared/review/isRepositoryRelativePath.ts
|
|
255
|
+
/**
|
|
256
|
+
* Tells whether a path names a file inside the repo without leaving it
|
|
257
|
+
*
|
|
258
|
+
* @param value the path to check
|
|
259
|
+
* @returns true for a relative POSIX path with no empty, "." or ".." segments
|
|
260
|
+
*/
|
|
261
|
+
function isRepositoryRelativePath(value) {
|
|
262
|
+
if (value === "" || value.startsWith("/") || value.includes("\\")) return false;
|
|
263
|
+
return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
264
|
+
}
|
|
265
|
+
var documentPathSchema = z.string().refine(isRepositoryRelativePath, "must be a repo-relative POSIX path");
|
|
266
|
+
var reviewAnchorSchema = z.strictObject({ kind: z.literal("review") });
|
|
267
|
+
var documentAnchorSchema = z.strictObject({
|
|
268
|
+
document: documentPathSchema,
|
|
269
|
+
kind: z.literal("document")
|
|
270
|
+
});
|
|
271
|
+
var passageAnchorSchema = z.strictObject({
|
|
272
|
+
anchoredText: z.string().min(1),
|
|
273
|
+
document: documentPathSchema,
|
|
274
|
+
endLine: z.int().positive(),
|
|
275
|
+
endOffset: z.int().nonnegative(),
|
|
276
|
+
kind: z.literal("passage"),
|
|
277
|
+
outdated: z.boolean(),
|
|
278
|
+
prefix: z.string().max(32),
|
|
279
|
+
quote: z.string().min(1),
|
|
280
|
+
startLine: z.int().positive(),
|
|
281
|
+
startOffset: z.int().nonnegative(),
|
|
282
|
+
suffix: z.string().max(32)
|
|
283
|
+
}).refine((anchor) => anchor.startLine <= anchor.endLine && anchor.startOffset <= anchor.endOffset, { message: "must not end before it starts" });
|
|
284
|
+
var anchorSchema = z.discriminatedUnion("kind", [
|
|
285
|
+
reviewAnchorSchema,
|
|
286
|
+
documentAnchorSchema,
|
|
287
|
+
passageAnchorSchema
|
|
288
|
+
]);
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/shared/review/threadSchema.ts
|
|
291
|
+
var messageBodySchema = z.string().refine((body) => body.trim() !== "", "must not be blank");
|
|
292
|
+
var messageSchema = z.strictObject({
|
|
293
|
+
at: z.iso.datetime(),
|
|
294
|
+
author: z.enum(["user", "agent"]),
|
|
295
|
+
body: messageBodySchema
|
|
296
|
+
});
|
|
297
|
+
var draftMessageSchema = z.strictObject({
|
|
298
|
+
at: z.iso.datetime(),
|
|
299
|
+
body: messageBodySchema
|
|
300
|
+
});
|
|
301
|
+
var threadSchema = z.strictObject({
|
|
302
|
+
anchor: anchorSchema,
|
|
303
|
+
createdAt: z.iso.datetime(),
|
|
304
|
+
draft: draftMessageSchema.optional(),
|
|
305
|
+
id: z.int().positive(),
|
|
306
|
+
messages: z.array(messageSchema),
|
|
307
|
+
status: z.enum([
|
|
308
|
+
"draft",
|
|
309
|
+
"open",
|
|
310
|
+
"resolved"
|
|
311
|
+
]),
|
|
312
|
+
updatedAt: z.iso.datetime()
|
|
313
|
+
}).refine((thread) => thread.status === "draft" === (thread.messages.length === 0), { message: "must have messages unless it is a draft" }).refine((thread) => thread.status !== "draft" || thread.draft !== void 0, { message: "must hold its comment as a draft while it is a draft" });
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/shared/api/apiResponseSchemas.ts
|
|
316
|
+
var reviewStateSchema = z.strictObject({
|
|
317
|
+
approved: z.boolean(),
|
|
318
|
+
approvedAt: z.iso.datetime().nullable(),
|
|
319
|
+
requestedAt: z.iso.datetime().nullable()
|
|
320
|
+
});
|
|
321
|
+
/**
|
|
322
|
+
* Not strict, so a newer server that reports more fields is still recognised by its protocol rather than mistaken for
|
|
323
|
+
* no server
|
|
324
|
+
*/
|
|
325
|
+
var healthSchema = z.object({
|
|
326
|
+
name: z.literal("markdown-review"),
|
|
327
|
+
pid: z.int(),
|
|
328
|
+
protocol: z.int(),
|
|
329
|
+
root: z.string(),
|
|
330
|
+
version: z.string()
|
|
331
|
+
});
|
|
332
|
+
var threadsSnapshotSchema = z.strictObject({
|
|
333
|
+
problems: z.array(z.string()),
|
|
334
|
+
review: reviewStateSchema,
|
|
335
|
+
threads: z.array(threadSchema)
|
|
336
|
+
});
|
|
337
|
+
var pollResponseSchema = threadsSnapshotSchema.extend({ timedOut: z.boolean() });
|
|
338
|
+
var agentOpenResponseSchema = z.strictObject({
|
|
339
|
+
navigated: z.boolean(),
|
|
340
|
+
review: reviewStateSchema,
|
|
341
|
+
url: z.string()
|
|
342
|
+
});
|
|
343
|
+
var agentThreadResponseSchema = z.strictObject({
|
|
344
|
+
inbox: threadsSnapshotSchema,
|
|
345
|
+
thread: threadSchema
|
|
346
|
+
});
|
|
347
|
+
var shutdownResponseSchema = z.strictObject({ stopping: z.literal(true) });
|
|
348
|
+
z.strictObject({
|
|
349
|
+
documents: z.array(z.strictObject({
|
|
350
|
+
document: documentPathSchema,
|
|
351
|
+
draftCount: z.int().nonnegative(),
|
|
352
|
+
openCount: z.int().nonnegative()
|
|
353
|
+
})),
|
|
354
|
+
problems: z.array(z.string()),
|
|
355
|
+
recent: z.array(documentPathSchema)
|
|
356
|
+
});
|
|
357
|
+
z.strictObject({
|
|
358
|
+
hash: z.string(),
|
|
359
|
+
path: documentPathSchema,
|
|
360
|
+
source: z.string()
|
|
361
|
+
});
|
|
362
|
+
var apiErrorSchema = z.strictObject({ error: z.strictObject({
|
|
363
|
+
message: z.string(),
|
|
364
|
+
reason: z.string()
|
|
365
|
+
}) });
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/cli/ServerRequestError.ts
|
|
368
|
+
/**
|
|
369
|
+
* A request the review server refused or answered with something the CLI cannot read
|
|
370
|
+
*/
|
|
371
|
+
var ServerRequestError = class extends Error {
|
|
372
|
+
reason;
|
|
373
|
+
status;
|
|
374
|
+
constructor(status, reason, message) {
|
|
375
|
+
super(message);
|
|
376
|
+
this.name = "ServerRequestError";
|
|
377
|
+
this.reason = reason;
|
|
378
|
+
this.status = status;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/cli/ServerClient.ts
|
|
383
|
+
var healthTimeoutMilliseconds = 1e3;
|
|
384
|
+
/**
|
|
385
|
+
* Talks to one root's review server on behalf of the agent
|
|
386
|
+
*/
|
|
387
|
+
var ServerClient = class {
|
|
388
|
+
port;
|
|
389
|
+
token;
|
|
390
|
+
constructor(port, token) {
|
|
391
|
+
this.port = port;
|
|
392
|
+
this.token = token;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* @throws when nothing answers on the port within a second
|
|
396
|
+
*/
|
|
397
|
+
health() {
|
|
398
|
+
return this.request("GET", "/api/health", healthSchema, { signal: AbortSignal.timeout(healthTimeoutMilliseconds) });
|
|
399
|
+
}
|
|
400
|
+
open(document) {
|
|
401
|
+
return this.request("POST", "/api/agent/open", agentOpenResponseSchema, { body: document === null ? {} : { path: document } });
|
|
402
|
+
}
|
|
403
|
+
inbox(document) {
|
|
404
|
+
return this.request("GET", `/api/inbox${documentQuery(document)}`, threadsSnapshotSchema);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Waits for the review to be approved, a thread to need the agent, or the timeout
|
|
408
|
+
*/
|
|
409
|
+
poll(document, timeoutSeconds, signal) {
|
|
410
|
+
const query = new URLSearchParams({ timeout: String(timeoutSeconds) });
|
|
411
|
+
if (document !== null) query.set("document", document);
|
|
412
|
+
return this.request("GET", `/api/poll?${query}`, pollResponseSchema, { signal });
|
|
413
|
+
}
|
|
414
|
+
reply(id, body) {
|
|
415
|
+
return this.request("POST", `/api/agent/threads/${id}/reply`, agentThreadResponseSchema, { body: { body } });
|
|
416
|
+
}
|
|
417
|
+
resolve(id, body) {
|
|
418
|
+
return this.request("POST", `/api/agent/threads/${id}/resolve`, agentThreadResponseSchema, { body: { body } });
|
|
419
|
+
}
|
|
420
|
+
async shutdown() {
|
|
421
|
+
await this.request("POST", "/api/shutdown", shutdownResponseSchema);
|
|
422
|
+
}
|
|
423
|
+
async request(method, path, schema, { body, signal } = {}) {
|
|
424
|
+
const response = await fetch(`http://127.0.0.1:${this.port}${path}`, {
|
|
425
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
426
|
+
headers: {
|
|
427
|
+
authorization: `Bearer ${this.token}`,
|
|
428
|
+
"content-type": "application/json"
|
|
429
|
+
},
|
|
430
|
+
method,
|
|
431
|
+
signal
|
|
432
|
+
});
|
|
433
|
+
const text = await response.text();
|
|
434
|
+
const value = parseJson(text);
|
|
435
|
+
const refusal = apiErrorSchema.safeParse(value);
|
|
436
|
+
if (!response.ok || refusal.success) {
|
|
437
|
+
const { message, reason } = refusal.data?.error ?? {
|
|
438
|
+
message: text,
|
|
439
|
+
reason: "unknown"
|
|
440
|
+
};
|
|
441
|
+
throw new ServerRequestError(response.status, reason, message);
|
|
442
|
+
}
|
|
443
|
+
const result = schema.safeParse(value);
|
|
444
|
+
if (!result.success) throw new ServerRequestError(response.status, "unreadable-response", `${method} ${path} answered with ${text}`);
|
|
445
|
+
return result.data;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
function documentQuery(document) {
|
|
449
|
+
return document === null ? "" : `?${new URLSearchParams({ document })}`;
|
|
450
|
+
}
|
|
451
|
+
function parseJson(text) {
|
|
452
|
+
try {
|
|
453
|
+
return JSON.parse(text);
|
|
454
|
+
} catch {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/cli/findServer.ts
|
|
460
|
+
/**
|
|
461
|
+
* Looks for a server already serving the root, using `.markdown-review/server.json`
|
|
462
|
+
*
|
|
463
|
+
* @param root the root's real path
|
|
464
|
+
* @returns "ready" for a healthy server of this protocol, "older" or "newer" for one of another protocol, and
|
|
465
|
+
* "absent" when there is no record, the recorded server does not answer, or it serves another root
|
|
466
|
+
*/
|
|
467
|
+
async function findServer(root) {
|
|
468
|
+
const recorded = await readStoreFile(serverFilePath(root), serverFileSchema);
|
|
469
|
+
if (recorded.kind === "invalid" || recorded.value === null) return { kind: "absent" };
|
|
470
|
+
const client = new ServerClient(recorded.value.port, recorded.value.token);
|
|
471
|
+
let health;
|
|
472
|
+
try {
|
|
473
|
+
health = await client.health();
|
|
474
|
+
} catch {
|
|
475
|
+
return { kind: "absent" };
|
|
476
|
+
}
|
|
477
|
+
if (health.root !== root) return { kind: "absent" };
|
|
478
|
+
if (health.protocol < 1) return {
|
|
479
|
+
client,
|
|
480
|
+
kind: "older"
|
|
481
|
+
};
|
|
482
|
+
return health.protocol > 1 ? {
|
|
483
|
+
health,
|
|
484
|
+
kind: "newer"
|
|
485
|
+
} : {
|
|
486
|
+
client,
|
|
487
|
+
kind: "ready"
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/cli/withStartLock.ts
|
|
492
|
+
var lockWaitMilliseconds = 15e3;
|
|
493
|
+
var lockRetryMilliseconds = 100;
|
|
494
|
+
/**
|
|
495
|
+
* Runs a task while holding `.markdown-review/server.lock`, so only one command at a time starts the root's server
|
|
496
|
+
*
|
|
497
|
+
* @param root the root's real path; its `.markdown-review/` directory must exist
|
|
498
|
+
* @param task what to do while holding the lock
|
|
499
|
+
* @returns the task's result
|
|
500
|
+
* @throws CliError when another live process holds the lock for longer than 15 seconds
|
|
501
|
+
*/
|
|
502
|
+
async function withStartLock(root, task) {
|
|
503
|
+
const lockPath = serverLockPath(root);
|
|
504
|
+
const deadline = Date.now() + lockWaitMilliseconds;
|
|
505
|
+
while (!await tryCreateLock(lockPath)) {
|
|
506
|
+
if (await removeStaleLock(lockPath)) continue;
|
|
507
|
+
if (Date.now() > deadline) throw new CliError(`Another markdown-review command has been starting the server for ${root} for over 15 seconds`, `Wait for it to finish, or delete ${lockPath} if no other markdown-review command is running, then try again.`);
|
|
508
|
+
await setTimeout$1(lockRetryMilliseconds);
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
return await task();
|
|
512
|
+
} finally {
|
|
513
|
+
await rm(lockPath, { force: true });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async function tryCreateLock(lockPath) {
|
|
517
|
+
try {
|
|
518
|
+
await writeFile(lockPath, String(process.pid), { flag: "wx" });
|
|
519
|
+
return true;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") return false;
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Removes the lock when its owner has exited without removing it. Commands check and remove it one at a time, under
|
|
527
|
+
* a second lock, so a command that found the lock stale cannot then remove the lock another command has just taken.
|
|
528
|
+
*
|
|
529
|
+
* @returns whether the lock was removed
|
|
530
|
+
*/
|
|
531
|
+
async function removeStaleLock(lockPath) {
|
|
532
|
+
const checkPath = `${lockPath}.check`;
|
|
533
|
+
if (!await tryCreateLock(checkPath)) {
|
|
534
|
+
if (await isStaleLock(checkPath)) await rm(checkPath, { force: true });
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
if (!await isStaleLock(lockPath)) return false;
|
|
539
|
+
await rm(lockPath, { force: true });
|
|
540
|
+
return true;
|
|
541
|
+
} finally {
|
|
542
|
+
await rm(checkPath, { force: true });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Tells whether the lock's owner has exited without removing it
|
|
547
|
+
*/
|
|
548
|
+
async function isStaleLock(lockPath) {
|
|
549
|
+
let owner;
|
|
550
|
+
try {
|
|
551
|
+
owner = Number(await readFile(lockPath, "utf8"));
|
|
552
|
+
} catch {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
if (!Number.isInteger(owner) || owner <= 0) return false;
|
|
556
|
+
try {
|
|
557
|
+
process.kill(owner, 0);
|
|
558
|
+
return false;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
//#endregion
|
|
564
|
+
//#region src/cli/connectToServer.ts
|
|
565
|
+
var startWaitMilliseconds = 1e4;
|
|
566
|
+
var stopWaitMilliseconds = 5e3;
|
|
567
|
+
var checkIntervalMilliseconds = 100;
|
|
568
|
+
/**
|
|
569
|
+
* Connects to the root's review server, starting one when none is running and replacing one of an older protocol
|
|
570
|
+
*
|
|
571
|
+
* @param root the root's real path
|
|
572
|
+
* @param cliPath the built CLI, which the server is started from
|
|
573
|
+
* @returns a client for the running server
|
|
574
|
+
* @throws CliError when a newer markdown-review is serving the root, or the server does not start
|
|
575
|
+
*/
|
|
576
|
+
async function connectToServer(root, cliPath) {
|
|
577
|
+
const found = await findServer(root);
|
|
578
|
+
switch (found.kind) {
|
|
579
|
+
case "ready": return found.client;
|
|
580
|
+
case "newer": throw new CliError(`A newer markdown-review (version ${found.health.version}, protocol ${found.health.protocol}) is serving ${root}`, "Run the newer markdown-review, for example `npx @krelborn/markdown-review@latest <command>`, or stop its server with it.");
|
|
581
|
+
case "older":
|
|
582
|
+
await found.client.shutdown();
|
|
583
|
+
await waitUntilStopped(found.client);
|
|
584
|
+
}
|
|
585
|
+
await mkdir(storeDirectory(root), { recursive: true });
|
|
586
|
+
return withStartLock(root, async () => {
|
|
587
|
+
const recheck = await findServer(root);
|
|
588
|
+
if (recheck.kind === "ready") return recheck.client;
|
|
589
|
+
await spawnServer(root, cliPath);
|
|
590
|
+
return waitUntilReady(root);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Waits for a server to stop answering, after asking it to shut down
|
|
595
|
+
*
|
|
596
|
+
* @param client the server's client
|
|
597
|
+
*/
|
|
598
|
+
async function waitUntilStopped(client) {
|
|
599
|
+
const deadline = Date.now() + stopWaitMilliseconds;
|
|
600
|
+
while (Date.now() < deadline) {
|
|
601
|
+
try {
|
|
602
|
+
await client.health();
|
|
603
|
+
} catch {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
await setTimeout$1(checkIntervalMilliseconds);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async function spawnServer(root, cliPath) {
|
|
610
|
+
const log = await open(serverLogPath(root), "a");
|
|
611
|
+
try {
|
|
612
|
+
spawn(process.execPath, [
|
|
613
|
+
cliPath,
|
|
614
|
+
"serve",
|
|
615
|
+
"--root",
|
|
616
|
+
root
|
|
617
|
+
], {
|
|
618
|
+
detached: true,
|
|
619
|
+
stdio: [
|
|
620
|
+
"ignore",
|
|
621
|
+
log.fd,
|
|
622
|
+
log.fd
|
|
623
|
+
]
|
|
624
|
+
}).unref();
|
|
625
|
+
} finally {
|
|
626
|
+
await log.close();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function waitUntilReady(root) {
|
|
630
|
+
const deadline = Date.now() + startWaitMilliseconds;
|
|
631
|
+
while (Date.now() < deadline) {
|
|
632
|
+
const found = await findServer(root);
|
|
633
|
+
if (found.kind === "ready") return found.client;
|
|
634
|
+
await setTimeout$1(checkIntervalMilliseconds);
|
|
635
|
+
}
|
|
636
|
+
throw new CliError(`The review server for ${root} did not start within 10 seconds`, `Read ${serverLogPath(root)}, fix what it reports, and run the command again.`);
|
|
637
|
+
}
|
|
638
|
+
//#endregion
|
|
639
|
+
//#region src/cli/findRoot.ts
|
|
640
|
+
var runCommand = promisify(execFile);
|
|
641
|
+
/**
|
|
642
|
+
* Finds the root a directory's reviews belong to
|
|
643
|
+
*
|
|
644
|
+
* @param directory an existing directory
|
|
645
|
+
* @param outsideGitDirectory the root to use when `directory` is not in a git repository; `directory` itself unless
|
|
646
|
+
* given
|
|
647
|
+
* @returns the real path of the git repository (or worktree) root holding the directory, or of `outsideGitDirectory`
|
|
648
|
+
* when it is not in a git repository
|
|
649
|
+
*/
|
|
650
|
+
async function findRoot(directory, outsideGitDirectory = directory) {
|
|
651
|
+
try {
|
|
652
|
+
const { stdout } = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: directory });
|
|
653
|
+
return await realpath(stdout.trim());
|
|
654
|
+
} catch {
|
|
655
|
+
return realpath(outsideGitDirectory);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/cli/formatIdList.ts
|
|
660
|
+
/**
|
|
661
|
+
* Lists thread IDs the way the CLI's sentences name them
|
|
662
|
+
*
|
|
663
|
+
* @param ids the thread IDs, in the order to list them
|
|
664
|
+
* @returns e.g. "#14", "#14 and #15", or "#14, #15 and #16"
|
|
665
|
+
*/
|
|
666
|
+
function formatIdList(ids) {
|
|
667
|
+
const names = ids.map((id) => `#${id}`);
|
|
668
|
+
const last = names.pop();
|
|
669
|
+
if (last === void 0) return "";
|
|
670
|
+
return names.length === 0 ? last : `${names.join(", ")} and ${last}`;
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/cli/nextSteps.ts
|
|
674
|
+
var pollTimeoutAdvice = "Give the shell command a timeout of at least 600000 ms, or in Claude Code run it with run_in_background and `--timeout 7080`.";
|
|
675
|
+
var waitForCommentsStep = `Run \`markdown-review poll\` to wait for the user's comments. ${pollTimeoutAdvice}`;
|
|
676
|
+
/**
|
|
677
|
+
* @param root the root of a doc opened from outside its repository
|
|
678
|
+
*/
|
|
679
|
+
function waitForCommentsInRootStep(root) {
|
|
680
|
+
return `This doc belongs to the repository at ${root}, so run markdown-review commands from that directory. ` + waitForCommentsStep;
|
|
681
|
+
}
|
|
682
|
+
var approvedStep = "The user approved the review. Carry on with your task; do not run `markdown-review poll` again for this review.";
|
|
683
|
+
/**
|
|
684
|
+
* @param ids the threads that need the agent, at least one
|
|
685
|
+
*/
|
|
686
|
+
function addressThreadsStep(ids) {
|
|
687
|
+
return `Edit the docs, then run \`markdown-review resolve <id> "<what changed>"\` for ${formatIdList(ids)}, or \`markdown-review reply <id> "<question>"\` if you need input. Then run \`markdown-review poll\` to wait for the next round.`;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* @param ids the threads the user commented on when approving, at least one
|
|
691
|
+
*/
|
|
692
|
+
function approvedWithCommentsStep(ids) {
|
|
693
|
+
const resolveCommand = ids.length === 1 ? `\`markdown-review resolve ${ids[0]} "<what changed>"\`` : "`markdown-review resolve <id> \"<what changed>\"` for each";
|
|
694
|
+
return `The user approved the review with comments. Address ${formatIdList(ids)} and run ${resolveCommand}, then carry on with your task; do not run \`markdown-review poll\` again for this review.`;
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/cli/formatAgentAction.ts
|
|
698
|
+
/**
|
|
699
|
+
* Confirms an agent reply or resolve and says what is left
|
|
700
|
+
*
|
|
701
|
+
* @param action what the agent did
|
|
702
|
+
* @param response the thread after the action and the inbox that remains
|
|
703
|
+
* @returns the confirmation, the threads still needing the agent, and a `next_step`
|
|
704
|
+
*/
|
|
705
|
+
function formatAgentAction(action, { inbox, thread }) {
|
|
706
|
+
const done = action === "resolved" ? `Resolved #${thread.id}.` : `Replied to #${thread.id}; it waits for the user.`;
|
|
707
|
+
const remaining = inbox.threads.map((remainingThread) => remainingThread.id);
|
|
708
|
+
if (remaining.length > 0) return `${done}\n${remaining.length === 1 ? "1 thread still needs you" : `${remaining.length} threads still need you`}: ${formatIdList(remaining)}.\n\nnext_step: Address ${formatIdList(remaining)}, then run \`markdown-review resolve <id> "<what changed>"\`, or \`markdown-review reply <id> "<question>"\` if you need input.
|
|
709
|
+
`;
|
|
710
|
+
return `${done} No threads need you now.\n\nnext_step: ${inbox.review.approved ? approvedStep : "Run `markdown-review poll` to wait for the user's next round of comments."}\n`;
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/cli/readMessageBody.ts
|
|
714
|
+
/**
|
|
715
|
+
* Reads a reply or resolve message from the command's words or from `--file`
|
|
716
|
+
*
|
|
717
|
+
* @param words the words after the thread ID
|
|
718
|
+
* @param file the `--file` option: a path, or "-" for standard input
|
|
719
|
+
* @param terminal where standard input comes from
|
|
720
|
+
* @returns the message, or null when neither was given
|
|
721
|
+
*/
|
|
722
|
+
async function readMessageBody(words, file, terminal) {
|
|
723
|
+
if (file === "-") return terminal.readStdin();
|
|
724
|
+
if (file !== void 0) return readFile(path.resolve(terminal.workingDirectory, file), "utf8");
|
|
725
|
+
return words.length === 0 ? null : words.join(" ");
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/cli/readThreadIdArgument.ts
|
|
729
|
+
/**
|
|
730
|
+
* @param value the command's first word
|
|
731
|
+
* @param usage the command's usage line, for the error
|
|
732
|
+
* @returns the thread ID
|
|
733
|
+
* @throws CliError when the word is not a thread ID
|
|
734
|
+
*/
|
|
735
|
+
function readThreadIdArgument(value, usage) {
|
|
736
|
+
if (value === void 0 || !/^#?[1-9][0-9]*$/.test(value)) throw new CliError(`Expected a thread ID such as 14, not ${value ?? "nothing"}`, `Run \`${usage}\`.`, 2);
|
|
737
|
+
return Number(value.replace("#", ""));
|
|
738
|
+
}
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/cli/commands/agentThreadCommands.ts
|
|
741
|
+
async function replyCommand(args, { cliPath, terminal }) {
|
|
742
|
+
const { id, message } = await readArguments(args, terminal, "markdown-review reply <id> \"<text>\"");
|
|
743
|
+
if (message === null) throw new CliError("reply needs the text of the reply", "Run `markdown-review reply <id> \"<text>\"`.", 2);
|
|
744
|
+
const client = await connectToServer(await findRoot(terminal.workingDirectory), cliPath);
|
|
745
|
+
const response = await explainUnknownThread(client, id, () => client.reply(id, message));
|
|
746
|
+
terminal.stdout(formatAgentAction("replied", response));
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
async function resolveCommand(args, { cliPath, terminal }) {
|
|
750
|
+
const { id, message } = await readArguments(args, terminal, "markdown-review resolve <id> \"<what changed>\"");
|
|
751
|
+
const client = await connectToServer(await findRoot(terminal.workingDirectory), cliPath);
|
|
752
|
+
const response = await explainUnknownThread(client, id, () => client.resolve(id, message));
|
|
753
|
+
terminal.stdout(formatAgentAction("resolved", response));
|
|
754
|
+
return 0;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Reads the thread ID and the message, before anything connects to the server
|
|
758
|
+
*/
|
|
759
|
+
async function readArguments(args, terminal, usage) {
|
|
760
|
+
const { positionals, values } = parseArgs({
|
|
761
|
+
allowPositionals: true,
|
|
762
|
+
args,
|
|
763
|
+
options: { file: { type: "string" } }
|
|
764
|
+
});
|
|
765
|
+
const [idWord, ...words] = positionals;
|
|
766
|
+
return {
|
|
767
|
+
id: readThreadIdArgument(idWord, usage),
|
|
768
|
+
message: await readMessageBody(words, values.file, terminal)
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Turns the server's "unknown thread" into an error that lists the threads the agent can act on
|
|
773
|
+
*/
|
|
774
|
+
async function explainUnknownThread(client, id, action) {
|
|
775
|
+
try {
|
|
776
|
+
return await action();
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (!(error instanceof ServerRequestError) || error.reason !== "unknown-thread") throw error;
|
|
779
|
+
const waiting = (await client.inbox(null)).threads.map((thread) => thread.id);
|
|
780
|
+
throw new CliError(`No thread #${id} is waiting for you. ${waiting.length === 0 ? "No threads need you." : `The threads that need you are ${formatIdList(waiting)}.`}`, "Run `markdown-review inbox` to see them.");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region src/cli/formatInbox.ts
|
|
785
|
+
var longQuoteLength = 200;
|
|
786
|
+
var quoteStartLength = 120;
|
|
787
|
+
/**
|
|
788
|
+
* Describes the agent's inbox as compact text for the agent to read
|
|
789
|
+
*
|
|
790
|
+
* @param snapshot the review state and the threads that need the agent
|
|
791
|
+
* @returns the summary, each thread grouped by doc with its messages, any store problems, and a final `next_step`
|
|
792
|
+
*/
|
|
793
|
+
function formatInbox({ problems, review, threads }) {
|
|
794
|
+
const ordered = orderByDocument(threads);
|
|
795
|
+
const ids = ordered.map((thread) => thread.id);
|
|
796
|
+
const sections = [summaryLine(review.approved, ordered)];
|
|
797
|
+
if (ordered.length > 0) sections.push(ordered.map(formatThread).join("\n\n"));
|
|
798
|
+
if (problems.length > 0) sections.push(problems.map((problem) => `warning: ${indentContinuation(problem, " ")}`).join("\n"));
|
|
799
|
+
sections.push(`next_step: ${nextStep(review.approved, ids)}`);
|
|
800
|
+
return `${sections.join("\n\n")}\n`;
|
|
801
|
+
}
|
|
802
|
+
function formatThread(thread) {
|
|
803
|
+
const messages = thread.messages.map(({ author, body }) => ` ${author}: ${indentContinuation(body, " ")}`);
|
|
804
|
+
return [...describeAnchor(thread), ...messages].join("\n");
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Says where the thread is, and for a passage, the text it quotes now and what the user originally selected
|
|
808
|
+
*/
|
|
809
|
+
function describeAnchor({ anchor, id }) {
|
|
810
|
+
switch (anchor.kind) {
|
|
811
|
+
case "review": return [`#${id} (whole review)`];
|
|
812
|
+
case "document": return [`#${id} ${anchor.document} (whole doc)`];
|
|
813
|
+
case "passage": {
|
|
814
|
+
const range = anchor.startLine === anchor.endLine ? `${anchor.startLine}` : `${anchor.startLine}-${anchor.endLine}`;
|
|
815
|
+
const location = `#${id} ${anchor.document}:${range}${anchor.outdated ? " (outdated)" : ""}`;
|
|
816
|
+
if (anchor.outdated) return [location, ` quote: ${formatQuote(anchor.quote)}`];
|
|
817
|
+
const original = anchor.anchoredText === anchor.quote ? [] : [` was: ${formatQuote(anchor.quote)}`];
|
|
818
|
+
return [
|
|
819
|
+
location,
|
|
820
|
+
` quote: ${formatQuote(anchor.anchoredText)}`,
|
|
821
|
+
...original
|
|
822
|
+
];
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function summaryLine(approved, threads) {
|
|
827
|
+
if (threads.length === 0) return approved ? "Review approved. No threads need you." : "No threads need you.";
|
|
828
|
+
const counts = /* @__PURE__ */ new Map();
|
|
829
|
+
for (const thread of threads) {
|
|
830
|
+
const group = thread.anchor.kind === "review" ? "review" : thread.anchor.document;
|
|
831
|
+
counts.set(group, (counts.get(group) ?? 0) + 1);
|
|
832
|
+
}
|
|
833
|
+
const breakdown = [...counts].map(([group, count]) => `${group}: ${count}`).join(", ");
|
|
834
|
+
const needs = threads.length === 1 ? "1 thread needs you" : `${threads.length} threads need you`;
|
|
835
|
+
return approved ? `Review approved, ${needs} (${breakdown})` : `${needs} (${breakdown})`;
|
|
836
|
+
}
|
|
837
|
+
function nextStep(approved, ids) {
|
|
838
|
+
if (ids.length === 0) return approved ? approvedStep : waitForCommentsStep;
|
|
839
|
+
return approved ? approvedWithCommentsStep(ids) : addressThreadsStep(ids);
|
|
840
|
+
}
|
|
841
|
+
function orderByDocument(threads) {
|
|
842
|
+
const groupOf = (thread) => thread.anchor.kind === "review" ? "" : thread.anchor.document;
|
|
843
|
+
return [...threads].sort((left, right) => groupOf(left).localeCompare(groupOf(right)) || left.id - right.id);
|
|
844
|
+
}
|
|
845
|
+
function formatQuote(text) {
|
|
846
|
+
const shortened = text.length > longQuoteLength ? `${text.slice(0, quoteStartLength)}…${text.slice(-60)}` : text;
|
|
847
|
+
return JSON.stringify(shortened);
|
|
848
|
+
}
|
|
849
|
+
function indentContinuation(text, indent) {
|
|
850
|
+
return text.split("\n").join(`\n${indent}`);
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/cli/toRepositoryPath.ts
|
|
854
|
+
/**
|
|
855
|
+
* Turns a path the agent typed into the repo-relative POSIX path the server uses
|
|
856
|
+
*
|
|
857
|
+
* @param root the root's real path
|
|
858
|
+
* @param absolutePath the path, already resolved against the working directory
|
|
859
|
+
* @throws CliError when the path is outside the root
|
|
860
|
+
*/
|
|
861
|
+
function toRepositoryPath(root, absolutePath) {
|
|
862
|
+
const relativePath = path.relative(root, absolutePath);
|
|
863
|
+
if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) throw new CliError(`${absolutePath} is not a file inside the repository at ${root}`, "Pass a path to a markdown file inside the repository.");
|
|
864
|
+
return relativePath.split(path.sep).join("/");
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/cli/readDocumentOption.ts
|
|
868
|
+
/**
|
|
869
|
+
* Reads a `--document <path>` option, which may be relative to the working directory
|
|
870
|
+
*
|
|
871
|
+
* @param root the root's real path
|
|
872
|
+
* @param workingDirectory the directory the command runs in
|
|
873
|
+
* @param value the option's value, if it was given
|
|
874
|
+
* @returns the doc's repo-relative POSIX path, or null when the option was not given
|
|
875
|
+
*/
|
|
876
|
+
async function readDocumentOption(root, workingDirectory, value) {
|
|
877
|
+
return value === void 0 ? null : toRepositoryPath(root, path.resolve(await realpath(workingDirectory), value));
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
//#region src/cli/commands/inboxCommand.ts
|
|
881
|
+
async function inboxCommand(args, { cliPath, terminal }) {
|
|
882
|
+
const { values } = parseArgs({
|
|
883
|
+
args,
|
|
884
|
+
options: { document: { type: "string" } }
|
|
885
|
+
});
|
|
886
|
+
const root = await findRoot(terminal.workingDirectory);
|
|
887
|
+
const document = await readDocumentOption(root, terminal.workingDirectory, values.document);
|
|
888
|
+
const client = await connectToServer(root, cliPath);
|
|
889
|
+
terminal.stdout(formatInbox(await client.inbox(document)));
|
|
890
|
+
return 0;
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region skills/markdown-review/SKILL.md?raw
|
|
894
|
+
var SKILL_default = "---\nname: markdown-review\ndescription: Hands markdown docs you wrote, such as plans, specs and design notes, to the user to review in their browser, then reads their comments back so you can edit the docs and answer each one. Use when the user asks you to have a doc reviewed, or when a doc you wrote needs their sign-off before you carry on.\n---\n\n# Markdown Review\n\nThe user reviews your markdown in a browser and comments on passages, blocks, whole docs or the review as a whole. You read the comments with the `markdown-review` command, edit the docs, and answer each comment.\n\nIf the `markdown-review` command is not found, install it with `npm install --global @krelborn/markdown-review`.\n\n## The review loop\n\n1. Run `markdown-review open <doc.md>`. It starts a review round and shows the doc in the user's browser.\n2. Run `markdown-review poll`. It waits until the user submits, then prints each comment: its thread ID, file, line range, the quoted text and the user's words.\n - Give the shell command a timeout of at least 600000 ms. `poll` gives up after 540 seconds by default; run it again if it says there are no comments yet.\n - In Claude Code, run it with `run_in_background` and `--timeout 7080`, and a Bash timeout of 7200000 ms. A submit still ends it at once.\n3. Edit the docs. Then answer every thread:\n - `markdown-review resolve <id> \"<what changed>\"` when you have addressed it.\n - `markdown-review reply <id> \"<question>\"` when you need the user's input.\n - For a long message, put it in a file and pass `--file <path>`, or pass `--file -` and write it to standard input.\n4. Run `markdown-review poll` again for the next round.\n\n`markdown-review inbox` prints what `poll` would, without waiting, for picking comments up later.\n\n## Approval\n\nThe review ends when the user approves it, and `poll` and `inbox` then start with \"Review approved\". Address any comments that came with the approval, then carry on with your task. Do not run `poll` again for that review.\n\n## Good to know\n\n- A quote is the text as the browser shows it, not the markdown source. The line range is exact, so use it to find the passage.\n- If `poll` is stopped before the user submits, nothing is lost: run it again.\n- Every command ends with a `next_step` line that says what to do next. `markdown-review --help` lists the commands.\n- Run `markdown-review stop` to stop the review server when you have finished.\n";
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/cli/commands/installSkillCommand.ts
|
|
897
|
+
/**
|
|
898
|
+
* Writes the skill that teaches agents the review workflow where Claude Code and OpenCode look for skills
|
|
899
|
+
*/
|
|
900
|
+
async function installSkillCommand(args, { terminal }) {
|
|
901
|
+
const { values } = parseArgs({
|
|
902
|
+
args,
|
|
903
|
+
options: { global: { type: "boolean" } }
|
|
904
|
+
});
|
|
905
|
+
const base = values.global === true ? terminal.env.HOME ?? os.homedir() : await findRoot(terminal.workingDirectory);
|
|
906
|
+
const skillPath = path.join(base, ".claude", "skills", "markdown-review", "SKILL.md");
|
|
907
|
+
const installed = await readTextFileOrNull(skillPath);
|
|
908
|
+
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
909
|
+
await writeFile(skillPath, SKILL_default);
|
|
910
|
+
const outcome = installed === null ? "Installed" : installed === "---\nname: markdown-review\ndescription: Hands markdown docs you wrote, such as plans, specs and design notes, to the user to review in their browser, then reads their comments back so you can edit the docs and answer each one. Use when the user asks you to have a doc reviewed, or when a doc you wrote needs their sign-off before you carry on.\n---\n\n# Markdown Review\n\nThe user reviews your markdown in a browser and comments on passages, blocks, whole docs or the review as a whole. You read the comments with the `markdown-review` command, edit the docs, and answer each comment.\n\nIf the `markdown-review` command is not found, install it with `npm install --global @krelborn/markdown-review`.\n\n## The review loop\n\n1. Run `markdown-review open <doc.md>`. It starts a review round and shows the doc in the user's browser.\n2. Run `markdown-review poll`. It waits until the user submits, then prints each comment: its thread ID, file, line range, the quoted text and the user's words.\n - Give the shell command a timeout of at least 600000 ms. `poll` gives up after 540 seconds by default; run it again if it says there are no comments yet.\n - In Claude Code, run it with `run_in_background` and `--timeout 7080`, and a Bash timeout of 7200000 ms. A submit still ends it at once.\n3. Edit the docs. Then answer every thread:\n - `markdown-review resolve <id> \"<what changed>\"` when you have addressed it.\n - `markdown-review reply <id> \"<question>\"` when you need the user's input.\n - For a long message, put it in a file and pass `--file <path>`, or pass `--file -` and write it to standard input.\n4. Run `markdown-review poll` again for the next round.\n\n`markdown-review inbox` prints what `poll` would, without waiting, for picking comments up later.\n\n## Approval\n\nThe review ends when the user approves it, and `poll` and `inbox` then start with \"Review approved\". Address any comments that came with the approval, then carry on with your task. Do not run `poll` again for that review.\n\n## Good to know\n\n- A quote is the text as the browser shows it, not the markdown source. The line range is exact, so use it to find the passage.\n- If `poll` is stopped before the user submits, nothing is lost: run it again.\n- Every command ends with a `next_step` line that says what to do next. `markdown-review --help` lists the commands.\n- Run `markdown-review stop` to stop the review server when you have finished.\n" ? "Reinstalled" : "Updated";
|
|
911
|
+
terminal.stdout(`${outcome} the markdown-review skill at ${skillPath}\n\nnext_step: The skill loads when an agent session starts. To start a review now, run \`markdown-review open <doc.md>\`.
|
|
912
|
+
`);
|
|
913
|
+
return 0;
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/cli/commands/openCommand.ts
|
|
917
|
+
async function openCommand(args, { cliPath, terminal }) {
|
|
918
|
+
const { positionals } = parseArgs({
|
|
919
|
+
allowPositionals: true,
|
|
920
|
+
args,
|
|
921
|
+
options: {}
|
|
922
|
+
});
|
|
923
|
+
if (positionals.length > 1) throw new CliError("open takes at most one path", "Run `markdown-review open [path]`.", 2);
|
|
924
|
+
const target = positionals[0];
|
|
925
|
+
const workingRoot = await findRoot(terminal.workingDirectory);
|
|
926
|
+
const { document, root } = target === void 0 ? {
|
|
927
|
+
document: null,
|
|
928
|
+
root: workingRoot
|
|
929
|
+
} : await locate(terminal.workingDirectory, target);
|
|
930
|
+
const { navigated, url } = await (await connectToServer(root, cliPath)).open(document);
|
|
931
|
+
const shown = navigated ? "Shown in the browser tab that was already open." : await openUrl(url, terminal.env);
|
|
932
|
+
const nextStep = root === workingRoot ? waitForCommentsStep : waitForCommentsInRootStep(root);
|
|
933
|
+
terminal.stdout(`Opened ${document ?? "the docs list"} for review: ${url}\n${shown}\n\nnext_step: ${nextStep}\n`);
|
|
934
|
+
return 0;
|
|
935
|
+
}
|
|
936
|
+
async function locate(workingDirectory, target) {
|
|
937
|
+
const absolutePath = path.resolve(workingDirectory, target);
|
|
938
|
+
const found = await stat(absolutePath).catch(() => null);
|
|
939
|
+
if (found === null || !found.isFile()) throw new CliError(`${target} is not a file`, "Check the path and run `markdown-review open <path>` again.");
|
|
940
|
+
const root = await findRoot(path.dirname(absolutePath), workingDirectory);
|
|
941
|
+
return {
|
|
942
|
+
document: toRepositoryPath(root, await realpath(absolutePath)),
|
|
943
|
+
root
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
async function openUrl(url, env) {
|
|
947
|
+
if (env.MARKDOWN_REVIEW_NO_BROWSER === "1") return "Not opened in a browser because MARKDOWN_REVIEW_NO_BROWSER is set; give the user the URL.";
|
|
948
|
+
try {
|
|
949
|
+
await openInBrowser(url);
|
|
950
|
+
return "Opened in the user's browser.";
|
|
951
|
+
} catch {
|
|
952
|
+
return "Could not open a browser; give the user the URL.";
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
//#endregion
|
|
956
|
+
//#region src/cli/commands/pollCommand.ts
|
|
957
|
+
var defaultTimeoutSeconds$1 = 540;
|
|
958
|
+
var interruptedExitCodes = {
|
|
959
|
+
SIGINT: 130,
|
|
960
|
+
SIGTERM: 143
|
|
961
|
+
};
|
|
962
|
+
async function pollCommand(args, { cliPath, terminal }) {
|
|
963
|
+
const { values } = parseArgs({
|
|
964
|
+
args,
|
|
965
|
+
options: {
|
|
966
|
+
document: { type: "string" },
|
|
967
|
+
timeout: { type: "string" }
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
const timeoutSeconds = readTimeout(values.timeout);
|
|
971
|
+
const root = await findRoot(terminal.workingDirectory);
|
|
972
|
+
const document = await readDocumentOption(root, terminal.workingDirectory, values.document);
|
|
973
|
+
const client = await connectToServer(root, cliPath);
|
|
974
|
+
const abort = new AbortController();
|
|
975
|
+
let interruptedBy;
|
|
976
|
+
const onSignal = (signal) => {
|
|
977
|
+
interruptedBy = signal;
|
|
978
|
+
abort.abort();
|
|
979
|
+
};
|
|
980
|
+
process.once("SIGINT", onSignal);
|
|
981
|
+
process.once("SIGTERM", onSignal);
|
|
982
|
+
try {
|
|
983
|
+
terminal.stderr(`Waiting up to ${timeoutSeconds}s for review comments. If this is interrupted, run \`markdown-review poll\` again; nothing is lost.\n`);
|
|
984
|
+
const result = await client.poll(document, timeoutSeconds, abort.signal);
|
|
985
|
+
if (result.timedOut && result.threads.length === 0 && !result.review.approved) terminal.stdout(`No comments yet.\n\nnext_step: Run \`markdown-review poll\` again to keep waiting. ${pollTimeoutAdvice}\n`);
|
|
986
|
+
else terminal.stdout(formatInbox(result));
|
|
987
|
+
return 0;
|
|
988
|
+
} catch (error) {
|
|
989
|
+
if (interruptedBy !== void 0) {
|
|
990
|
+
terminal.stderr("Interrupted while waiting. Nothing was lost; run `markdown-review poll` again to keep waiting.\n");
|
|
991
|
+
return interruptedExitCodes[interruptedBy] ?? 1;
|
|
992
|
+
}
|
|
993
|
+
if (error instanceof ServerRequestError) throw error;
|
|
994
|
+
throw new CliError("The review server stopped while you were waiting", "Run `markdown-review poll` again; it starts the server again and nothing was lost.");
|
|
995
|
+
} finally {
|
|
996
|
+
process.off("SIGINT", onSignal);
|
|
997
|
+
process.off("SIGTERM", onSignal);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function readTimeout(value) {
|
|
1001
|
+
if (value === void 0) return defaultTimeoutSeconds$1;
|
|
1002
|
+
if (!/^[0-9]+$/.test(value)) throw new CliError(`--timeout must be a whole number of seconds, not ${value}`, "Run `markdown-review poll --timeout 540`.", 2);
|
|
1003
|
+
return Number(value);
|
|
1004
|
+
}
|
|
1005
|
+
//#endregion
|
|
1006
|
+
//#region src/server/runtime/createConsoleLogger.ts
|
|
1007
|
+
/**
|
|
1008
|
+
* Creates the server process's logger; the CLI points the process's output at `.markdown-review/server.log`
|
|
1009
|
+
*
|
|
1010
|
+
* @returns a logger that writes one timestamped line per entry
|
|
1011
|
+
*/
|
|
1012
|
+
function createConsoleLogger() {
|
|
1013
|
+
const write = (level, message, error) => {
|
|
1014
|
+
const detail = error === void 0 ? "" : ` ${error instanceof Error ? error.stack ?? error.message : String(error)}`;
|
|
1015
|
+
process.stdout.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${message}${detail}\n`);
|
|
1016
|
+
};
|
|
1017
|
+
return {
|
|
1018
|
+
error: (message, error) => write("error", message, error),
|
|
1019
|
+
info: (message) => write("info", message),
|
|
1020
|
+
warn: (message, error) => write("warn", message, error)
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/server/events/BrowserTabs.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* The browser tabs connected to the server's event stream, in the order they connected
|
|
1027
|
+
*/
|
|
1028
|
+
var BrowserTabs = class {
|
|
1029
|
+
tabs = [];
|
|
1030
|
+
/**
|
|
1031
|
+
* @returns a function that disconnects the tab
|
|
1032
|
+
*/
|
|
1033
|
+
connect(tab) {
|
|
1034
|
+
this.tabs.push(tab);
|
|
1035
|
+
return () => {
|
|
1036
|
+
const index = this.tabs.indexOf(tab);
|
|
1037
|
+
if (index !== -1) this.tabs.splice(index, 1);
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Shows a page in the most recently connected tab
|
|
1042
|
+
*
|
|
1043
|
+
* @param url the page to show
|
|
1044
|
+
* @returns false when no tab is connected
|
|
1045
|
+
*/
|
|
1046
|
+
navigateLatest(url) {
|
|
1047
|
+
const latest = this.tabs.at(-1);
|
|
1048
|
+
latest?.navigate(url);
|
|
1049
|
+
return latest !== void 0;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/server/events/ServerEvents.ts
|
|
1054
|
+
/**
|
|
1055
|
+
* Carries changes between the parts of the server: a submit wakes waiting polls, and the rest reach connected tabs
|
|
1056
|
+
*/
|
|
1057
|
+
var ServerEvents = class {
|
|
1058
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1059
|
+
publish(event) {
|
|
1060
|
+
for (const listener of this.listeners) listener(event);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* @returns a function that stops the listener receiving events
|
|
1064
|
+
*/
|
|
1065
|
+
subscribe(listener) {
|
|
1066
|
+
this.listeners.add(listener);
|
|
1067
|
+
return () => {
|
|
1068
|
+
this.listeners.delete(listener);
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/server/files/writeJsonAtomically.ts
|
|
1074
|
+
/**
|
|
1075
|
+
* Writes a value as formatted JSON so readers see either the old file or the new one, never half of it
|
|
1076
|
+
*
|
|
1077
|
+
* @param filePath the file to write; missing parent directories are created
|
|
1078
|
+
* @param value the value to serialise
|
|
1079
|
+
* @param mode the file's permissions, e.g. 0o600 to keep other users out; the platform default when omitted
|
|
1080
|
+
*/
|
|
1081
|
+
async function writeJsonAtomically(filePath, value, mode) {
|
|
1082
|
+
const contents = `${JSON.stringify(value, null, 2)}\n`;
|
|
1083
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
1084
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
1085
|
+
await writeFile(temporaryPath, contents, {
|
|
1086
|
+
encoding: "utf8",
|
|
1087
|
+
mode
|
|
1088
|
+
});
|
|
1089
|
+
await rename(temporaryPath, filePath);
|
|
1090
|
+
}
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region src/server/store/StoreError.ts
|
|
1093
|
+
/**
|
|
1094
|
+
* A store operation that cannot be carried out; `reason` says why
|
|
1095
|
+
*/
|
|
1096
|
+
var StoreError = class extends Error {
|
|
1097
|
+
reason;
|
|
1098
|
+
constructor(reason, message) {
|
|
1099
|
+
super(message);
|
|
1100
|
+
this.name = "StoreError";
|
|
1101
|
+
this.reason = reason;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/shared/api/apiRequestSchemas.ts
|
|
1106
|
+
var agentOpenRequestSchema = z.strictObject({ path: documentPathSchema.optional() });
|
|
1107
|
+
var messageRequestSchema = z.strictObject({ body: messageBodySchema });
|
|
1108
|
+
var resolveRequestSchema = z.strictObject({ body: messageBodySchema.nullable() });
|
|
1109
|
+
var submitRequestSchema = z.strictObject({ verdict: z.enum(["request-changes", "approve"]) });
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/server/files/resolveRepositoryPath.ts
|
|
1112
|
+
/**
|
|
1113
|
+
* Finds a file in the repo from its repo-relative path
|
|
1114
|
+
*
|
|
1115
|
+
* @param root the repo root
|
|
1116
|
+
* @param relativePath a repo-relative POSIX path, e.g. "docs/plan.md"
|
|
1117
|
+
* @returns the file's real path when it is inside the repo; "outside" when it, or a symlink on the way, leads out of
|
|
1118
|
+
* the repo; "private" when it, or a symlink on the way, leads into `.markdown-review/`, which holds the server's
|
|
1119
|
+
* token; "missing" when nothing exists there; "invalid" when the path is not repo-relative
|
|
1120
|
+
*/
|
|
1121
|
+
async function resolveRepositoryPath(root, relativePath) {
|
|
1122
|
+
if (!isRepositoryRelativePath(relativePath)) return { kind: "invalid" };
|
|
1123
|
+
const joinedPath = path.join(root, ...relativePath.split("/"));
|
|
1124
|
+
if (isInDirectory(joinedPath, storeDirectory(root))) return { kind: "private" };
|
|
1125
|
+
let absolutePath;
|
|
1126
|
+
try {
|
|
1127
|
+
absolutePath = await realpath(joinedPath);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
if (isFileNotFound(error) || isNotDirectory(error)) return { kind: "missing" };
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1132
|
+
const realRoot = await realpath(root);
|
|
1133
|
+
if (!absolutePath.startsWith(realRoot + path.sep)) return { kind: "outside" };
|
|
1134
|
+
return isInDirectory(absolutePath, storeDirectory(realRoot)) ? { kind: "private" } : {
|
|
1135
|
+
absolutePath,
|
|
1136
|
+
kind: "inside"
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
function isInDirectory(filePath, directory) {
|
|
1140
|
+
return filePath === directory || filePath.startsWith(directory + path.sep);
|
|
1141
|
+
}
|
|
1142
|
+
function isNotDirectory(error) {
|
|
1143
|
+
return error instanceof Error && "code" in error && error.code === "ENOTDIR";
|
|
1144
|
+
}
|
|
1145
|
+
//#endregion
|
|
1146
|
+
//#region src/server/store/parseStoreInput.ts
|
|
1147
|
+
/**
|
|
1148
|
+
* Checks a value before the store acts on it or writes it
|
|
1149
|
+
*
|
|
1150
|
+
* @param schema the shape the value must have
|
|
1151
|
+
* @param value the value
|
|
1152
|
+
* @param description what the value is, for the error message, e.g. "The reply"
|
|
1153
|
+
* @returns the validated value
|
|
1154
|
+
* @throws StoreError "invalid-input" when the value does not have that shape
|
|
1155
|
+
*/
|
|
1156
|
+
function parseStoreInput(schema, value, description) {
|
|
1157
|
+
const result = schema.safeParse(value);
|
|
1158
|
+
if (!result.success) throw new StoreError("invalid-input", `${description} is not valid:\n${z.prettifyError(result.error)}`);
|
|
1159
|
+
return result.data;
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/server/http/HttpError.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* A request the server refuses, with the HTTP status and a stable `reason` the CLI and the browser can act on
|
|
1165
|
+
*/
|
|
1166
|
+
var HttpError = class extends Error {
|
|
1167
|
+
reason;
|
|
1168
|
+
status;
|
|
1169
|
+
constructor(status, reason, message) {
|
|
1170
|
+
super(message);
|
|
1171
|
+
this.name = "HttpError";
|
|
1172
|
+
this.reason = reason;
|
|
1173
|
+
this.status = status;
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
//#endregion
|
|
1177
|
+
//#region src/server/http/requestInputs.ts
|
|
1178
|
+
/**
|
|
1179
|
+
* Reads and validates a JSON request body
|
|
1180
|
+
*
|
|
1181
|
+
* @throws StoreError "invalid-input" when the body is not JSON or does not have the schema's shape
|
|
1182
|
+
*/
|
|
1183
|
+
async function readJsonBody(context, schema) {
|
|
1184
|
+
let body;
|
|
1185
|
+
try {
|
|
1186
|
+
body = await context.req.json();
|
|
1187
|
+
} catch {
|
|
1188
|
+
body = void 0;
|
|
1189
|
+
}
|
|
1190
|
+
return parseStoreInput(schema, body, "The request body");
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Reads the `:id` route parameter
|
|
1194
|
+
*
|
|
1195
|
+
* @throws HttpError 400 when it is not a positive whole number
|
|
1196
|
+
*/
|
|
1197
|
+
function readThreadId(context) {
|
|
1198
|
+
const id = context.req.param("id") ?? "";
|
|
1199
|
+
if (!/^[1-9][0-9]*$/.test(id)) throw new HttpError(400, "invalid-input", `"${id}" is not a thread ID`);
|
|
1200
|
+
return Number(id);
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Checks that a doc path from a request stays inside the repo
|
|
1204
|
+
*
|
|
1205
|
+
* @param root the repo root
|
|
1206
|
+
* @param document a repo-relative POSIX path
|
|
1207
|
+
* @param allowMissing whether a doc that no longer exists is acceptable, as it is when reading its threads
|
|
1208
|
+
* @throws HttpError 400 for a path that is not repo-relative, 403 for one that leads out of the repo or into the
|
|
1209
|
+
* review store, and 404 for a missing doc unless `allowMissing` is set
|
|
1210
|
+
*/
|
|
1211
|
+
async function checkDocumentPath(root, document, allowMissing) {
|
|
1212
|
+
switch ((await resolveRepositoryPath(root, document)).kind) {
|
|
1213
|
+
case "invalid": throw new HttpError(400, "invalid-input", `${document} is not a repo-relative path`);
|
|
1214
|
+
case "outside": throw new HttpError(403, "outside-root", `${document} leads outside the repo`);
|
|
1215
|
+
case "private": throw new HttpError(403, "private-file", `${document} is in the review store`);
|
|
1216
|
+
case "missing":
|
|
1217
|
+
if (!allowMissing) throw new HttpError(404, "missing-document", `${document} does not exist`);
|
|
1218
|
+
return;
|
|
1219
|
+
case "inside": return;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/server/http/agentRoutes.ts
|
|
1224
|
+
var shutdownDelayMilliseconds = 100;
|
|
1225
|
+
function registerAgentRoutes(app, dependencies) {
|
|
1226
|
+
const { events, port, recentDocuments, root, shutdown, store, tabs, watchDocument } = dependencies;
|
|
1227
|
+
const afterAgentAction = async (thread) => {
|
|
1228
|
+
events.publish({ type: "threads-changed" });
|
|
1229
|
+
return {
|
|
1230
|
+
inbox: await store.readInbox(null),
|
|
1231
|
+
thread
|
|
1232
|
+
};
|
|
1233
|
+
};
|
|
1234
|
+
app.post("/api/shutdown", (context) => {
|
|
1235
|
+
setTimeout(shutdown, shutdownDelayMilliseconds);
|
|
1236
|
+
return context.json({ stopping: true });
|
|
1237
|
+
});
|
|
1238
|
+
app.post("/api/agent/open", async (context) => {
|
|
1239
|
+
const { path } = await readJsonBody(context, agentOpenRequestSchema);
|
|
1240
|
+
if (path !== void 0) {
|
|
1241
|
+
await checkDocumentPath(root, path, false);
|
|
1242
|
+
recentDocuments.add(path);
|
|
1243
|
+
watchDocument(path);
|
|
1244
|
+
}
|
|
1245
|
+
const review = await store.requestReview();
|
|
1246
|
+
events.publish({ type: "threads-changed" });
|
|
1247
|
+
const pagePath = path === void 0 ? "" : `document/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
1248
|
+
const url = `http://127.0.0.1:${port()}/${pagePath}`;
|
|
1249
|
+
const response = {
|
|
1250
|
+
navigated: tabs.navigateLatest(url),
|
|
1251
|
+
review,
|
|
1252
|
+
url
|
|
1253
|
+
};
|
|
1254
|
+
return context.json(response);
|
|
1255
|
+
});
|
|
1256
|
+
app.get("/api/inbox", async (context) => {
|
|
1257
|
+
const document = context.req.query("document");
|
|
1258
|
+
if (document !== void 0) await checkDocumentPath(root, document, true);
|
|
1259
|
+
return context.json(await store.readInbox(document ?? null));
|
|
1260
|
+
});
|
|
1261
|
+
app.post("/api/agent/threads/:id/reply", async (context) => {
|
|
1262
|
+
const { body } = await readJsonBody(context, messageRequestSchema);
|
|
1263
|
+
return context.json(await afterAgentAction(await store.replyAsAgent(readThreadId(context), body)));
|
|
1264
|
+
});
|
|
1265
|
+
app.post("/api/agent/threads/:id/resolve", async (context) => {
|
|
1266
|
+
const { body } = await readJsonBody(context, resolveRequestSchema);
|
|
1267
|
+
return context.json(await afterAgentAction(await store.resolveAsAgent(readThreadId(context), body)));
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
//#endregion
|
|
1271
|
+
//#region src/shared/review/newThreadSchema.ts
|
|
1272
|
+
var newPassageAnchorSchema = z.strictObject({
|
|
1273
|
+
document: documentPathSchema,
|
|
1274
|
+
endOffset: z.int().nonnegative(),
|
|
1275
|
+
kind: z.literal("passage"),
|
|
1276
|
+
prefix: z.string().max(32),
|
|
1277
|
+
quote: z.string().min(1),
|
|
1278
|
+
startOffset: z.int().nonnegative(),
|
|
1279
|
+
suffix: z.string().max(32)
|
|
1280
|
+
}).refine((anchor) => anchor.endOffset - anchor.startOffset === anchor.quote.length, { message: "must span exactly its quote" });
|
|
1281
|
+
var newThreadSchema = z.strictObject({
|
|
1282
|
+
anchor: z.discriminatedUnion("kind", [
|
|
1283
|
+
reviewAnchorSchema,
|
|
1284
|
+
documentAnchorSchema,
|
|
1285
|
+
newPassageAnchorSchema
|
|
1286
|
+
]),
|
|
1287
|
+
body: messageBodySchema,
|
|
1288
|
+
renderedHash: z.string().optional()
|
|
1289
|
+
});
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/server/store/hashSource.ts
|
|
1292
|
+
/**
|
|
1293
|
+
* Fingerprints a doc's source so the store can tell when anchors were computed against an older version
|
|
1294
|
+
*
|
|
1295
|
+
* @param source the markdown source
|
|
1296
|
+
* @returns the SHA-256 of the source, as 64 lowercase hex digits
|
|
1297
|
+
*/
|
|
1298
|
+
function hashSource(source) {
|
|
1299
|
+
return createHash("sha256").update(source, "utf8").digest("hex");
|
|
1300
|
+
}
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/server/http/browserRoutes.ts
|
|
1303
|
+
function registerBrowserRoutes(app, dependencies) {
|
|
1304
|
+
const { events, recentDocuments, root, store, watchDocument } = dependencies;
|
|
1305
|
+
const threadsChanged = () => events.publish({ type: "threads-changed" });
|
|
1306
|
+
app.get("/api/documents", async (context) => {
|
|
1307
|
+
const { problems, threads } = await store.readThreads(null);
|
|
1308
|
+
const response = {
|
|
1309
|
+
documents: countByDocument(threads),
|
|
1310
|
+
problems,
|
|
1311
|
+
recent: recentDocuments.list()
|
|
1312
|
+
};
|
|
1313
|
+
return context.json(response);
|
|
1314
|
+
});
|
|
1315
|
+
app.get("/api/document", async (context) => {
|
|
1316
|
+
const document = context.req.query("path") ?? "";
|
|
1317
|
+
await checkDocumentPath(root, document, false);
|
|
1318
|
+
const source = await readFile(path.join(root, ...document.split("/")), "utf8");
|
|
1319
|
+
recentDocuments.add(document);
|
|
1320
|
+
watchDocument(document);
|
|
1321
|
+
const response = {
|
|
1322
|
+
hash: hashSource(source),
|
|
1323
|
+
path: document,
|
|
1324
|
+
source
|
|
1325
|
+
};
|
|
1326
|
+
return context.json(response);
|
|
1327
|
+
});
|
|
1328
|
+
app.get("/api/threads", async (context) => {
|
|
1329
|
+
const document = context.req.query("document");
|
|
1330
|
+
if (document === void 0 && context.req.query("all") !== "1") throw new HttpError(400, "invalid-input", "Pass ?document=<path> or ?all=1");
|
|
1331
|
+
if (document !== void 0) {
|
|
1332
|
+
await checkDocumentPath(root, document, true);
|
|
1333
|
+
watchDocument(document);
|
|
1334
|
+
}
|
|
1335
|
+
return context.json(await store.readThreads(document ?? null));
|
|
1336
|
+
});
|
|
1337
|
+
app.post("/api/threads", async (context) => {
|
|
1338
|
+
const newThread = await readJsonBody(context, newThreadSchema);
|
|
1339
|
+
if (newThread.anchor.kind !== "review") {
|
|
1340
|
+
await checkDocumentPath(root, newThread.anchor.document, false);
|
|
1341
|
+
watchDocument(newThread.anchor.document);
|
|
1342
|
+
}
|
|
1343
|
+
const thread = await store.createDraftThread(newThread);
|
|
1344
|
+
threadsChanged();
|
|
1345
|
+
return context.json({ thread }, 201);
|
|
1346
|
+
});
|
|
1347
|
+
app.put("/api/threads/:id/draft", async (context) => {
|
|
1348
|
+
const { body } = await readJsonBody(context, messageRequestSchema);
|
|
1349
|
+
const thread = await store.writeDraft(readThreadId(context), body);
|
|
1350
|
+
threadsChanged();
|
|
1351
|
+
return context.json({ thread });
|
|
1352
|
+
});
|
|
1353
|
+
app.delete("/api/threads/:id/draft", async (context) => {
|
|
1354
|
+
const thread = await store.deleteDraft(readThreadId(context));
|
|
1355
|
+
threadsChanged();
|
|
1356
|
+
return context.json({ thread });
|
|
1357
|
+
});
|
|
1358
|
+
app.post("/api/threads/:id/resolve", async (context) => {
|
|
1359
|
+
const thread = await store.resolveAsUser(readThreadId(context));
|
|
1360
|
+
threadsChanged();
|
|
1361
|
+
return context.json({ thread });
|
|
1362
|
+
});
|
|
1363
|
+
app.post("/api/submit", async (context) => {
|
|
1364
|
+
const { verdict } = await readJsonBody(context, submitRequestSchema);
|
|
1365
|
+
const result = await store.submit(verdict);
|
|
1366
|
+
events.publish({ type: "submitted" });
|
|
1367
|
+
threadsChanged();
|
|
1368
|
+
return context.json(result);
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
function countByDocument(threads) {
|
|
1372
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1373
|
+
for (const thread of threads) {
|
|
1374
|
+
if (thread.anchor.kind === "review") continue;
|
|
1375
|
+
const count = counts.get(thread.anchor.document) ?? {
|
|
1376
|
+
draftCount: 0,
|
|
1377
|
+
openCount: 0
|
|
1378
|
+
};
|
|
1379
|
+
counts.set(thread.anchor.document, {
|
|
1380
|
+
draftCount: count.draftCount + (thread.draft === void 0 ? 0 : 1),
|
|
1381
|
+
openCount: count.openCount + (thread.status === "open" ? 1 : 0)
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
return [...counts].filter(([, count]) => count.draftCount + count.openCount > 0).map(([document, count]) => ({
|
|
1385
|
+
document,
|
|
1386
|
+
...count
|
|
1387
|
+
})).sort((left, right) => left.document.localeCompare(right.document));
|
|
1388
|
+
}
|
|
1389
|
+
//#endregion
|
|
1390
|
+
//#region src/server/http/eventRoutes.ts
|
|
1391
|
+
function registerEventRoutes(app, { activity, events, logger, tabs }) {
|
|
1392
|
+
app.get("/api/events", (context) => streamSSE(context, async (output) => {
|
|
1393
|
+
let writes = Promise.resolve();
|
|
1394
|
+
const send = (event, data) => {
|
|
1395
|
+
writes = writes.then(() => output.writeSSE({
|
|
1396
|
+
data: JSON.stringify(data),
|
|
1397
|
+
event
|
|
1398
|
+
})).catch((error) => logger.warn("Could not write to a browser's event stream", error));
|
|
1399
|
+
};
|
|
1400
|
+
const endBrowser = activity.beginBrowser();
|
|
1401
|
+
const unsubscribe = events.subscribe((event) => {
|
|
1402
|
+
if (event.type !== "submitted") {
|
|
1403
|
+
const { type, ...data } = event;
|
|
1404
|
+
send(type, data);
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
const disconnect = tabs.connect({ navigate: (url) => send("navigate", { url }) });
|
|
1408
|
+
send("presence", { agentWaiting: activity.agentWaiting });
|
|
1409
|
+
await new Promise((resolve) => output.onAbort(resolve));
|
|
1410
|
+
unsubscribe();
|
|
1411
|
+
disconnect();
|
|
1412
|
+
endBrowser();
|
|
1413
|
+
}));
|
|
1414
|
+
}
|
|
1415
|
+
//#endregion
|
|
1416
|
+
//#region src/server/http/contentTypeFor.ts
|
|
1417
|
+
var contentTypes = {
|
|
1418
|
+
".gif": "image/gif",
|
|
1419
|
+
".html": "text/html; charset=utf-8",
|
|
1420
|
+
".jpeg": "image/jpeg",
|
|
1421
|
+
".jpg": "image/jpeg",
|
|
1422
|
+
".json": "application/json",
|
|
1423
|
+
".md": "text/markdown; charset=utf-8",
|
|
1424
|
+
".pdf": "application/pdf",
|
|
1425
|
+
".png": "image/png",
|
|
1426
|
+
".svg": "image/svg+xml",
|
|
1427
|
+
".txt": "text/plain; charset=utf-8",
|
|
1428
|
+
".webp": "image/webp"
|
|
1429
|
+
};
|
|
1430
|
+
/**
|
|
1431
|
+
* Picks the Content-Type for a repo file from its extension
|
|
1432
|
+
*
|
|
1433
|
+
* @param filePath the file's path
|
|
1434
|
+
* @returns the media type, or application/octet-stream for an extension it does not know
|
|
1435
|
+
*/
|
|
1436
|
+
function contentTypeFor(filePath) {
|
|
1437
|
+
return contentTypes[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
1438
|
+
}
|
|
1439
|
+
//#endregion
|
|
1440
|
+
//#region src/server/http/fileRoutes.ts
|
|
1441
|
+
function registerFileRoutes(app, { root }) {
|
|
1442
|
+
app.get("/files/*", async (context) => {
|
|
1443
|
+
const relativePath = decodeURIComponent(context.req.path.slice(7));
|
|
1444
|
+
const resolved = await resolveRepositoryPath(root, relativePath);
|
|
1445
|
+
if (resolved.kind === "invalid" || resolved.kind === "outside") throw new HttpError(403, "outside-root", `${relativePath} is not a file in the repo`);
|
|
1446
|
+
if (resolved.kind === "private") throw new HttpError(403, "private-file", `${relativePath} is in the review store`);
|
|
1447
|
+
if (resolved.kind === "missing" || !(await stat(resolved.absolutePath)).isFile()) throw new HttpError(404, "missing-file", `${relativePath} does not exist`);
|
|
1448
|
+
const contents = await readFile(resolved.absolutePath);
|
|
1449
|
+
return context.body(new Uint8Array(contents), 200, {
|
|
1450
|
+
"Content-Security-Policy": "sandbox",
|
|
1451
|
+
"Content-Type": contentTypeFor(relativePath),
|
|
1452
|
+
"X-Content-Type-Options": "nosniff"
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
//#endregion
|
|
1457
|
+
//#region src/server/http/healthRoutes.ts
|
|
1458
|
+
function registerHealthRoutes(app, { root, version }) {
|
|
1459
|
+
app.get("/api/health", (context) => {
|
|
1460
|
+
const health = {
|
|
1461
|
+
name: "markdown-review",
|
|
1462
|
+
pid: process.pid,
|
|
1463
|
+
protocol: 1,
|
|
1464
|
+
root,
|
|
1465
|
+
version
|
|
1466
|
+
};
|
|
1467
|
+
return context.json(health);
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
//#endregion
|
|
1471
|
+
//#region src/server/http/waitForInbox.ts
|
|
1472
|
+
/**
|
|
1473
|
+
* Waits until the review is approved or a thread needs the agent
|
|
1474
|
+
*
|
|
1475
|
+
* @returns the inbox as soon as it has something for the agent, or as it stands when the time runs out or the
|
|
1476
|
+
* request is aborted, with `timedOut` set
|
|
1477
|
+
*/
|
|
1478
|
+
async function waitForInbox({ document, events, signal, store, timeoutMilliseconds }) {
|
|
1479
|
+
const deadline = Date.now() + timeoutMilliseconds;
|
|
1480
|
+
for (;;) {
|
|
1481
|
+
const nextSubmit = waitForSubmit(events, deadline - Date.now(), signal);
|
|
1482
|
+
const inbox = await store.readInbox(document);
|
|
1483
|
+
if (inbox.review.approved || inbox.threads.length > 0) {
|
|
1484
|
+
nextSubmit.cancel();
|
|
1485
|
+
return {
|
|
1486
|
+
...inbox,
|
|
1487
|
+
timedOut: false
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
if (await nextSubmit.outcome !== "submitted") return {
|
|
1491
|
+
...inbox,
|
|
1492
|
+
timedOut: true
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Starts listening for the next submit straight away, so one that happens while the inbox is being read is not missed
|
|
1498
|
+
*/
|
|
1499
|
+
function waitForSubmit(events, timeoutMilliseconds, signal) {
|
|
1500
|
+
let finish = () => {};
|
|
1501
|
+
const outcome = new Promise((resolve) => {
|
|
1502
|
+
finish = resolve;
|
|
1503
|
+
});
|
|
1504
|
+
const timer = setTimeout(() => finish("timed-out"), Math.max(0, timeoutMilliseconds));
|
|
1505
|
+
const onAbort = () => finish("aborted");
|
|
1506
|
+
signal.addEventListener("abort", onAbort);
|
|
1507
|
+
const unsubscribe = events.subscribe((event) => {
|
|
1508
|
+
if (event.type === "submitted") finish("submitted");
|
|
1509
|
+
});
|
|
1510
|
+
const cancel = () => {
|
|
1511
|
+
clearTimeout(timer);
|
|
1512
|
+
signal.removeEventListener("abort", onAbort);
|
|
1513
|
+
unsubscribe();
|
|
1514
|
+
};
|
|
1515
|
+
if (signal.aborted) finish("aborted");
|
|
1516
|
+
outcome.then(cancel);
|
|
1517
|
+
return {
|
|
1518
|
+
cancel,
|
|
1519
|
+
outcome
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
//#endregion
|
|
1523
|
+
//#region src/server/http/pollRoutes.ts
|
|
1524
|
+
var defaultTimeoutSeconds = 540;
|
|
1525
|
+
var maximumTimeoutSeconds = 86400;
|
|
1526
|
+
function registerPollRoutes(app, dependencies) {
|
|
1527
|
+
const { activity, events, logger, pollKeepAliveMilliseconds, root, store } = dependencies;
|
|
1528
|
+
app.get("/api/poll", async (context) => {
|
|
1529
|
+
const document = context.req.query("document") ?? null;
|
|
1530
|
+
if (document !== null) await checkDocumentPath(root, document, true);
|
|
1531
|
+
const timeoutMilliseconds = readTimeoutSeconds(context.req.query("timeout")) * 1e3;
|
|
1532
|
+
context.header("Content-Type", "application/json");
|
|
1533
|
+
return stream(context, async (output) => {
|
|
1534
|
+
const abort = new AbortController();
|
|
1535
|
+
output.onAbort(() => abort.abort());
|
|
1536
|
+
const endPoll = activity.beginPoll();
|
|
1537
|
+
const keepAlive = setInterval(() => {
|
|
1538
|
+
output.write(" ").catch(() => abort.abort());
|
|
1539
|
+
}, pollKeepAliveMilliseconds);
|
|
1540
|
+
try {
|
|
1541
|
+
const result = await waitForInbox({
|
|
1542
|
+
document,
|
|
1543
|
+
events,
|
|
1544
|
+
signal: abort.signal,
|
|
1545
|
+
store,
|
|
1546
|
+
timeoutMilliseconds
|
|
1547
|
+
});
|
|
1548
|
+
if (!abort.signal.aborted) await output.write(JSON.stringify(result));
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
logger.error("A poll failed", error);
|
|
1551
|
+
await output.write(JSON.stringify({ error: {
|
|
1552
|
+
message: "The poll failed; see .markdown-review/server.log",
|
|
1553
|
+
reason: "internal"
|
|
1554
|
+
} }));
|
|
1555
|
+
} finally {
|
|
1556
|
+
clearInterval(keepAlive);
|
|
1557
|
+
endPoll();
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
function readTimeoutSeconds(value) {
|
|
1563
|
+
if (value === void 0) return defaultTimeoutSeconds;
|
|
1564
|
+
const seconds = Number(value);
|
|
1565
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > maximumTimeoutSeconds) throw new HttpError(400, "invalid-input", `The timeout must be a whole number of seconds up to ${maximumTimeoutSeconds}`);
|
|
1566
|
+
return seconds;
|
|
1567
|
+
}
|
|
1568
|
+
//#endregion
|
|
1569
|
+
//#region src/server/http/securityMiddleware.ts
|
|
1570
|
+
/**
|
|
1571
|
+
* Refuses requests whose Host header does not name this server, which blocks DNS rebinding
|
|
1572
|
+
*
|
|
1573
|
+
* @param port the port the server listens on
|
|
1574
|
+
*/
|
|
1575
|
+
function requireLocalHost(port) {
|
|
1576
|
+
return async (context, next) => {
|
|
1577
|
+
if (![`127.0.0.1:${port()}`, `localhost:${port()}`].includes(context.req.header("host") ?? "")) throw new HttpError(403, "forbidden", "The Host header does not name this server");
|
|
1578
|
+
await next();
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Refuses requests a browser sends from a page on another site, so such a page can neither read the review nor connect
|
|
1583
|
+
* to the event stream as if it were a review tab
|
|
1584
|
+
*
|
|
1585
|
+
* @param port the port the server listens on
|
|
1586
|
+
*/
|
|
1587
|
+
function refuseOtherOrigins(port) {
|
|
1588
|
+
return async (context, next) => {
|
|
1589
|
+
const origin = context.req.header("origin");
|
|
1590
|
+
if (origin !== void 0 && !ownOrigins(port()).includes(origin)) throw new HttpError(403, "forbidden", "Requests must come from this server's own pages");
|
|
1591
|
+
await next();
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Lets only the CLI through: the request must carry the server's token and no Origin header, which browsers add
|
|
1596
|
+
*
|
|
1597
|
+
* @param token the secret from `server.json`
|
|
1598
|
+
*/
|
|
1599
|
+
function requireAgentToken(token) {
|
|
1600
|
+
return async (context, next) => {
|
|
1601
|
+
if (context.req.header("origin") !== void 0) throw new HttpError(403, "forbidden", "Agent routes do not accept requests from web pages");
|
|
1602
|
+
if (!isExpectedToken(context.req.header("authorization"), token)) throw new HttpError(401, "unauthorized", "Agent routes need the token from .markdown-review/server.json");
|
|
1603
|
+
await next();
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Lets changes through only from this server's own pages, sent as JSON, which an HTML form on another page cannot do
|
|
1608
|
+
*
|
|
1609
|
+
* @param port the port the server listens on
|
|
1610
|
+
*/
|
|
1611
|
+
function requireBrowserOrigin(port) {
|
|
1612
|
+
return async (context, next) => {
|
|
1613
|
+
if (context.req.method === "GET" || context.req.method === "HEAD") {
|
|
1614
|
+
await next();
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (!ownOrigins(port()).includes(context.req.header("origin") ?? "")) throw new HttpError(403, "forbidden", "Changes must come from this server's own pages");
|
|
1618
|
+
if (!(context.req.header("content-type") ?? "").startsWith("application/json")) throw new HttpError(415, "unsupported-media-type", "Changes must be sent as application/json");
|
|
1619
|
+
await next();
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
function ownOrigins(port) {
|
|
1623
|
+
return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
|
1624
|
+
}
|
|
1625
|
+
function isExpectedToken(authorization, token) {
|
|
1626
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
1627
|
+
const actual = Buffer.from(authorization ?? "");
|
|
1628
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region src/server/http/shellRoutes.ts
|
|
1632
|
+
var assetContentTypes = {
|
|
1633
|
+
".css": "text/css; charset=utf-8",
|
|
1634
|
+
".js": "text/javascript; charset=utf-8"
|
|
1635
|
+
};
|
|
1636
|
+
var contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
|
|
1637
|
+
function registerShellRoutes(app, { webDirectory }) {
|
|
1638
|
+
const serveShell = async (context) => {
|
|
1639
|
+
const html = await readTextFileOrNull(path.join(webDirectory, "index.html"));
|
|
1640
|
+
if (html === null) throw new HttpError(500, "missing-web-app", "This build of markdown-review has no web app");
|
|
1641
|
+
return context.html(html, 200, { "Content-Security-Policy": contentSecurityPolicy });
|
|
1642
|
+
};
|
|
1643
|
+
app.get("/", serveShell);
|
|
1644
|
+
app.get("/document/*", serveShell);
|
|
1645
|
+
app.get("/assets/*", async (context) => {
|
|
1646
|
+
const name = context.req.path.slice(8);
|
|
1647
|
+
if (!isRepositoryRelativePath(name)) throw new HttpError(404, "missing-file", `${name} is not an asset`);
|
|
1648
|
+
let contents;
|
|
1649
|
+
try {
|
|
1650
|
+
contents = await readFile(path.join(webDirectory, "assets", ...name.split("/")));
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
if (isFileNotFound(error)) throw new HttpError(404, "missing-file", `${name} is not an asset`);
|
|
1653
|
+
throw error;
|
|
1654
|
+
}
|
|
1655
|
+
return context.body(new Uint8Array(contents), 200, {
|
|
1656
|
+
"Content-Type": assetContentTypes[path.extname(name)] ?? "application/octet-stream",
|
|
1657
|
+
"X-Content-Type-Options": "nosniff"
|
|
1658
|
+
});
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
//#endregion
|
|
1662
|
+
//#region src/server/http/createApp.ts
|
|
1663
|
+
var statusForStoreError = {
|
|
1664
|
+
"invalid-file": 500,
|
|
1665
|
+
"invalid-input": 400,
|
|
1666
|
+
"invalid-state": 409,
|
|
1667
|
+
"missing-document": 404,
|
|
1668
|
+
"unknown-thread": 404
|
|
1669
|
+
};
|
|
1670
|
+
/**
|
|
1671
|
+
* Builds the server's HTTP app: the agent's API, the browser's API, the event stream, repo files and the app shell
|
|
1672
|
+
*
|
|
1673
|
+
* @param dependencies the store and the server's shared state
|
|
1674
|
+
* @returns the app, ready to be served on 127.0.0.1
|
|
1675
|
+
*/
|
|
1676
|
+
function createApp(dependencies) {
|
|
1677
|
+
const { activity, logger, port, token } = dependencies;
|
|
1678
|
+
const app = new Hono();
|
|
1679
|
+
app.use("*", requireLocalHost(port));
|
|
1680
|
+
app.use("*", refuseOtherOrigins(port));
|
|
1681
|
+
app.use("*", async (_context, next) => {
|
|
1682
|
+
activity.touch();
|
|
1683
|
+
await next();
|
|
1684
|
+
});
|
|
1685
|
+
for (const agentPath of [
|
|
1686
|
+
"/api/agent/*",
|
|
1687
|
+
"/api/inbox",
|
|
1688
|
+
"/api/poll",
|
|
1689
|
+
"/api/shutdown"
|
|
1690
|
+
]) app.use(agentPath, requireAgentToken(token));
|
|
1691
|
+
for (const browserPath of [
|
|
1692
|
+
"/api/threads",
|
|
1693
|
+
"/api/threads/*",
|
|
1694
|
+
"/api/submit"
|
|
1695
|
+
]) app.use(browserPath, requireBrowserOrigin(port));
|
|
1696
|
+
app.onError((error, context) => {
|
|
1697
|
+
if (error instanceof HttpError) return context.json({ error: {
|
|
1698
|
+
message: error.message,
|
|
1699
|
+
reason: error.reason
|
|
1700
|
+
} }, error.status);
|
|
1701
|
+
if (error instanceof StoreError) return context.json({ error: {
|
|
1702
|
+
message: error.message,
|
|
1703
|
+
reason: error.reason
|
|
1704
|
+
} }, statusForStoreError[error.reason]);
|
|
1705
|
+
logger.error(`${context.req.method} ${context.req.path} failed`, error);
|
|
1706
|
+
return context.json({ error: {
|
|
1707
|
+
message: "The server failed to handle the request; see .markdown-review/server.log",
|
|
1708
|
+
reason: "internal"
|
|
1709
|
+
} }, 500);
|
|
1710
|
+
});
|
|
1711
|
+
registerHealthRoutes(app, dependencies);
|
|
1712
|
+
registerAgentRoutes(app, dependencies);
|
|
1713
|
+
registerPollRoutes(app, dependencies);
|
|
1714
|
+
registerBrowserRoutes(app, dependencies);
|
|
1715
|
+
registerEventRoutes(app, dependencies);
|
|
1716
|
+
registerFileRoutes(app, dependencies);
|
|
1717
|
+
registerShellRoutes(app, dependencies);
|
|
1718
|
+
return app;
|
|
1719
|
+
}
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/server/http/RecentDocuments.ts
|
|
1722
|
+
var recentLimit = 10;
|
|
1723
|
+
/**
|
|
1724
|
+
* The docs most recently opened by the agent or shown in a browser, newest first
|
|
1725
|
+
*/
|
|
1726
|
+
var RecentDocuments = class {
|
|
1727
|
+
documents = [];
|
|
1728
|
+
add(document) {
|
|
1729
|
+
this.documents = [document, ...this.documents.filter((existing) => existing !== document)].slice(0, recentLimit);
|
|
1730
|
+
}
|
|
1731
|
+
list() {
|
|
1732
|
+
return [...this.documents];
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
//#endregion
|
|
1736
|
+
//#region src/shared/markdown/fenceLanguage.ts
|
|
1737
|
+
/**
|
|
1738
|
+
* Reads the language of a fenced code block from its info string
|
|
1739
|
+
*
|
|
1740
|
+
* @param info the text after the opening fence, e.g. "ts title=example"
|
|
1741
|
+
* @returns the first word of the info string, or an empty string when there is none
|
|
1742
|
+
*/
|
|
1743
|
+
function fenceLanguage(info) {
|
|
1744
|
+
return info.trim().split(/\s+/)[0] ?? "";
|
|
1745
|
+
}
|
|
1746
|
+
//#endregion
|
|
1747
|
+
//#region src/shared/markdown/findLeafBlocks.ts
|
|
1748
|
+
var leafBlockKinds = {
|
|
1749
|
+
code_block: "codeBlock",
|
|
1750
|
+
fence: "fence",
|
|
1751
|
+
heading_open: "inline",
|
|
1752
|
+
html_block: "htmlBlock",
|
|
1753
|
+
paragraph_open: "inline",
|
|
1754
|
+
tr_open: "tableRow"
|
|
1755
|
+
};
|
|
1756
|
+
/**
|
|
1757
|
+
* Finds the leaf blocks of a parsed doc
|
|
1758
|
+
*
|
|
1759
|
+
* @param tokens the token stream markdown-it parsed from `source`
|
|
1760
|
+
* @param source the markdown source
|
|
1761
|
+
* @returns the leaf blocks in document order
|
|
1762
|
+
*/
|
|
1763
|
+
function findLeafBlocks(tokens, source) {
|
|
1764
|
+
const sourceLines = source.split(/\r\n?|\n/);
|
|
1765
|
+
const leafBlocks = [];
|
|
1766
|
+
tokens.forEach((token, tokenIndex) => {
|
|
1767
|
+
const kind = leafBlockKinds[token.type];
|
|
1768
|
+
if (kind === void 0 || token.map === null) return;
|
|
1769
|
+
const [lineBegin, lineEnd] = token.map;
|
|
1770
|
+
leafBlocks.push({
|
|
1771
|
+
endLine: lastLineWithText(sourceLines, lineBegin, lineEnd),
|
|
1772
|
+
kind,
|
|
1773
|
+
startLine: lineBegin + 1,
|
|
1774
|
+
token,
|
|
1775
|
+
tokenIndex
|
|
1776
|
+
});
|
|
1777
|
+
});
|
|
1778
|
+
return leafBlocks;
|
|
1779
|
+
}
|
|
1780
|
+
function lastLineWithText(sourceLines, lineBegin, lineEnd) {
|
|
1781
|
+
let endLine = lineEnd;
|
|
1782
|
+
while (endLine > lineBegin + 1 && (sourceLines[endLine - 1] ?? "").trim() === "") endLine -= 1;
|
|
1783
|
+
return endLine;
|
|
1784
|
+
}
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region src/shared/markdown/tokenAt.ts
|
|
1787
|
+
/**
|
|
1788
|
+
* Reads a token that markdown-it guarantees is present, such as the token a render rule was called for
|
|
1789
|
+
*
|
|
1790
|
+
* @param tokens the token stream
|
|
1791
|
+
* @param index the token's position in the stream
|
|
1792
|
+
* @returns the token
|
|
1793
|
+
* @throws RangeError when no token is at that position
|
|
1794
|
+
*/
|
|
1795
|
+
function tokenAt(tokens, index) {
|
|
1796
|
+
const token = tokens[index];
|
|
1797
|
+
if (token === void 0) throw new RangeError(`No markdown token at index ${index} of ${tokens.length}`);
|
|
1798
|
+
return token;
|
|
1799
|
+
}
|
|
1800
|
+
//#endregion
|
|
1801
|
+
//#region src/shared/markdown/withoutFinalNewline.ts
|
|
1802
|
+
/**
|
|
1803
|
+
* Removes one trailing newline, as markdown-it leaves on the content of code and HTML blocks
|
|
1804
|
+
*
|
|
1805
|
+
* @param content the block content
|
|
1806
|
+
* @returns the content without its final newline
|
|
1807
|
+
*/
|
|
1808
|
+
function withoutFinalNewline(content) {
|
|
1809
|
+
return content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
1810
|
+
}
|
|
1811
|
+
//#endregion
|
|
1812
|
+
//#region src/shared/markdown/createMarkdownIt.ts
|
|
1813
|
+
/**
|
|
1814
|
+
* Creates the markdown-it instance the server and the browser share, so both find the same blocks
|
|
1815
|
+
*
|
|
1816
|
+
* @param options how rendering highlights fenced code; parsing does not use it
|
|
1817
|
+
* @returns an instance whose rendered leaf block elements carry `data-md-block`, `data-md-start` and `data-md-end`
|
|
1818
|
+
*/
|
|
1819
|
+
function createMarkdownIt({ highlight } = {}) {
|
|
1820
|
+
const markdown = new markdownIt({ html: true }).use(tasklist);
|
|
1821
|
+
const escapeHtml = markdown.utils.escapeHtml;
|
|
1822
|
+
const renderFence = (tokens, index, _options, _environment, renderer) => {
|
|
1823
|
+
const token = tokenAt(tokens, index);
|
|
1824
|
+
const code = withoutFinalNewline(token.content);
|
|
1825
|
+
const language = fenceLanguage(token.info);
|
|
1826
|
+
const languageClass = language === "" ? "" : ` class="language-${escapeHtml(language)}"`;
|
|
1827
|
+
const highlighted = highlight?.(code, language) ?? `<pre><code${languageClass}>${escapeHtml(code)}</code></pre>`;
|
|
1828
|
+
return `<div${renderer.renderAttrs(token)}>${highlighted}</div>\n`;
|
|
1829
|
+
};
|
|
1830
|
+
const renderCodeBlock = (tokens, index, _options, _environment, renderer) => {
|
|
1831
|
+
const token = tokenAt(tokens, index);
|
|
1832
|
+
return `<div${renderer.renderAttrs(token)}><pre><code>${escapeHtml(withoutFinalNewline(token.content))}</code></pre></div>\n`;
|
|
1833
|
+
};
|
|
1834
|
+
markdown.core.ruler.push("tag_leaf_blocks", tagLeafBlocks);
|
|
1835
|
+
markdown.renderer.rules.paragraph_open = renderParagraphOpen;
|
|
1836
|
+
markdown.renderer.rules.paragraph_close = renderParagraphClose;
|
|
1837
|
+
markdown.renderer.rules.fence = renderFence;
|
|
1838
|
+
markdown.renderer.rules.code_block = renderCodeBlock;
|
|
1839
|
+
markdown.renderer.rules.html_block = renderHtmlBlock;
|
|
1840
|
+
return markdown;
|
|
1841
|
+
}
|
|
1842
|
+
function tagLeafBlocks(state) {
|
|
1843
|
+
findLeafBlocks(state.tokens, state.src).forEach(({ endLine, startLine, token }, blockIndex) => {
|
|
1844
|
+
token.attrSet("data-md-block", blockIndex);
|
|
1845
|
+
token.attrSet("data-md-start", startLine);
|
|
1846
|
+
token.attrSet("data-md-end", endLine);
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
var renderParagraphOpen = (tokens, index, options, _environment, renderer) => {
|
|
1850
|
+
const token = tokenAt(tokens, index);
|
|
1851
|
+
return token.hidden ? `<span${renderer.renderAttrs(token)}>` : renderer.renderToken(tokens, index, options);
|
|
1852
|
+
};
|
|
1853
|
+
var renderParagraphClose = (tokens, index, options, _environment, renderer) => tokenAt(tokens, index).hidden ? "</span>" : renderer.renderToken(tokens, index, options);
|
|
1854
|
+
var renderHtmlBlock = (tokens, index, _options, _environment, renderer) => {
|
|
1855
|
+
const token = tokenAt(tokens, index);
|
|
1856
|
+
return `<div${renderer.renderAttrs(token)}>${token.content}</div>\n`;
|
|
1857
|
+
};
|
|
1858
|
+
//#endregion
|
|
1859
|
+
//#region src/shared/markdown/hasStructuralHtml.ts
|
|
1860
|
+
var phrasingTagNames = /* @__PURE__ */ new Set([
|
|
1861
|
+
"a",
|
|
1862
|
+
"abbr",
|
|
1863
|
+
"b",
|
|
1864
|
+
"bdi",
|
|
1865
|
+
"bdo",
|
|
1866
|
+
"br",
|
|
1867
|
+
"cite",
|
|
1868
|
+
"code",
|
|
1869
|
+
"data",
|
|
1870
|
+
"del",
|
|
1871
|
+
"dfn",
|
|
1872
|
+
"em",
|
|
1873
|
+
"i",
|
|
1874
|
+
"img",
|
|
1875
|
+
"ins",
|
|
1876
|
+
"kbd",
|
|
1877
|
+
"mark",
|
|
1878
|
+
"q",
|
|
1879
|
+
"s",
|
|
1880
|
+
"samp",
|
|
1881
|
+
"small",
|
|
1882
|
+
"span",
|
|
1883
|
+
"strong",
|
|
1884
|
+
"sub",
|
|
1885
|
+
"sup",
|
|
1886
|
+
"time",
|
|
1887
|
+
"u",
|
|
1888
|
+
"var",
|
|
1889
|
+
"wbr"
|
|
1890
|
+
]);
|
|
1891
|
+
/**
|
|
1892
|
+
* Tells whether a paragraph or heading holds inline HTML that the browser may move out of the block, or that the
|
|
1893
|
+
* sanitizer may remove with its content, so the rendered text can differ from the canonical text
|
|
1894
|
+
*
|
|
1895
|
+
* @param children the children of the block's inline token
|
|
1896
|
+
* @returns true when an inline HTML tag is not one of the phrasing elements such as `span`, `b` or `a`
|
|
1897
|
+
*/
|
|
1898
|
+
function hasStructuralHtml(children) {
|
|
1899
|
+
return children.some((child) => {
|
|
1900
|
+
if (child.type !== "html_inline") return false;
|
|
1901
|
+
const tagName = /^<\/?([a-z][a-z0-9-]*)/i.exec(child.content)?.[1];
|
|
1902
|
+
return tagName !== void 0 && !phrasingTagNames.has(tagName.toLowerCase());
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
//#endregion
|
|
1906
|
+
//#region src/shared/markdown/inlineText.ts
|
|
1907
|
+
var textTokenTypes = /* @__PURE__ */ new Set([
|
|
1908
|
+
"text",
|
|
1909
|
+
"text_special",
|
|
1910
|
+
"code_inline"
|
|
1911
|
+
]);
|
|
1912
|
+
var lineBreakTokenTypes = /* @__PURE__ */ new Set(["softbreak", "hardbreak"]);
|
|
1913
|
+
/**
|
|
1914
|
+
* Reads the canonical text of a paragraph, heading or table cell
|
|
1915
|
+
*
|
|
1916
|
+
* @param children the children of the block's inline token
|
|
1917
|
+
* @returns the text as the browser shows it, with each line break as "\n"; images and HTML tags contribute nothing
|
|
1918
|
+
*/
|
|
1919
|
+
function inlineText(children) {
|
|
1920
|
+
let text = "";
|
|
1921
|
+
const lineStartOffsets = [];
|
|
1922
|
+
for (const child of children) if (textTokenTypes.has(child.type)) text += child.content;
|
|
1923
|
+
else if (lineBreakTokenTypes.has(child.type)) {
|
|
1924
|
+
text += "\n";
|
|
1925
|
+
lineStartOffsets.push(text.length);
|
|
1926
|
+
}
|
|
1927
|
+
return {
|
|
1928
|
+
lineStartOffsets,
|
|
1929
|
+
text
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
//#endregion
|
|
1933
|
+
//#region src/shared/markdown/parseBlocks.ts
|
|
1934
|
+
var markdown = createMarkdownIt();
|
|
1935
|
+
/**
|
|
1936
|
+
* Splits a doc into the leaf blocks comments anchor to
|
|
1937
|
+
*
|
|
1938
|
+
* @param source the markdown source
|
|
1939
|
+
* @returns the leaf blocks in document order, each with its canonical text; a block with no visible text, such as an
|
|
1940
|
+
* image on its own, takes whole-block comments only and its text is its source
|
|
1941
|
+
*/
|
|
1942
|
+
function parseBlocks(source) {
|
|
1943
|
+
const tokens = markdown.parse(source, {});
|
|
1944
|
+
const sourceLines = source.split(/\r\n?|\n/);
|
|
1945
|
+
return findLeafBlocks(tokens, source).map((leafBlock) => {
|
|
1946
|
+
const block = toMarkdownBlock(tokens, leafBlock);
|
|
1947
|
+
return block.text === "" ? toSourceTextBlock(block, sourceLines) : block;
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
function toSourceTextBlock({ endLine, startLine }, sourceLines) {
|
|
1951
|
+
const text = sourceLines.slice(startLine - 1, endLine).join("\n");
|
|
1952
|
+
return {
|
|
1953
|
+
endLine,
|
|
1954
|
+
exactLines: true,
|
|
1955
|
+
lineOffsets: codeLineOffsets(text, startLine, endLine),
|
|
1956
|
+
startLine,
|
|
1957
|
+
text,
|
|
1958
|
+
wholeBlockOnly: true
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
function toMarkdownBlock(tokens, leafBlock) {
|
|
1962
|
+
const { endLine, kind, startLine, token, tokenIndex } = leafBlock;
|
|
1963
|
+
switch (kind) {
|
|
1964
|
+
case "inline": {
|
|
1965
|
+
const children = tokenAt(tokens, tokenIndex + 1).children ?? [];
|
|
1966
|
+
const { lineStartOffsets, text } = inlineText(children);
|
|
1967
|
+
const lineOffsets = [0, ...lineStartOffsets].map((offset, index) => ({
|
|
1968
|
+
line: startLine + index,
|
|
1969
|
+
offset
|
|
1970
|
+
}));
|
|
1971
|
+
const isSetextHeading = token.markup === "=" || token.markup === "-";
|
|
1972
|
+
const textLineCount = endLine - startLine + (isSetextHeading ? 0 : 1);
|
|
1973
|
+
return {
|
|
1974
|
+
endLine,
|
|
1975
|
+
exactLines: lineOffsets.length === textLineCount,
|
|
1976
|
+
lineOffsets,
|
|
1977
|
+
startLine,
|
|
1978
|
+
text,
|
|
1979
|
+
wholeBlockOnly: hasStructuralHtml(children)
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
case "tableRow": {
|
|
1983
|
+
const text = tableRowCells(tokens, tokenIndex).join(" ");
|
|
1984
|
+
return {
|
|
1985
|
+
endLine,
|
|
1986
|
+
exactLines: true,
|
|
1987
|
+
lineOffsets: [{
|
|
1988
|
+
line: startLine,
|
|
1989
|
+
offset: 0
|
|
1990
|
+
}],
|
|
1991
|
+
startLine,
|
|
1992
|
+
text,
|
|
1993
|
+
wholeBlockOnly: false
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
case "fence": {
|
|
1997
|
+
const text = withoutFinalNewline(token.content);
|
|
1998
|
+
const wholeBlockOnly = fenceLanguage(token.info) === "mermaid";
|
|
1999
|
+
return {
|
|
2000
|
+
endLine,
|
|
2001
|
+
exactLines: true,
|
|
2002
|
+
lineOffsets: codeLineOffsets(text, startLine + 1, endLine),
|
|
2003
|
+
startLine,
|
|
2004
|
+
text,
|
|
2005
|
+
wholeBlockOnly
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
case "codeBlock":
|
|
2009
|
+
case "htmlBlock": {
|
|
2010
|
+
const text = withoutFinalNewline(token.content);
|
|
2011
|
+
const wholeBlockOnly = kind === "htmlBlock";
|
|
2012
|
+
return {
|
|
2013
|
+
endLine,
|
|
2014
|
+
exactLines: true,
|
|
2015
|
+
lineOffsets: codeLineOffsets(text, startLine, endLine),
|
|
2016
|
+
startLine,
|
|
2017
|
+
text,
|
|
2018
|
+
wholeBlockOnly
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
function tableRowCells(tokens, rowTokenIndex) {
|
|
2024
|
+
const cells = [];
|
|
2025
|
+
for (let index = rowTokenIndex + 1; index < tokens.length; index++) {
|
|
2026
|
+
const token = tokenAt(tokens, index);
|
|
2027
|
+
if (token.type === "tr_close") break;
|
|
2028
|
+
if (token.type === "inline") cells.push(inlineText(token.children ?? []).text);
|
|
2029
|
+
}
|
|
2030
|
+
return cells;
|
|
2031
|
+
}
|
|
2032
|
+
function codeLineOffsets(text, firstLine, lastLine) {
|
|
2033
|
+
if (text === "") return [];
|
|
2034
|
+
const lineOffsets = [{
|
|
2035
|
+
line: firstLine,
|
|
2036
|
+
offset: 0
|
|
2037
|
+
}];
|
|
2038
|
+
for (let offset = text.indexOf("\n"); offset !== -1; offset = text.indexOf("\n", offset + 1)) lineOffsets.push({
|
|
2039
|
+
line: firstLine + lineOffsets.length,
|
|
2040
|
+
offset: offset + 1
|
|
2041
|
+
});
|
|
2042
|
+
return lineOffsets.filter(({ line }) => line <= lastLine);
|
|
2043
|
+
}
|
|
2044
|
+
//#endregion
|
|
2045
|
+
//#region src/shared/markdown/createDocumentText.ts
|
|
2046
|
+
/**
|
|
2047
|
+
* Builds the canonical text of a doc
|
|
2048
|
+
*
|
|
2049
|
+
* @param source the markdown source
|
|
2050
|
+
* @returns the doc's blocks, their joined text and where each block starts in it
|
|
2051
|
+
*/
|
|
2052
|
+
function createDocumentText(source) {
|
|
2053
|
+
const blocks = parseBlocks(source);
|
|
2054
|
+
const blockStartOffsets = [];
|
|
2055
|
+
let offset = 0;
|
|
2056
|
+
for (const block of blocks) {
|
|
2057
|
+
blockStartOffsets.push(offset);
|
|
2058
|
+
offset += block.text.length + 1;
|
|
2059
|
+
}
|
|
2060
|
+
return {
|
|
2061
|
+
blockStartOffsets,
|
|
2062
|
+
blocks,
|
|
2063
|
+
text: blocks.map((block) => block.text).join("\n")
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
//#endregion
|
|
2067
|
+
//#region src/shared/markdown/blockAtOffset.ts
|
|
2068
|
+
/**
|
|
2069
|
+
* Finds the block holding a character of a doc's canonical text
|
|
2070
|
+
*
|
|
2071
|
+
* @param documentText the doc's canonical text
|
|
2072
|
+
* @param offset a position in the canonical text; the "\n" between two blocks belongs to the earlier block
|
|
2073
|
+
* @returns the block and the position within its text, clamped to the text's length, or null when the doc has no
|
|
2074
|
+
* blocks
|
|
2075
|
+
*/
|
|
2076
|
+
function blockAtOffset(documentText, offset) {
|
|
2077
|
+
const { blocks, blockStartOffsets } = documentText;
|
|
2078
|
+
let blockIndex = blocks.length - 1;
|
|
2079
|
+
while (blockIndex > 0 && (blockStartOffsets[blockIndex] ?? 0) > offset) blockIndex -= 1;
|
|
2080
|
+
const block = blocks[blockIndex];
|
|
2081
|
+
if (block === void 0) return null;
|
|
2082
|
+
return {
|
|
2083
|
+
block,
|
|
2084
|
+
offsetInBlock: Math.min(Math.max(0, offset - (blockStartOffsets[blockIndex] ?? 0)), block.text.length)
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
//#endregion
|
|
2088
|
+
//#region src/shared/markdown/lineAtOffset.ts
|
|
2089
|
+
/**
|
|
2090
|
+
* Finds the source line holding a character of a doc's canonical text
|
|
2091
|
+
*
|
|
2092
|
+
* @param documentText the doc's canonical text
|
|
2093
|
+
* @param offset a position in the canonical text; the "\n" between two blocks belongs to the earlier block
|
|
2094
|
+
* @returns the 1-based source line, or 1 when the doc has no blocks; never later than the true line
|
|
2095
|
+
*/
|
|
2096
|
+
function lineAtOffset(documentText, offset) {
|
|
2097
|
+
const position = blockAtOffset(documentText, offset);
|
|
2098
|
+
if (position === null) return 1;
|
|
2099
|
+
const { block, offsetInBlock } = position;
|
|
2100
|
+
return block.lineOffsets.findLast((lineOffset) => lineOffset.offset <= offsetInBlock)?.line ?? block.startLine;
|
|
2101
|
+
}
|
|
2102
|
+
//#endregion
|
|
2103
|
+
//#region src/shared/markdown/linesForRange.ts
|
|
2104
|
+
/**
|
|
2105
|
+
* Finds the source lines holding a range of a doc's canonical text
|
|
2106
|
+
*
|
|
2107
|
+
* @param documentText the doc's canonical text
|
|
2108
|
+
* @param startOffset where the range starts
|
|
2109
|
+
* @param endOffset where the range ends, exclusive
|
|
2110
|
+
* @returns the lines holding the first and last characters of the range; when a block's lines are not exact, the range
|
|
2111
|
+
* runs to the end of that block, so it always contains the text
|
|
2112
|
+
*/
|
|
2113
|
+
function linesForRange(documentText, startOffset, endOffset) {
|
|
2114
|
+
const lastOffset = Math.max(startOffset, endOffset - 1);
|
|
2115
|
+
const lastBlock = blockAtOffset(documentText, lastOffset)?.block;
|
|
2116
|
+
return {
|
|
2117
|
+
endLine: lastBlock?.exactLines === false ? lastBlock.endLine : lineAtOffset(documentText, lastOffset),
|
|
2118
|
+
startLine: lineAtOffset(documentText, startOffset)
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
//#endregion
|
|
2122
|
+
//#region src/server/anchoring/reanchorPassage.ts
|
|
2123
|
+
var maxErrorRatio = .2;
|
|
2124
|
+
/**
|
|
2125
|
+
* Moves a passage anchor to where its text is in the current version of its doc
|
|
2126
|
+
*
|
|
2127
|
+
* @param anchor the anchor as last computed
|
|
2128
|
+
* @param documentText the canonical text of the doc as it is now
|
|
2129
|
+
* @returns the anchor on the best exact or approximate match, or the anchor marked outdated, with its old position
|
|
2130
|
+
* clamped to the doc, when nothing matches
|
|
2131
|
+
*/
|
|
2132
|
+
function reanchorPassage(anchor, documentText) {
|
|
2133
|
+
const candidates = exactCandidates(anchor.anchoredText, documentText.text);
|
|
2134
|
+
const best = chooseCandidate(candidates.length > 0 ? candidates : approximateCandidates(anchor.anchoredText, documentText.text), anchor, documentText.text);
|
|
2135
|
+
if (best === void 0) return {
|
|
2136
|
+
...anchor,
|
|
2137
|
+
endOffset: Math.min(anchor.endOffset, documentText.text.length),
|
|
2138
|
+
outdated: true,
|
|
2139
|
+
startOffset: Math.min(anchor.startOffset, documentText.text.length)
|
|
2140
|
+
};
|
|
2141
|
+
const { end, start } = best;
|
|
2142
|
+
return {
|
|
2143
|
+
...anchor,
|
|
2144
|
+
...linesForRange(documentText, start, end),
|
|
2145
|
+
anchoredText: documentText.text.slice(start, end),
|
|
2146
|
+
endOffset: end,
|
|
2147
|
+
outdated: false,
|
|
2148
|
+
prefix: documentText.text.slice(Math.max(0, start - 32), start),
|
|
2149
|
+
startOffset: start,
|
|
2150
|
+
suffix: documentText.text.slice(end, end + 32)
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
function exactCandidates(pattern, text) {
|
|
2154
|
+
if (pattern === "") return [];
|
|
2155
|
+
const candidates = [];
|
|
2156
|
+
for (let start = text.indexOf(pattern); start !== -1; start = text.indexOf(pattern, start + 1)) candidates.push({
|
|
2157
|
+
end: start + pattern.length,
|
|
2158
|
+
errors: 0,
|
|
2159
|
+
start
|
|
2160
|
+
});
|
|
2161
|
+
return candidates;
|
|
2162
|
+
}
|
|
2163
|
+
function approximateCandidates(pattern, text) {
|
|
2164
|
+
const maxErrors = Math.floor(maxErrorRatio * pattern.length);
|
|
2165
|
+
return maxErrors === 0 ? [] : search(text, pattern, maxErrors);
|
|
2166
|
+
}
|
|
2167
|
+
function chooseCandidate(candidates, anchor, text) {
|
|
2168
|
+
const ranked = candidates.map((candidate) => ({
|
|
2169
|
+
candidate,
|
|
2170
|
+
contextScore: contextScore(candidate, anchor, text),
|
|
2171
|
+
distance: Math.abs(candidate.start - anchor.startOffset)
|
|
2172
|
+
}));
|
|
2173
|
+
ranked.sort((left, right) => left.candidate.errors - right.candidate.errors || right.contextScore - left.contextScore || left.distance - right.distance);
|
|
2174
|
+
return ranked[0]?.candidate;
|
|
2175
|
+
}
|
|
2176
|
+
function contextScore({ end, start }, { prefix, suffix }, text) {
|
|
2177
|
+
const textBefore = text.slice(Math.max(0, start - prefix.length), start);
|
|
2178
|
+
const textAfter = text.slice(end, end + suffix.length);
|
|
2179
|
+
return commonSuffixLength(prefix, textBefore) + commonPrefixLength(suffix, textAfter);
|
|
2180
|
+
}
|
|
2181
|
+
function commonPrefixLength(left, right) {
|
|
2182
|
+
let length = 0;
|
|
2183
|
+
while (length < left.length && left[length] === right[length]) length += 1;
|
|
2184
|
+
return length;
|
|
2185
|
+
}
|
|
2186
|
+
function commonSuffixLength(left, right) {
|
|
2187
|
+
let length = 0;
|
|
2188
|
+
while (length < left.length && left[left.length - 1 - length] === right[right.length - 1 - length]) length += 1;
|
|
2189
|
+
return length;
|
|
2190
|
+
}
|
|
2191
|
+
//#endregion
|
|
2192
|
+
//#region src/server/anchoring/anchorNewPassage.ts
|
|
2193
|
+
/**
|
|
2194
|
+
* Anchors a passage the user has just selected
|
|
2195
|
+
*
|
|
2196
|
+
* @param newAnchor the selection, as offsets and text in the doc the browser rendered
|
|
2197
|
+
* @param source the doc's current source
|
|
2198
|
+
* @param renderedHash the hash of the source the browser rendered, if it sent one
|
|
2199
|
+
* @returns the anchor at the selected offsets when the browser rendered the current source, otherwise the anchor
|
|
2200
|
+
* re-anchored against the current source
|
|
2201
|
+
*/
|
|
2202
|
+
function anchorNewPassage(newAnchor, source, renderedHash) {
|
|
2203
|
+
const documentText = createDocumentText(source);
|
|
2204
|
+
const { endOffset, quote, startOffset } = newAnchor;
|
|
2205
|
+
const anchor = {
|
|
2206
|
+
...newAnchor,
|
|
2207
|
+
...linesForRange(documentText, startOffset, endOffset),
|
|
2208
|
+
anchoredText: quote,
|
|
2209
|
+
outdated: false
|
|
2210
|
+
};
|
|
2211
|
+
return renderedHash === hashSource(source) && documentText.text.slice(startOffset, endOffset) === quote ? anchor : reanchorPassage(anchor, documentText);
|
|
2212
|
+
}
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region src/server/anchoring/reanchorDocumentThreads.ts
|
|
2215
|
+
/**
|
|
2216
|
+
* Brings the passage threads of a doc up to date with its source
|
|
2217
|
+
*
|
|
2218
|
+
* @param threads the doc's threads, of any status
|
|
2219
|
+
* @param source the doc's current source, or null when the doc no longer exists
|
|
2220
|
+
* @returns the threads with every passage anchor moved to its text, or marked outdated; other threads unchanged
|
|
2221
|
+
*/
|
|
2222
|
+
function reanchorDocumentThreads(threads, source) {
|
|
2223
|
+
const documentText = source === null ? null : createDocumentText(source);
|
|
2224
|
+
return threads.map((thread) => {
|
|
2225
|
+
if (thread.anchor.kind !== "passage") return thread;
|
|
2226
|
+
const anchor = documentText === null ? {
|
|
2227
|
+
...thread.anchor,
|
|
2228
|
+
outdated: true
|
|
2229
|
+
} : reanchorPassage(thread.anchor, documentText);
|
|
2230
|
+
return {
|
|
2231
|
+
...thread,
|
|
2232
|
+
anchor
|
|
2233
|
+
};
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
//#endregion
|
|
2237
|
+
//#region src/server/store/emptyReviewFile.ts
|
|
2238
|
+
var emptyReviewFile = {
|
|
2239
|
+
approvedAt: null,
|
|
2240
|
+
requestedAt: null,
|
|
2241
|
+
threads: [],
|
|
2242
|
+
version: 1
|
|
2243
|
+
};
|
|
2244
|
+
//#endregion
|
|
2245
|
+
//#region src/server/store/needsAgent.ts
|
|
2246
|
+
/**
|
|
2247
|
+
* Tells whether a thread is waiting for the agent
|
|
2248
|
+
*
|
|
2249
|
+
* @param thread the thread
|
|
2250
|
+
* @returns true when the thread is open and the user had the last word
|
|
2251
|
+
*/
|
|
2252
|
+
function needsAgent(thread) {
|
|
2253
|
+
return thread.status === "open" && thread.messages.at(-1)?.author === "user";
|
|
2254
|
+
}
|
|
2255
|
+
//#endregion
|
|
2256
|
+
//#region src/server/store/OperationQueue.ts
|
|
2257
|
+
/**
|
|
2258
|
+
* Serialises asynchronous operations so they run one at a time in call order
|
|
2259
|
+
*/
|
|
2260
|
+
var OperationQueue = class {
|
|
2261
|
+
queue = Promise.resolve();
|
|
2262
|
+
/**
|
|
2263
|
+
* Runs a task after all previous tasks in this queue have settled
|
|
2264
|
+
*
|
|
2265
|
+
* @param task the asynchronous operation to run
|
|
2266
|
+
* @returns a promise that resolves to the task's result
|
|
2267
|
+
*/
|
|
2268
|
+
enqueue(task) {
|
|
2269
|
+
const result = this.queue.then(task);
|
|
2270
|
+
this.queue = result.then(() => void 0, () => void 0);
|
|
2271
|
+
return result;
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
//#endregion
|
|
2275
|
+
//#region src/server/store/renumberDuplicateThreads.ts
|
|
2276
|
+
/**
|
|
2277
|
+
* Gives every thread a unique ID after a merge has brought in threads that share one
|
|
2278
|
+
*
|
|
2279
|
+
* @param files the threads of every store file
|
|
2280
|
+
* @returns the files with the later-created thread of each clash moved to the next free ID, and what was moved
|
|
2281
|
+
*/
|
|
2282
|
+
function renumberDuplicateThreads(files) {
|
|
2283
|
+
const entries = files.flatMap((file, fileIndex) => file.threads.map((thread) => ({
|
|
2284
|
+
fileIndex,
|
|
2285
|
+
thread
|
|
2286
|
+
})));
|
|
2287
|
+
entries.sort((left, right) => left.thread.id - right.thread.id || Date.parse(left.thread.createdAt) - Date.parse(right.thread.createdAt) || left.fileIndex - right.fileIndex);
|
|
2288
|
+
let nextId = Math.max(0, ...entries.map(({ thread }) => thread.id)) + 1;
|
|
2289
|
+
const newIds = /* @__PURE__ */ new Map();
|
|
2290
|
+
entries.forEach(({ thread }, index) => {
|
|
2291
|
+
if (entries[index - 1]?.thread.id === thread.id) {
|
|
2292
|
+
newIds.set(thread, nextId);
|
|
2293
|
+
nextId += 1;
|
|
2294
|
+
}
|
|
2295
|
+
});
|
|
2296
|
+
const renumberings = [];
|
|
2297
|
+
return {
|
|
2298
|
+
files: files.map((file) => ({
|
|
2299
|
+
...file,
|
|
2300
|
+
threads: file.threads.map((thread) => {
|
|
2301
|
+
const to = newIds.get(thread);
|
|
2302
|
+
if (to === void 0) return thread;
|
|
2303
|
+
renumberings.push({
|
|
2304
|
+
document: file.document,
|
|
2305
|
+
from: thread.id,
|
|
2306
|
+
to
|
|
2307
|
+
});
|
|
2308
|
+
return {
|
|
2309
|
+
...thread,
|
|
2310
|
+
id: to
|
|
2311
|
+
};
|
|
2312
|
+
})
|
|
2313
|
+
})),
|
|
2314
|
+
renumberings
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
//#endregion
|
|
2318
|
+
//#region src/shared/review/documentThreadsFileSchema.ts
|
|
2319
|
+
var documentThreadsFileSchema = z.strictObject({
|
|
2320
|
+
document: documentPathSchema,
|
|
2321
|
+
sourceHash: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
|
|
2322
|
+
threads: z.array(threadSchema),
|
|
2323
|
+
version: z.literal(1)
|
|
2324
|
+
}).refine((file) => file.threads.every((thread) => thread.anchor.kind !== "review" && thread.anchor.document === file.document), { message: "must hold only threads anchored to its own doc" });
|
|
2325
|
+
//#endregion
|
|
2326
|
+
//#region src/shared/review/reviewFileSchema.ts
|
|
2327
|
+
var reviewFileSchema = z.strictObject({
|
|
2328
|
+
approvedAt: z.iso.datetime().nullable(),
|
|
2329
|
+
requestedAt: z.iso.datetime().nullable(),
|
|
2330
|
+
threads: z.array(threadSchema),
|
|
2331
|
+
version: z.literal(1)
|
|
2332
|
+
}).refine((file) => file.threads.every((thread) => thread.anchor.kind === "review"), { message: "must hold only threads on the whole review" });
|
|
2333
|
+
//#endregion
|
|
2334
|
+
//#region src/server/files/writeTextFileUnlessExists.ts
|
|
2335
|
+
/**
|
|
2336
|
+
* Creates a UTF-8 text file, leaving any file already at that path alone
|
|
2337
|
+
*
|
|
2338
|
+
* @param filePath the file to create
|
|
2339
|
+
* @param contents the text to write
|
|
2340
|
+
* @returns true when the file was created; false when a file already existed, so nothing was written
|
|
2341
|
+
* @throws the file system error for any other failure
|
|
2342
|
+
*/
|
|
2343
|
+
async function writeTextFileUnlessExists(filePath, contents) {
|
|
2344
|
+
try {
|
|
2345
|
+
await writeFile(filePath, contents, {
|
|
2346
|
+
encoding: "utf8",
|
|
2347
|
+
flag: "wx"
|
|
2348
|
+
});
|
|
2349
|
+
return true;
|
|
2350
|
+
} catch (error) {
|
|
2351
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") return false;
|
|
2352
|
+
throw error;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
//#endregion
|
|
2356
|
+
//#region src/server/store/StoreFiles.ts
|
|
2357
|
+
/**
|
|
2358
|
+
* Reads and writes the files of one root's `.markdown-review/` directory and the docs they refer to
|
|
2359
|
+
*/
|
|
2360
|
+
var StoreFiles = class {
|
|
2361
|
+
root;
|
|
2362
|
+
constructor(root) {
|
|
2363
|
+
this.root = root;
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Creates the store directory, and a `.gitignore` that keeps it out of git unless the user has written their own
|
|
2367
|
+
*/
|
|
2368
|
+
async ensureStoreDirectory() {
|
|
2369
|
+
await mkdir(storeDirectory(this.root), { recursive: true });
|
|
2370
|
+
await writeTextFileUnlessExists(path.join(storeDirectory(this.root), ".gitignore"), "*\n");
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* @returns the review file, or an empty one when it does not exist yet
|
|
2374
|
+
*/
|
|
2375
|
+
async readReviewFile() {
|
|
2376
|
+
const result = await readStoreFile(reviewFilePath(this.root), reviewFileSchema);
|
|
2377
|
+
return result.kind === "valid" ? {
|
|
2378
|
+
kind: "valid",
|
|
2379
|
+
value: result.value ?? emptyReviewFile
|
|
2380
|
+
} : result;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* @throws StoreError "invalid-input" when the file would not pass validation when read back
|
|
2384
|
+
*/
|
|
2385
|
+
async writeReviewFile(file) {
|
|
2386
|
+
await writeJsonAtomically(reviewFilePath(this.root), parseStoreInput(reviewFileSchema, file, "The review file"));
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* @returns the repo-relative paths of the docs that have a threads file
|
|
2390
|
+
*/
|
|
2391
|
+
async listDocuments() {
|
|
2392
|
+
let entries;
|
|
2393
|
+
try {
|
|
2394
|
+
entries = await readdir(documentsDirectory(this.root), { recursive: true });
|
|
2395
|
+
} catch (error) {
|
|
2396
|
+
if (isFileNotFound(error)) return [];
|
|
2397
|
+
throw error;
|
|
2398
|
+
}
|
|
2399
|
+
return entries.filter((entry) => entry.endsWith(".json")).map((entry) => entry.slice(0, -5).split(path.sep).join("/")).sort();
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* @param document a repo-relative POSIX path
|
|
2403
|
+
* @returns the doc's threads file as stored, or null when the doc has none
|
|
2404
|
+
*/
|
|
2405
|
+
readDocumentFile(document) {
|
|
2406
|
+
return readStoreFile(documentThreadsFilePath(this.root, document), documentThreadsFileSchema);
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* @throws StoreError "invalid-input" when the file would not pass validation when read back
|
|
2410
|
+
*/
|
|
2411
|
+
async writeDocumentFile(file) {
|
|
2412
|
+
const valid = parseStoreInput(documentThreadsFileSchema, file, `The threads file for ${file.document}`);
|
|
2413
|
+
await writeJsonAtomically(documentThreadsFilePath(this.root, valid.document), valid);
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* @param document a repo-relative POSIX path
|
|
2417
|
+
* @returns the doc's markdown source, or null when it does not exist
|
|
2418
|
+
*/
|
|
2419
|
+
readDocumentSource(document) {
|
|
2420
|
+
return readTextFileOrNull(documentSourcePath(this.root, document));
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
//#endregion
|
|
2424
|
+
//#region src/server/store/threadTransitions.ts
|
|
2425
|
+
/**
|
|
2426
|
+
* Starts a thread holding the user's comment as a draft
|
|
2427
|
+
*
|
|
2428
|
+
* @param id the new thread's ID
|
|
2429
|
+
* @param anchor what the comment is on
|
|
2430
|
+
* @param body the comment
|
|
2431
|
+
* @param at the current time, ISO 8601
|
|
2432
|
+
* @returns the draft thread
|
|
2433
|
+
*/
|
|
2434
|
+
function createDraftThread(id, anchor, body, at) {
|
|
2435
|
+
return {
|
|
2436
|
+
anchor,
|
|
2437
|
+
createdAt: at,
|
|
2438
|
+
draft: {
|
|
2439
|
+
at,
|
|
2440
|
+
body
|
|
2441
|
+
},
|
|
2442
|
+
id,
|
|
2443
|
+
messages: [],
|
|
2444
|
+
status: "draft",
|
|
2445
|
+
updatedAt: at
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Sets the user's unsubmitted text: the comment on a draft thread, otherwise a reply
|
|
2450
|
+
*
|
|
2451
|
+
* @param thread the thread
|
|
2452
|
+
* @param body the text
|
|
2453
|
+
* @param at the current time, ISO 8601
|
|
2454
|
+
* @returns the thread with its draft replaced
|
|
2455
|
+
*/
|
|
2456
|
+
function writeDraft(thread, body, at) {
|
|
2457
|
+
return {
|
|
2458
|
+
...thread,
|
|
2459
|
+
draft: {
|
|
2460
|
+
at,
|
|
2461
|
+
body
|
|
2462
|
+
},
|
|
2463
|
+
updatedAt: at
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
/**
|
|
2467
|
+
* Discards the user's unsubmitted text
|
|
2468
|
+
*
|
|
2469
|
+
* @param thread the thread
|
|
2470
|
+
* @param at the current time, ISO 8601
|
|
2471
|
+
* @returns the thread without its draft, or null when it was a draft thread, which no longer exists
|
|
2472
|
+
* @throws StoreError "invalid-state" when the thread has no draft
|
|
2473
|
+
*/
|
|
2474
|
+
function deleteDraft(thread, at) {
|
|
2475
|
+
if (thread.draft === void 0) throw new StoreError("invalid-state", `Thread #${thread.id} has no draft to delete`);
|
|
2476
|
+
if (thread.status === "draft") return null;
|
|
2477
|
+
return {
|
|
2478
|
+
...withoutDraft(thread),
|
|
2479
|
+
updatedAt: at
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Resolves a thread at the user's request; a pending draft reply is kept
|
|
2484
|
+
*
|
|
2485
|
+
* @param thread the thread
|
|
2486
|
+
* @param at the current time, ISO 8601
|
|
2487
|
+
* @returns the resolved thread, unchanged when it was already resolved
|
|
2488
|
+
* @throws StoreError "invalid-state" when the thread is a draft
|
|
2489
|
+
*/
|
|
2490
|
+
function resolveAsUser(thread, at) {
|
|
2491
|
+
if (thread.status === "draft") throw new StoreError("invalid-state", `Thread #${thread.id} is a draft; delete it instead of resolving it`);
|
|
2492
|
+
if (thread.status === "resolved") return thread;
|
|
2493
|
+
return {
|
|
2494
|
+
...thread,
|
|
2495
|
+
status: "resolved",
|
|
2496
|
+
updatedAt: at
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Turns the user's draft into a submitted message, opening the thread
|
|
2501
|
+
*
|
|
2502
|
+
* @param thread the thread
|
|
2503
|
+
* @param at the submit time, ISO 8601, which the message is stamped with
|
|
2504
|
+
* @returns the open thread with the draft appended as a user message, or the thread unchanged when it has no draft
|
|
2505
|
+
*/
|
|
2506
|
+
function submitDraft(thread, at) {
|
|
2507
|
+
if (thread.draft === void 0) return thread;
|
|
2508
|
+
return {
|
|
2509
|
+
...withoutDraft(thread),
|
|
2510
|
+
messages: [...thread.messages, {
|
|
2511
|
+
at,
|
|
2512
|
+
author: "user",
|
|
2513
|
+
body: thread.draft.body
|
|
2514
|
+
}],
|
|
2515
|
+
status: "open",
|
|
2516
|
+
updatedAt: at
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Adds the agent's answer to a thread without changing its status
|
|
2521
|
+
*
|
|
2522
|
+
* @param thread a thread that is not a draft
|
|
2523
|
+
* @param body the answer
|
|
2524
|
+
* @param at the current time, ISO 8601
|
|
2525
|
+
* @returns the thread with the agent message appended
|
|
2526
|
+
*/
|
|
2527
|
+
function replyAsAgent(thread, body, at) {
|
|
2528
|
+
return {
|
|
2529
|
+
...thread,
|
|
2530
|
+
messages: [...thread.messages, {
|
|
2531
|
+
at,
|
|
2532
|
+
author: "agent",
|
|
2533
|
+
body
|
|
2534
|
+
}],
|
|
2535
|
+
updatedAt: at
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Resolves a thread at the agent's request
|
|
2540
|
+
*
|
|
2541
|
+
* @param thread a thread that is not a draft
|
|
2542
|
+
* @param body what the agent changed, or null to resolve without a message
|
|
2543
|
+
* @param at the current time, ISO 8601
|
|
2544
|
+
* @returns the resolved thread
|
|
2545
|
+
*/
|
|
2546
|
+
function resolveAsAgent(thread, body, at) {
|
|
2547
|
+
return {
|
|
2548
|
+
...body === null ? thread : replyAsAgent(thread, body, at),
|
|
2549
|
+
status: "resolved",
|
|
2550
|
+
updatedAt: at
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
function withoutDraft(thread) {
|
|
2554
|
+
const next = { ...thread };
|
|
2555
|
+
delete next.draft;
|
|
2556
|
+
return next;
|
|
2557
|
+
}
|
|
2558
|
+
//#endregion
|
|
2559
|
+
//#region src/server/store/toReviewState.ts
|
|
2560
|
+
/**
|
|
2561
|
+
* Works out whether the current review round is approved
|
|
2562
|
+
*
|
|
2563
|
+
* @param reviewFile the review's stored timestamps
|
|
2564
|
+
* @returns the timestamps and whether the approval came after the latest request
|
|
2565
|
+
*/
|
|
2566
|
+
function toReviewState({ approvedAt, requestedAt }) {
|
|
2567
|
+
return {
|
|
2568
|
+
approved: approvedAt !== null && (requestedAt === null || Date.parse(approvedAt) > Date.parse(requestedAt)),
|
|
2569
|
+
approvedAt,
|
|
2570
|
+
requestedAt
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
//#endregion
|
|
2574
|
+
//#region src/server/store/ReviewStore.ts
|
|
2575
|
+
/**
|
|
2576
|
+
* Holds one root's review threads and carries out every change to them, one at a time
|
|
2577
|
+
*/
|
|
2578
|
+
var ReviewStore = class {
|
|
2579
|
+
files;
|
|
2580
|
+
logger;
|
|
2581
|
+
queue = new OperationQueue();
|
|
2582
|
+
/**
|
|
2583
|
+
* @param root the repo root whose `.markdown-review/` directory holds the threads
|
|
2584
|
+
* @param logger where store files that cannot be read, and repairs to the store, are reported
|
|
2585
|
+
*/
|
|
2586
|
+
constructor(root, logger) {
|
|
2587
|
+
this.files = new StoreFiles(root);
|
|
2588
|
+
this.logger = logger;
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Creates the store directory if needed and gives threads that share an ID, after a merge, unique IDs
|
|
2592
|
+
*/
|
|
2593
|
+
initialize() {
|
|
2594
|
+
return this.queue.enqueue(async () => {
|
|
2595
|
+
await this.files.ensureStoreDirectory();
|
|
2596
|
+
const { problems } = await this.readAllStoredThreads();
|
|
2597
|
+
problems.forEach((problem) => this.logger.warn(problem));
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Starts a review round, so an earlier approval no longer counts
|
|
2602
|
+
*
|
|
2603
|
+
* @returns the review state after the request
|
|
2604
|
+
*/
|
|
2605
|
+
requestReview() {
|
|
2606
|
+
return this.queue.enqueue(async () => {
|
|
2607
|
+
const next = {
|
|
2608
|
+
...requireValid(await this.files.readReviewFile()),
|
|
2609
|
+
requestedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2610
|
+
};
|
|
2611
|
+
await this.files.writeReviewFile(next);
|
|
2612
|
+
return toReviewState(next);
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Saves a new comment as a draft thread
|
|
2617
|
+
*
|
|
2618
|
+
* @param newThread the comment and what it is on
|
|
2619
|
+
* @returns the draft thread
|
|
2620
|
+
* @throws StoreError "invalid-input" when the comment is malformed, or "missing-document" when its doc does not exist
|
|
2621
|
+
*/
|
|
2622
|
+
createDraftThread(newThread) {
|
|
2623
|
+
return this.queue.enqueue(async () => {
|
|
2624
|
+
const { anchor, body, renderedHash } = parseStoreInput(newThreadSchema, newThread, "The new thread");
|
|
2625
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2626
|
+
const id = await this.nextThreadId();
|
|
2627
|
+
if (anchor.kind === "review") {
|
|
2628
|
+
const review = requireValid(await this.files.readReviewFile());
|
|
2629
|
+
const thread = createDraftThread(id, anchor, body, at);
|
|
2630
|
+
await this.files.writeReviewFile({
|
|
2631
|
+
...review,
|
|
2632
|
+
threads: [...review.threads, thread]
|
|
2633
|
+
});
|
|
2634
|
+
return thread;
|
|
2635
|
+
}
|
|
2636
|
+
const { file, source } = await this.readCurrentDocumentFile(anchor.document);
|
|
2637
|
+
if (source === null) throw new StoreError("missing-document", `${anchor.document} does not exist`);
|
|
2638
|
+
const thread = createDraftThread(id, anchor.kind === "document" ? anchor : anchorNewPassage(anchor, source, renderedHash), body, at);
|
|
2639
|
+
await this.files.writeDocumentFile({
|
|
2640
|
+
...file,
|
|
2641
|
+
threads: [...file.threads, thread]
|
|
2642
|
+
});
|
|
2643
|
+
return thread;
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* Sets the user's unsubmitted comment or reply on a thread
|
|
2648
|
+
*
|
|
2649
|
+
* @throws StoreError "unknown-thread" when no thread has the ID, or "invalid-input" when the text is blank
|
|
2650
|
+
*/
|
|
2651
|
+
writeDraft(id, body) {
|
|
2652
|
+
return this.changeThread(id, "user", (thread, at) => writeDraft(thread, parseStoreInput(messageBodySchema, body, "The draft"), at));
|
|
2653
|
+
}
|
|
2654
|
+
/**
|
|
2655
|
+
* Discards the user's unsubmitted comment or reply on a thread
|
|
2656
|
+
*
|
|
2657
|
+
* @returns the thread without its draft, or null when it was a draft thread and has been deleted
|
|
2658
|
+
* @throws StoreError "unknown-thread", or "invalid-state" when the thread has no draft
|
|
2659
|
+
*/
|
|
2660
|
+
deleteDraft(id) {
|
|
2661
|
+
return this.changeThread(id, "user", deleteDraft);
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* @throws StoreError "unknown-thread", or "invalid-state" when the thread is a draft
|
|
2665
|
+
*/
|
|
2666
|
+
resolveAsUser(id) {
|
|
2667
|
+
return this.changeThread(id, "user", resolveAsUser);
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Submits every draft in the repo and records the user's verdict on the review round
|
|
2671
|
+
*
|
|
2672
|
+
* @param verdict "approve" ends the round; "request-changes" needs at least one draft
|
|
2673
|
+
* @returns the IDs of the threads that received a submitted message, and the review state after the submit
|
|
2674
|
+
* @throws StoreError "invalid-state" when requesting changes with no drafts
|
|
2675
|
+
*/
|
|
2676
|
+
submit(verdict) {
|
|
2677
|
+
return this.queue.enqueue(async () => {
|
|
2678
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2679
|
+
const review = requireValid(await this.files.readReviewFile());
|
|
2680
|
+
const documentFiles = await this.readDocumentFilesWithDrafts();
|
|
2681
|
+
const submittedThreadIds = [...review.threads, ...documentFiles.flatMap((file) => file.threads)].filter((thread) => thread.draft !== void 0).map((thread) => thread.id).sort((left, right) => left - right);
|
|
2682
|
+
if (verdict === "request-changes" && submittedThreadIds.length === 0) throw new StoreError("invalid-state", "There are no drafts to submit");
|
|
2683
|
+
for (const file of documentFiles) await this.files.writeDocumentFile({
|
|
2684
|
+
...file,
|
|
2685
|
+
threads: file.threads.map((thread) => submitDraft(thread, at))
|
|
2686
|
+
});
|
|
2687
|
+
const next = {
|
|
2688
|
+
...review,
|
|
2689
|
+
approvedAt: verdict === "approve" ? at : null,
|
|
2690
|
+
threads: review.threads.map((thread) => submitDraft(thread, at))
|
|
2691
|
+
};
|
|
2692
|
+
await this.files.writeReviewFile(next);
|
|
2693
|
+
return {
|
|
2694
|
+
review: toReviewState(next),
|
|
2695
|
+
submittedThreadIds
|
|
2696
|
+
};
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
/**
|
|
2700
|
+
* @throws StoreError "unknown-thread" when no thread has the ID or the thread is a draft, or "invalid-input" when
|
|
2701
|
+
* the reply is blank
|
|
2702
|
+
*/
|
|
2703
|
+
replyAsAgent(id, body) {
|
|
2704
|
+
return this.changeThread(id, "agent", (thread, at) => replyAsAgent(thread, parseStoreInput(messageBodySchema, body, "The reply"), at));
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* @param body what the agent changed, or null to resolve without a message
|
|
2708
|
+
* @throws StoreError "unknown-thread" when no thread has the ID or the thread is a draft, or "invalid-input" when
|
|
2709
|
+
* the message is blank
|
|
2710
|
+
*/
|
|
2711
|
+
resolveAsAgent(id, body) {
|
|
2712
|
+
return this.changeThread(id, "agent", (thread, at) => resolveAsAgent(thread, body === null ? null : parseStoreInput(messageBodySchema, body, "The message"), at));
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Reads threads for the browser, first re-anchoring any doc that changed since its threads were last anchored
|
|
2716
|
+
*
|
|
2717
|
+
* @param document the doc whose threads to read, alongside the review's, or null for every thread
|
|
2718
|
+
* @returns the review state, the threads in ID order, and any store files that could not be read
|
|
2719
|
+
* @throws StoreError "invalid-input" when the doc path is not a repo-relative POSIX path
|
|
2720
|
+
*/
|
|
2721
|
+
readThreads(document) {
|
|
2722
|
+
return this.queue.enqueue(() => this.readSnapshot(document));
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Reads what the agent has to act on, first re-anchoring any doc that changed since its threads were last anchored
|
|
2726
|
+
*
|
|
2727
|
+
* @param document the doc to limit the threads to, or null for every thread including the review's
|
|
2728
|
+
* @returns the review state and the threads that need the agent
|
|
2729
|
+
* @throws StoreError "invalid-input" when the doc path is not a repo-relative POSIX path
|
|
2730
|
+
*/
|
|
2731
|
+
readInbox(document) {
|
|
2732
|
+
return this.queue.enqueue(async () => {
|
|
2733
|
+
const snapshot = await this.readSnapshot(document);
|
|
2734
|
+
const threads = snapshot.threads.filter((thread) => needsAgent(thread) && (document === null || thread.anchor.kind !== "review"));
|
|
2735
|
+
return {
|
|
2736
|
+
...snapshot,
|
|
2737
|
+
threads
|
|
2738
|
+
};
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
async readSnapshot(document) {
|
|
2742
|
+
const documents = document === null ? await this.files.listDocuments() : [parseStoreInput(documentPathSchema, document, "The doc path")];
|
|
2743
|
+
await this.readAllStoredThreads();
|
|
2744
|
+
const problems = [];
|
|
2745
|
+
const review = await this.readReviewFileOrReport(problems);
|
|
2746
|
+
const threads = [...review.threads];
|
|
2747
|
+
for (const name of documents) threads.push(...await this.readDocumentThreadsOrReport(name, problems));
|
|
2748
|
+
return {
|
|
2749
|
+
problems,
|
|
2750
|
+
review: toReviewState(review),
|
|
2751
|
+
threads: threads.sort((left, right) => left.id - right.id)
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
async readReviewFileOrReport(problems) {
|
|
2755
|
+
const result = await this.files.readReviewFile();
|
|
2756
|
+
if (result.kind === "invalid") {
|
|
2757
|
+
problems.push(result.problem);
|
|
2758
|
+
return emptyReviewFile;
|
|
2759
|
+
}
|
|
2760
|
+
return result.value;
|
|
2761
|
+
}
|
|
2762
|
+
async readDocumentThreadsOrReport(document, problems) {
|
|
2763
|
+
try {
|
|
2764
|
+
return (await this.readCurrentDocumentFile(document)).file.threads;
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
if (error instanceof StoreError && error.reason === "invalid-file") {
|
|
2767
|
+
problems.push(error.message);
|
|
2768
|
+
return [];
|
|
2769
|
+
}
|
|
2770
|
+
throw error;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
changeThread(id, actor, change) {
|
|
2774
|
+
return this.queue.enqueue(async () => {
|
|
2775
|
+
const document = await this.locateThread(id);
|
|
2776
|
+
const threads = document === null ? requireValid(await this.files.readReviewFile()).threads : (await this.readCurrentDocumentFile(document)).file.threads;
|
|
2777
|
+
const thread = threads.find((candidate) => candidate.id === id);
|
|
2778
|
+
if (thread === void 0 || actor === "agent" && thread.status === "draft") throw new StoreError("unknown-thread", `No thread #${id}`);
|
|
2779
|
+
const changed = change(thread, (/* @__PURE__ */ new Date()).toISOString());
|
|
2780
|
+
await this.replaceThreads(document, replaceThread(threads, id, changed));
|
|
2781
|
+
return changed;
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
2785
|
+
* @returns the doc whose threads file holds the thread, or null when `review.json` does
|
|
2786
|
+
* @throws StoreError "unknown-thread" when no readable store file holds it
|
|
2787
|
+
*/
|
|
2788
|
+
async locateThread(id) {
|
|
2789
|
+
const { files } = await this.readAllStoredThreads();
|
|
2790
|
+
const file = files.find((candidate) => candidate.threads.some((thread) => thread.id === id));
|
|
2791
|
+
if (file === void 0) throw new StoreError("unknown-thread", `No thread #${id}`);
|
|
2792
|
+
return file.document;
|
|
2793
|
+
}
|
|
2794
|
+
async nextThreadId() {
|
|
2795
|
+
const { files } = await this.readAllStoredThreads();
|
|
2796
|
+
return Math.max(0, ...files.flatMap((file) => file.threads.map((thread) => thread.id))) + 1;
|
|
2797
|
+
}
|
|
2798
|
+
/**
|
|
2799
|
+
* Reads the threads of every readable store file, first giving threads that share an ID, after a merge or a pull,
|
|
2800
|
+
* unique IDs, so an ID always names one thread
|
|
2801
|
+
*/
|
|
2802
|
+
async readAllStoredThreads() {
|
|
2803
|
+
const { files, problems } = await this.readStoredThreadFiles();
|
|
2804
|
+
const { files: renumbered, renumberings } = renumberDuplicateThreads(files);
|
|
2805
|
+
const changedDocuments = new Set(renumberings.map(({ document }) => document));
|
|
2806
|
+
for (const { document, threads } of renumbered) if (changedDocuments.has(document)) await this.replaceThreads(document, threads);
|
|
2807
|
+
for (const { document, from, to } of renumberings) this.logger.info(`Thread #${from} in ${document ?? "the review"} was renumbered to #${to} because its ID was taken`);
|
|
2808
|
+
return {
|
|
2809
|
+
files: renumbered,
|
|
2810
|
+
problems
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
async readStoredThreadFiles() {
|
|
2814
|
+
const files = [];
|
|
2815
|
+
const problems = [];
|
|
2816
|
+
const review = await this.files.readReviewFile();
|
|
2817
|
+
if (review.kind === "valid") files.push({
|
|
2818
|
+
document: null,
|
|
2819
|
+
threads: review.value.threads
|
|
2820
|
+
});
|
|
2821
|
+
else problems.push(review.problem);
|
|
2822
|
+
for (const document of await this.files.listDocuments()) {
|
|
2823
|
+
const result = await this.files.readDocumentFile(document);
|
|
2824
|
+
if (result.kind === "invalid") problems.push(result.problem);
|
|
2825
|
+
else if (result.value !== null) files.push({
|
|
2826
|
+
document,
|
|
2827
|
+
threads: result.value.threads
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
return {
|
|
2831
|
+
files,
|
|
2832
|
+
problems
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
async readDocumentFilesWithDrafts() {
|
|
2836
|
+
const files = [];
|
|
2837
|
+
for (const document of await this.files.listDocuments()) {
|
|
2838
|
+
const result = await this.files.readDocumentFile(document);
|
|
2839
|
+
if (result.kind === "invalid") this.logger.warn(`Drafts in ${document} were not submitted. ${result.problem}`);
|
|
2840
|
+
else if (result.value?.threads.some((thread) => thread.draft !== void 0)) files.push((await this.readCurrentDocumentFile(document)).file);
|
|
2841
|
+
}
|
|
2842
|
+
return files;
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Reads a doc's threads file, first re-anchoring its passage threads and saving them when the doc has changed since
|
|
2846
|
+
* they were last anchored
|
|
2847
|
+
*/
|
|
2848
|
+
async readCurrentDocumentFile(document) {
|
|
2849
|
+
const stored = requireValid(await this.files.readDocumentFile(document));
|
|
2850
|
+
if (stored !== null && stored.document !== document) throw new StoreError("invalid-input", `${document} has its threads stored as ${stored.document}; use that name`);
|
|
2851
|
+
const source = await this.files.readDocumentSource(document);
|
|
2852
|
+
const sourceHash = source === null ? null : hashSource(source);
|
|
2853
|
+
if (stored === null) return {
|
|
2854
|
+
file: {
|
|
2855
|
+
document,
|
|
2856
|
+
sourceHash,
|
|
2857
|
+
threads: [],
|
|
2858
|
+
version: 1
|
|
2859
|
+
},
|
|
2860
|
+
source
|
|
2861
|
+
};
|
|
2862
|
+
if (stored.sourceHash === sourceHash) return {
|
|
2863
|
+
file: stored,
|
|
2864
|
+
source
|
|
2865
|
+
};
|
|
2866
|
+
const file = {
|
|
2867
|
+
...stored,
|
|
2868
|
+
sourceHash,
|
|
2869
|
+
threads: reanchorDocumentThreads(stored.threads, source)
|
|
2870
|
+
};
|
|
2871
|
+
await this.files.writeDocumentFile(file);
|
|
2872
|
+
return {
|
|
2873
|
+
file,
|
|
2874
|
+
source
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
async replaceThreads(document, threads) {
|
|
2878
|
+
if (document === null) {
|
|
2879
|
+
const review = requireValid(await this.files.readReviewFile());
|
|
2880
|
+
await this.files.writeReviewFile({
|
|
2881
|
+
...review,
|
|
2882
|
+
threads
|
|
2883
|
+
});
|
|
2884
|
+
return;
|
|
2885
|
+
}
|
|
2886
|
+
const file = requireValid(await this.files.readDocumentFile(document));
|
|
2887
|
+
if (file === null) throw new StoreError("invalid-state", `${document} has no threads file to update`);
|
|
2888
|
+
await this.files.writeDocumentFile({
|
|
2889
|
+
...file,
|
|
2890
|
+
threads
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
function replaceThread(threads, id, replacement) {
|
|
2895
|
+
return threads.flatMap((thread) => {
|
|
2896
|
+
if (thread.id !== id) return [thread];
|
|
2897
|
+
return replacement === null ? [] : [replacement];
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
function requireValid(result) {
|
|
2901
|
+
if (result.kind === "invalid") throw new StoreError("invalid-file", result.problem);
|
|
2902
|
+
return result.value;
|
|
2903
|
+
}
|
|
2904
|
+
//#endregion
|
|
2905
|
+
//#region src/server/runtime/ActivityMonitor.ts
|
|
2906
|
+
/**
|
|
2907
|
+
* Tracks connected tabs and waiting polls, reports whether the agent is waiting, and notices when the server is idle
|
|
2908
|
+
*/
|
|
2909
|
+
var ActivityMonitor = class {
|
|
2910
|
+
browsers = 0;
|
|
2911
|
+
events;
|
|
2912
|
+
idleMilliseconds;
|
|
2913
|
+
idleTimer;
|
|
2914
|
+
onIdle;
|
|
2915
|
+
polls = 0;
|
|
2916
|
+
constructor({ events, idleMilliseconds, onIdle }) {
|
|
2917
|
+
this.events = events;
|
|
2918
|
+
this.idleMilliseconds = idleMilliseconds;
|
|
2919
|
+
this.onIdle = onIdle;
|
|
2920
|
+
this.restartIdleTimer();
|
|
2921
|
+
}
|
|
2922
|
+
get agentWaiting() {
|
|
2923
|
+
return this.polls > 0;
|
|
2924
|
+
}
|
|
2925
|
+
/**
|
|
2926
|
+
* Records a request, which restarts the idle countdown
|
|
2927
|
+
*/
|
|
2928
|
+
touch() {
|
|
2929
|
+
this.restartIdleTimer();
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* @returns a function to call when the tab disconnects
|
|
2933
|
+
*/
|
|
2934
|
+
beginBrowser() {
|
|
2935
|
+
this.browsers += 1;
|
|
2936
|
+
this.restartIdleTimer();
|
|
2937
|
+
return this.once(() => {
|
|
2938
|
+
this.browsers -= 1;
|
|
2939
|
+
this.restartIdleTimer();
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* @returns a function to call when the poll ends
|
|
2944
|
+
*/
|
|
2945
|
+
beginPoll() {
|
|
2946
|
+
this.polls += 1;
|
|
2947
|
+
this.restartIdleTimer();
|
|
2948
|
+
if (this.polls === 1) this.events.publish({
|
|
2949
|
+
agentWaiting: true,
|
|
2950
|
+
type: "presence"
|
|
2951
|
+
});
|
|
2952
|
+
return this.once(() => {
|
|
2953
|
+
this.polls -= 1;
|
|
2954
|
+
this.restartIdleTimer();
|
|
2955
|
+
if (this.polls === 0) this.events.publish({
|
|
2956
|
+
agentWaiting: false,
|
|
2957
|
+
type: "presence"
|
|
2958
|
+
});
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
stop() {
|
|
2962
|
+
clearTimeout(this.idleTimer);
|
|
2963
|
+
}
|
|
2964
|
+
restartIdleTimer() {
|
|
2965
|
+
clearTimeout(this.idleTimer);
|
|
2966
|
+
this.idleTimer = this.browsers === 0 && this.polls === 0 ? setTimeout(this.onIdle, this.idleMilliseconds).unref() : void 0;
|
|
2967
|
+
}
|
|
2968
|
+
once(end) {
|
|
2969
|
+
let ended = false;
|
|
2970
|
+
return () => {
|
|
2971
|
+
if (!ended) {
|
|
2972
|
+
ended = true;
|
|
2973
|
+
end();
|
|
2974
|
+
}
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
};
|
|
2978
|
+
//#endregion
|
|
2979
|
+
//#region src/server/runtime/DocumentWatcher.ts
|
|
2980
|
+
/**
|
|
2981
|
+
* Watches docs for edits, so connected tabs can be told as soon as the agent saves one
|
|
2982
|
+
*/
|
|
2983
|
+
var DocumentWatcher = class {
|
|
2984
|
+
documents = /* @__PURE__ */ new Map();
|
|
2985
|
+
root;
|
|
2986
|
+
watcher;
|
|
2987
|
+
/**
|
|
2988
|
+
* @param root the repo root
|
|
2989
|
+
* @param onChange called with a doc's repo-relative path when it is written, created or deleted
|
|
2990
|
+
*/
|
|
2991
|
+
constructor(root, onChange) {
|
|
2992
|
+
this.root = root;
|
|
2993
|
+
this.watcher = watch([], {
|
|
2994
|
+
atomic: true,
|
|
2995
|
+
awaitWriteFinish: {
|
|
2996
|
+
pollInterval: 20,
|
|
2997
|
+
stabilityThreshold: 50
|
|
2998
|
+
},
|
|
2999
|
+
ignoreInitial: true
|
|
3000
|
+
});
|
|
3001
|
+
this.watcher.on("all", (_event, changedPath) => {
|
|
3002
|
+
const document = this.documents.get(changedPath);
|
|
3003
|
+
if (document !== void 0) onChange(document);
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* @param document a repo-relative POSIX path; watching a doc twice has no further effect
|
|
3008
|
+
*/
|
|
3009
|
+
watch(document) {
|
|
3010
|
+
const absolutePath = path.join(this.root, ...document.split("/"));
|
|
3011
|
+
if (!this.documents.has(absolutePath)) {
|
|
3012
|
+
this.documents.set(absolutePath, document);
|
|
3013
|
+
this.watcher.add(absolutePath);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
close() {
|
|
3017
|
+
return this.watcher.close();
|
|
3018
|
+
}
|
|
3019
|
+
};
|
|
3020
|
+
//#endregion
|
|
3021
|
+
//#region src/server/runtime/preferredPort.ts
|
|
3022
|
+
var firstDynamicPort = 49152;
|
|
3023
|
+
var dynamicPortCount = 16384;
|
|
3024
|
+
/**
|
|
3025
|
+
* Picks the port a root's server tries first, so a restarted server usually comes back on the same port
|
|
3026
|
+
*
|
|
3027
|
+
* @param root the repo root
|
|
3028
|
+
* @returns a port in the dynamic range 49152-65535, the same for the same root
|
|
3029
|
+
*/
|
|
3030
|
+
function preferredPort(root) {
|
|
3031
|
+
return firstDynamicPort + createHash("sha256").update(root, "utf8").digest().readUInt32BE(0) % dynamicPortCount;
|
|
3032
|
+
}
|
|
3033
|
+
//#endregion
|
|
3034
|
+
//#region src/server/runtime/runServer.ts
|
|
3035
|
+
/**
|
|
3036
|
+
* Starts the root's review server on 127.0.0.1 and records it in `.markdown-review/server.json`
|
|
3037
|
+
*
|
|
3038
|
+
* @returns the running server; it removes `server.json` when it stops
|
|
3039
|
+
*/
|
|
3040
|
+
async function runServer({ idleMilliseconds = 18e5, logger, pollKeepAliveMilliseconds = 15e3, root, webDirectory }) {
|
|
3041
|
+
const store = new ReviewStore(root, logger);
|
|
3042
|
+
await store.initialize();
|
|
3043
|
+
const events = new ServerEvents();
|
|
3044
|
+
let port = 0;
|
|
3045
|
+
let stopping;
|
|
3046
|
+
let markClosed = () => {};
|
|
3047
|
+
const closed = new Promise((resolve) => {
|
|
3048
|
+
markClosed = resolve;
|
|
3049
|
+
});
|
|
3050
|
+
const close = () => {
|
|
3051
|
+
stopping ??= stop();
|
|
3052
|
+
return stopping;
|
|
3053
|
+
};
|
|
3054
|
+
const closeInBackground = () => {
|
|
3055
|
+
close().catch((error) => logger.error("The server did not stop cleanly", error));
|
|
3056
|
+
};
|
|
3057
|
+
const activity = new ActivityMonitor({
|
|
3058
|
+
events,
|
|
3059
|
+
idleMilliseconds,
|
|
3060
|
+
onIdle: closeInBackground
|
|
3061
|
+
});
|
|
3062
|
+
const watcher = new DocumentWatcher(root, (document) => {
|
|
3063
|
+
store.readThreads(document).then(() => {
|
|
3064
|
+
events.publish({
|
|
3065
|
+
document,
|
|
3066
|
+
type: "document-changed"
|
|
3067
|
+
});
|
|
3068
|
+
events.publish({ type: "threads-changed" });
|
|
3069
|
+
}).catch((error) => logger.warn(`Could not re-anchor ${document} after it changed`, error));
|
|
3070
|
+
});
|
|
3071
|
+
const token = randomBytes(32).toString("hex");
|
|
3072
|
+
const app = createApp({
|
|
3073
|
+
activity,
|
|
3074
|
+
events,
|
|
3075
|
+
logger,
|
|
3076
|
+
pollKeepAliveMilliseconds,
|
|
3077
|
+
port: () => port,
|
|
3078
|
+
recentDocuments: new RecentDocuments(),
|
|
3079
|
+
root,
|
|
3080
|
+
shutdown: closeInBackground,
|
|
3081
|
+
store,
|
|
3082
|
+
tabs: new BrowserTabs(),
|
|
3083
|
+
token,
|
|
3084
|
+
version: packageVersion,
|
|
3085
|
+
watchDocument: (document) => watcher.watch(document),
|
|
3086
|
+
webDirectory
|
|
3087
|
+
});
|
|
3088
|
+
const server = createServer(getRequestListener(app.fetch));
|
|
3089
|
+
port = await listen(server, preferredPort(root));
|
|
3090
|
+
const serverFile = {
|
|
3091
|
+
pid: process.pid,
|
|
3092
|
+
port,
|
|
3093
|
+
protocol: 1,
|
|
3094
|
+
root,
|
|
3095
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3096
|
+
token,
|
|
3097
|
+
version: packageVersion
|
|
3098
|
+
};
|
|
3099
|
+
await writeJsonAtomically(serverFilePath(root), serverFile, 384);
|
|
3100
|
+
for (const thread of (await store.readThreads(null)).threads) if (thread.anchor.kind !== "review") watcher.watch(thread.anchor.document);
|
|
3101
|
+
logger.info(`Serving ${root} on http://127.0.0.1:${port}`);
|
|
3102
|
+
async function stop() {
|
|
3103
|
+
activity.stop();
|
|
3104
|
+
await watcher.close();
|
|
3105
|
+
await closeServer(server);
|
|
3106
|
+
await removeServerFile(root);
|
|
3107
|
+
logger.info("Stopped");
|
|
3108
|
+
markClosed();
|
|
3109
|
+
}
|
|
3110
|
+
return {
|
|
3111
|
+
close,
|
|
3112
|
+
closed,
|
|
3113
|
+
port
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
async function listen(server, port) {
|
|
3117
|
+
try {
|
|
3118
|
+
return await listenOn(server, port);
|
|
3119
|
+
} catch (error) {
|
|
3120
|
+
if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") return listenOn(server, 0);
|
|
3121
|
+
throw error;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
function listenOn(server, port) {
|
|
3125
|
+
return new Promise((resolve, reject) => {
|
|
3126
|
+
const onError = (error) => {
|
|
3127
|
+
server.off("listening", onListening);
|
|
3128
|
+
reject(error);
|
|
3129
|
+
};
|
|
3130
|
+
const onListening = () => {
|
|
3131
|
+
server.off("error", onError);
|
|
3132
|
+
const address = server.address();
|
|
3133
|
+
if (address === null || typeof address === "string") {
|
|
3134
|
+
reject(/* @__PURE__ */ new TypeError("The server is not listening on a TCP port"));
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
resolve(address.port);
|
|
3138
|
+
};
|
|
3139
|
+
server.once("error", onError);
|
|
3140
|
+
server.once("listening", onListening);
|
|
3141
|
+
server.listen(port, "127.0.0.1");
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
function closeServer(server) {
|
|
3145
|
+
return new Promise((resolve, reject) => {
|
|
3146
|
+
server.close((error) => error === void 0 ? resolve() : reject(error));
|
|
3147
|
+
server.closeAllConnections();
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
/**
|
|
3151
|
+
* Removes `server.json` unless a newer server has already replaced it
|
|
3152
|
+
*/
|
|
3153
|
+
async function removeServerFile(root) {
|
|
3154
|
+
const contents = await readTextFileOrNull(serverFilePath(root));
|
|
3155
|
+
if (contents === null) return;
|
|
3156
|
+
let recorded;
|
|
3157
|
+
try {
|
|
3158
|
+
recorded = JSON.parse(contents);
|
|
3159
|
+
} catch {
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
if (serverFileSchema.safeParse(recorded).data?.pid === process.pid) await rm(serverFilePath(root), { force: true });
|
|
3163
|
+
}
|
|
3164
|
+
//#endregion
|
|
3165
|
+
//#region src/cli/commands/serveCommand.ts
|
|
3166
|
+
/**
|
|
3167
|
+
* Runs the review server in this process until it stops; the CLI starts it detached as `serve --root <root>`
|
|
3168
|
+
*/
|
|
3169
|
+
async function serveCommand(args, { cliPath }) {
|
|
3170
|
+
const { values } = parseArgs({
|
|
3171
|
+
args,
|
|
3172
|
+
options: { root: { type: "string" } }
|
|
3173
|
+
});
|
|
3174
|
+
if (values.root === void 0) throw new CliError("serve needs --root <path>", "Run `markdown-review serve --root <repository root>`.", 2);
|
|
3175
|
+
const server = await runServer({
|
|
3176
|
+
logger: createConsoleLogger(),
|
|
3177
|
+
root: values.root,
|
|
3178
|
+
webDirectory: path.join(path.dirname(cliPath), "web")
|
|
3179
|
+
});
|
|
3180
|
+
const stop = () => {
|
|
3181
|
+
server.close().catch((error) => process.stderr.write(`The server did not stop cleanly: ${String(error)}\n`));
|
|
3182
|
+
};
|
|
3183
|
+
process.once("SIGINT", stop);
|
|
3184
|
+
process.once("SIGTERM", stop);
|
|
3185
|
+
await server.closed;
|
|
3186
|
+
return 0;
|
|
3187
|
+
}
|
|
3188
|
+
//#endregion
|
|
3189
|
+
//#region src/cli/commands/stopCommand.ts
|
|
3190
|
+
async function stopCommand(args, { terminal }) {
|
|
3191
|
+
parseArgs({
|
|
3192
|
+
args,
|
|
3193
|
+
options: {}
|
|
3194
|
+
});
|
|
3195
|
+
const root = await findRoot(terminal.workingDirectory);
|
|
3196
|
+
const found = await findServer(root);
|
|
3197
|
+
if (found.kind !== "ready" && found.kind !== "older") {
|
|
3198
|
+
terminal.stdout(`No review server is running for ${root} that this markdown-review can stop.\n\nnext_step: None.\n`);
|
|
3199
|
+
return 0;
|
|
3200
|
+
}
|
|
3201
|
+
await found.client.shutdown();
|
|
3202
|
+
await waitUntilStopped(found.client);
|
|
3203
|
+
terminal.stdout(`Stopped the review server for ${root}.\n\nnext_step: None; any markdown-review command starts it again.\n`);
|
|
3204
|
+
return 0;
|
|
3205
|
+
}
|
|
3206
|
+
//#endregion
|
|
3207
|
+
//#region src/cli/helpText.ts
|
|
3208
|
+
var commandHelp = {
|
|
3209
|
+
inbox: ["markdown-review inbox [--document <path>]", " Print whether the review is approved and the threads that need you, then return at once."].join("\n"),
|
|
3210
|
+
"install-skill": [
|
|
3211
|
+
"markdown-review install-skill [--global]",
|
|
3212
|
+
" Install the skill that teaches agents this workflow in this repository's .claude/skills, or with --global in",
|
|
3213
|
+
" ~/.claude/skills. Claude Code and OpenCode both read skills from there."
|
|
3214
|
+
].join("\n"),
|
|
3215
|
+
open: ["markdown-review open [path]", " Start a review round and show the doc (or the docs list) in the user's browser. Prints the URL."].join("\n"),
|
|
3216
|
+
poll: [
|
|
3217
|
+
"markdown-review poll [--document <path>] [--timeout <seconds>]",
|
|
3218
|
+
" Wait until the user submits or approves, then print what needs you. The timeout defaults to 540 seconds.",
|
|
3219
|
+
" Give the shell command a timeout of at least 600000 ms, or in Claude Code run it with run_in_background",
|
|
3220
|
+
" and --timeout 7080."
|
|
3221
|
+
].join("\n"),
|
|
3222
|
+
reply: [
|
|
3223
|
+
"markdown-review reply <id> <text>",
|
|
3224
|
+
"markdown-review reply <id> --file <path> (--file - reads standard input)",
|
|
3225
|
+
" Answer a thread without resolving it, for example to ask the user a question."
|
|
3226
|
+
].join("\n"),
|
|
3227
|
+
resolve: [
|
|
3228
|
+
"markdown-review resolve <id> [text]",
|
|
3229
|
+
"markdown-review resolve <id> --file <path> (--file - reads standard input)",
|
|
3230
|
+
" Resolve a thread, optionally saying what changed."
|
|
3231
|
+
].join("\n"),
|
|
3232
|
+
stop: ["markdown-review stop", " Stop the review server for this repository."].join("\n")
|
|
3233
|
+
};
|
|
3234
|
+
/**
|
|
3235
|
+
* @param command a command name, or undefined for every command
|
|
3236
|
+
* @returns usage text for the command, or for the whole CLI
|
|
3237
|
+
*/
|
|
3238
|
+
function helpText(command) {
|
|
3239
|
+
const named = command === void 0 ? void 0 : commandHelp[command];
|
|
3240
|
+
if (named !== void 0) return `${named}\n`;
|
|
3241
|
+
return [
|
|
3242
|
+
"Markdown Review: the user reviews your markdown in a browser; you read and answer their comments here.",
|
|
3243
|
+
"",
|
|
3244
|
+
...Object.values(commandHelp),
|
|
3245
|
+
"",
|
|
3246
|
+
"Run `markdown-review <command> --help` for one command. Every command ends with a next_step line.",
|
|
3247
|
+
""
|
|
3248
|
+
].join("\n");
|
|
3249
|
+
}
|
|
3250
|
+
//#endregion
|
|
3251
|
+
//#region src/cli/runCli.ts
|
|
3252
|
+
var commands = {
|
|
3253
|
+
inbox: inboxCommand,
|
|
3254
|
+
"install-skill": installSkillCommand,
|
|
3255
|
+
open: openCommand,
|
|
3256
|
+
poll: pollCommand,
|
|
3257
|
+
reply: replyCommand,
|
|
3258
|
+
resolve: resolveCommand,
|
|
3259
|
+
serve: serveCommand,
|
|
3260
|
+
stop: stopCommand
|
|
3261
|
+
};
|
|
3262
|
+
/**
|
|
3263
|
+
* Runs one CLI invocation
|
|
3264
|
+
*
|
|
3265
|
+
* @param commandLine the arguments after the program name
|
|
3266
|
+
* @param context where to write output and how to start the server
|
|
3267
|
+
* @returns the process exit code: 0 on success, 1 when the command failed, 2 for a usage error
|
|
3268
|
+
*/
|
|
3269
|
+
async function runCli(commandLine, context) {
|
|
3270
|
+
const { terminal } = context;
|
|
3271
|
+
const [name, ...args] = commandLine;
|
|
3272
|
+
if (name === "--version") {
|
|
3273
|
+
terminal.stdout(`${packageVersion}\n`);
|
|
3274
|
+
return 0;
|
|
3275
|
+
}
|
|
3276
|
+
if (name === "--help" || name === "-h" || name === "help") {
|
|
3277
|
+
terminal.stdout(helpText());
|
|
3278
|
+
return 0;
|
|
3279
|
+
}
|
|
3280
|
+
if (name === void 0) {
|
|
3281
|
+
terminal.stderr(`error: no command given\n\n${helpText()}\nnext_step: Run one of the commands above.\n`);
|
|
3282
|
+
return 2;
|
|
3283
|
+
}
|
|
3284
|
+
const command = commands[name];
|
|
3285
|
+
if (command === void 0) {
|
|
3286
|
+
terminal.stderr(`error: unknown command ${name}\n\n${helpText()}\nnext_step: Run one of the commands above.\n`);
|
|
3287
|
+
return 2;
|
|
3288
|
+
}
|
|
3289
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
3290
|
+
terminal.stdout(helpText(name));
|
|
3291
|
+
return 0;
|
|
3292
|
+
}
|
|
3293
|
+
try {
|
|
3294
|
+
return await command(args, context);
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
return reportFailure(error, name, context);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
function reportFailure(error, name, { terminal }) {
|
|
3300
|
+
if (error instanceof CliError) {
|
|
3301
|
+
terminal.stderr(`error: ${error.message}\n\nnext_step: ${error.nextStep}\n`);
|
|
3302
|
+
return error.exitCode;
|
|
3303
|
+
}
|
|
3304
|
+
if (error instanceof ServerRequestError) {
|
|
3305
|
+
terminal.stderr(`error: the review server refused the request: ${error.message}\n\nnext_step: Fix what it reports and run the command again.\n`);
|
|
3306
|
+
return 1;
|
|
3307
|
+
}
|
|
3308
|
+
if (error instanceof TypeError && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS")) {
|
|
3309
|
+
terminal.stderr(`error: ${error.message}\n\nnext_step: Run \`markdown-review ${name} --help\`.\n`);
|
|
3310
|
+
return 2;
|
|
3311
|
+
}
|
|
3312
|
+
terminal.stderr(`error: ${error instanceof Error ? error.message : String(error)}\n\nnext_step: Run the command again; if it keeps failing, read .markdown-review/server.log.\n`);
|
|
3313
|
+
return 1;
|
|
3314
|
+
}
|
|
3315
|
+
//#endregion
|
|
3316
|
+
//#region src/cli/main.ts
|
|
3317
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
3318
|
+
cliPath: fileURLToPath(import.meta.url),
|
|
3319
|
+
terminal: createProcessTerminal()
|
|
3320
|
+
});
|
|
3321
|
+
//#endregion
|
|
3322
|
+
export {};
|