@thaut/claude-ide 0.0.0-stage → 1.0.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/bin/claude-ide.js +23 -0
- package/package.json +75 -4
- package/server/add-dirs.ts +126 -0
- package/server/auth.test.ts +148 -0
- package/server/auth.ts +235 -0
- package/server/bang.ts +54 -0
- package/server/btw-instructions.ts +19 -0
- package/server/btw-policy.ts +17 -0
- package/server/btw.ts +143 -0
- package/server/child-env.ts +29 -0
- package/server/cli-control.ts +255 -0
- package/server/cli-files.ts +55 -0
- package/server/cli-info.ts +73 -0
- package/server/config/account.ts +65 -0
- package/server/config/agents.ts +186 -0
- package/server/config/mcp.ts +86 -0
- package/server/config/memory.ts +193 -0
- package/server/config/paths.ts +156 -0
- package/server/config/plugins.ts +204 -0
- package/server/config/routes.ts +95 -0
- package/server/config/settings.ts +246 -0
- package/server/config.ts +46 -0
- package/server/db.ts +121 -0
- package/server/directories.test.ts +180 -0
- package/server/directories.ts +87 -0
- package/server/dist-version.ts +69 -0
- package/server/durable-tasks.ts +61 -0
- package/server/file-transfers.ts +79 -0
- package/server/files.test.ts +127 -0
- package/server/files.ts +386 -0
- package/server/fs-queue.ts +14 -0
- package/server/fsutil.ts +144 -0
- package/server/git.ts +86 -0
- package/server/goal-state.test.ts +64 -0
- package/server/goal-state.ts +78 -0
- package/server/history-pages.test.ts +619 -0
- package/server/history-pages.ts +513 -0
- package/server/http-channel-manager.ts +468 -0
- package/server/http-channel-pull.test.ts +314 -0
- package/server/http-channel-push.test.ts +364 -0
- package/server/http-channel-registration.test.ts +291 -0
- package/server/http-channel-routes.ts +73 -0
- package/server/http-channel-store.ts +142 -0
- package/server/http-channel-test-fixture.ts +103 -0
- package/server/ide-messages-mcp.test.ts +82 -0
- package/server/ide-messages-mcp.ts +47 -0
- package/server/idecron-mcp.ts +65 -0
- package/server/idecron.test.ts +79 -0
- package/server/idecron.ts +271 -0
- package/server/index.ts +336 -0
- package/server/live-session.test.ts +1126 -0
- package/server/live-session.ts +1165 -0
- package/server/project-routes.ts +46 -0
- package/server/projects.test.ts +28 -0
- package/server/push.ts +315 -0
- package/server/report-cache.ts +28 -0
- package/server/session-aliases.test.ts +57 -0
- package/server/session-aliases.ts +50 -0
- package/server/session-branch.ts +71 -0
- package/server/session-dirs.ts +189 -0
- package/server/session-file.test.ts +134 -0
- package/server/session-file.ts +113 -0
- package/server/session-messaging.test.ts +297 -0
- package/server/session-messaging.ts +165 -0
- package/server/sessions.ts +638 -0
- package/server/subagents.test.ts +43 -0
- package/server/subagents.ts +58 -0
- package/server/tool-images.test.ts +76 -0
- package/server/tool-images.ts +45 -0
- package/server/turn-changes.ts +308 -0
- package/server/worktree-git.ts +87 -0
- package/server/worktrees.ts +138 -0
- package/server/ws.test.ts +195 -0
- package/server/ws.ts +197 -0
- package/shared/agent-model.ts +15 -0
- package/shared/agent-tasks.ts +12 -0
- package/shared/config.ts +175 -0
- package/shared/directories.ts +12 -0
- package/shared/files.ts +51 -0
- package/shared/http-channels.ts +63 -0
- package/shared/message-window.ts +189 -0
- package/shared/project.ts +77 -0
- package/shared/protocol.ts +203 -0
- package/shared/task-types.ts +4 -0
- package/shared/todos.ts +40 -0
- package/shared/write-policy.ts +9 -0
- package/web/dist/assets/AgentView-wrTKgcJ3.js +1 -0
- package/web/dist/assets/AnnotationSelection-BZmNmwVW.js +7 -0
- package/web/dist/assets/ChangesView-G1WTzh4q.js +2 -0
- package/web/dist/assets/FilesView-DPgAbYt_.js +6 -0
- package/web/dist/assets/Form-BKTwiYyw.js +1 -0
- package/web/dist/assets/HomePage-Dq2apjBe.js +1 -0
- package/web/dist/assets/Markdown-IwP-xF29.js +67 -0
- package/web/dist/assets/MessageList-DD9GypSy.js +15 -0
- package/web/dist/assets/Modal-g2rv8I_n.js +1 -0
- package/web/dist/assets/NotificationSettings-DpuLO_Bv.js +1 -0
- package/web/dist/assets/OverviewPage-Dx6miabE.js +1 -0
- package/web/dist/assets/ProjectHome-CousyTLL.js +1 -0
- package/web/dist/assets/SessionView-CPVL7_HO.js +2 -0
- package/web/dist/assets/SettingsPage-D_oYSnW2.js +7 -0
- package/web/dist/assets/WorktreesView-C7e0HV__.js +1 -0
- package/web/dist/assets/abap-CLvhMVsD.js +1 -0
- package/web/dist/assets/actionscript-3-Cs6JJXT5.js +1 -0
- package/web/dist/assets/ada-X3Y2mlq2.js +1 -0
- package/web/dist/assets/ahk-DwADkxyd.js +1 -0
- package/web/dist/assets/ahk2-_6rzW9BS.js +1 -0
- package/web/dist/assets/andromeeda-vGVdxbeo.js +1 -0
- package/web/dist/assets/angular-html-Cw29lun8.js +1 -0
- package/web/dist/assets/angular-ts-BXz-O3SJ.js +1 -0
- package/web/dist/assets/apache-U0d_L8uA.js +1 -0
- package/web/dist/assets/apex-KFGFyKwG.js +1 -0
- package/web/dist/assets/apl-xBchdRhy.js +1 -0
- package/web/dist/assets/applescript-CCn79oCD.js +1 -0
- package/web/dist/assets/ara-4CJ0cIlV.js +1 -0
- package/web/dist/assets/asciidoc-SCjQUq34.js +1 -0
- package/web/dist/assets/asm-Cmm7eHzH.js +1 -0
- package/web/dist/assets/astro-1q6DGuN3.js +1 -0
- package/web/dist/assets/aurora-x-CDeNXAV0.js +1 -0
- package/web/dist/assets/awk-BWXHIvNe.js +1 -0
- package/web/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
- package/web/dist/assets/ayu-light-C3h-C4tm.js +1 -0
- package/web/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
- package/web/dist/assets/ballerina-B7ZEbQpA.js +1 -0
- package/web/dist/assets/bat-qdxAdBWQ.js +1 -0
- package/web/dist/assets/beancount-C2IN4Vk8.js +1 -0
- package/web/dist/assets/berry-DKpUyyne.js +1 -0
- package/web/dist/assets/bibtex-Ci_nEsc7.js +1 -0
- package/web/dist/assets/bicep-CUHmPFLl.js +1 -0
- package/web/dist/assets/bird2-CYIUgYqB.js +1 -0
- package/web/dist/assets/blade-Cqdv4oZN.js +1 -0
- package/web/dist/assets/bsl-CVED6lJ1.js +1 -0
- package/web/dist/assets/c-D9gIxSfx.js +1 -0
- package/web/dist/assets/c3-D0apG41V.js +1 -0
- package/web/dist/assets/cadence-CQ2zXKGN.js +1 -0
- package/web/dist/assets/cairo-DRAZz75c.js +1 -0
- package/web/dist/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
- package/web/dist/assets/catppuccin-latte-CtsyrtPq.js +1 -0
- package/web/dist/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
- package/web/dist/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
- package/web/dist/assets/chapel-CuseGurG.js +1 -0
- package/web/dist/assets/clarity-CtfBBspg.js +1 -0
- package/web/dist/assets/clojure-DqKBuwfJ.js +1 -0
- package/web/dist/assets/cmake-Bj61d0ZC.js +1 -0
- package/web/dist/assets/cobol-Bdpj3Qoh.js +1 -0
- package/web/dist/assets/codeowners-C8r90Shi.js +1 -0
- package/web/dist/assets/codeql-oeQT6MSM.js +1 -0
- package/web/dist/assets/coffee-Sk8pHFjs.js +1 -0
- package/web/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
- package/web/dist/assets/coq-CGaSfhwR.js +1 -0
- package/web/dist/assets/core-BxI4V4-T.js +12 -0
- package/web/dist/assets/cpp-BQfBYKx3.js +1 -0
- package/web/dist/assets/crystal-B3j7DoOZ.js +1 -0
- package/web/dist/assets/csharp-dmMxLVLW.js +1 -0
- package/web/dist/assets/css-DpFbYmRh.js +1 -0
- package/web/dist/assets/csv-Dx-8-gkx.js +1 -0
- package/web/dist/assets/cue-CE9AQfxI.js +1 -0
- package/web/dist/assets/cypher-ClKdZ_lG.js +1 -0
- package/web/dist/assets/d-qD-0Kul2.js +1 -0
- package/web/dist/assets/dark-plus-Cs2F2srj.js +1 -0
- package/web/dist/assets/dart-CHZY9Xhz.js +1 -0
- package/web/dist/assets/dax-BkyTk9wS.js +1 -0
- package/web/dist/assets/desktop-Dlh5hvp9.js +1 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/docker-IyjqRm3v.js +1 -0
- package/web/dist/assets/dotenv-CuH5my32.js +1 -0
- package/web/dist/assets/dracula-BHWKrbxM.js +1 -0
- package/web/dist/assets/dracula-soft-5eyTD99u.js +1 -0
- package/web/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
- package/web/dist/assets/edge-DXQWmXTB.js +1 -0
- package/web/dist/assets/elixir-CrDIY2Vo.js +1 -0
- package/web/dist/assets/elm-BslFEg8K.js +1 -0
- package/web/dist/assets/emacs-lisp-DR2vtWaj.js +1 -0
- package/web/dist/assets/engine-javascript-DJg3-Vu2.js +141 -0
- package/web/dist/assets/erb-CUYhg0Ws.js +1 -0
- package/web/dist/assets/erlang-BXdgHVfi.js +1 -0
- package/web/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
- package/web/dist/assets/everforest-light-Df2xbC6M.js +1 -0
- package/web/dist/assets/fennel-DQxkIbk2.js +1 -0
- package/web/dist/assets/fish-BJitypiv.js +1 -0
- package/web/dist/assets/fluent-C03EYrpw.js +1 -0
- package/web/dist/assets/format-DrBC7w0A.js +1 -0
- package/web/dist/assets/fortran-fixed-form-D6mN0kjy.js +1 -0
- package/web/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
- package/web/dist/assets/fsharp-CwoEF39o.js +1 -0
- package/web/dist/assets/gdresource-CZkFGo3B.js +1 -0
- package/web/dist/assets/gdscript-DYvRD3hg.js +1 -0
- package/web/dist/assets/gdshader-CBce3t8t.js +1 -0
- package/web/dist/assets/genie-CV2tkWYe.js +1 -0
- package/web/dist/assets/gherkin-DExj1W_8.js +1 -0
- package/web/dist/assets/git-commit-dUGrvm6Q.js +1 -0
- package/web/dist/assets/git-rebase-4zCRqF28.js +1 -0
- package/web/dist/assets/github-dark-C-LZuMrd.js +1 -0
- package/web/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/web/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
- package/web/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
- package/web/dist/assets/github-light-EUqPIrTm.js +1 -0
- package/web/dist/assets/github-light-default-BXViO-2h.js +1 -0
- package/web/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
- package/web/dist/assets/gleam-CSRkHgEL.js +1 -0
- package/web/dist/assets/glimmer-js-7adTRvH4.js +1 -0
- package/web/dist/assets/glimmer-ts-IdSskDYw.js +1 -0
- package/web/dist/assets/glsl-Qzy53KDt.js +1 -0
- package/web/dist/assets/gn-CAXYAgV-.js +1 -0
- package/web/dist/assets/gnuplot-7GGW24-e.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/graphql-CV_puxbB.js +1 -0
- package/web/dist/assets/groovy-CacY0gHj.js +1 -0
- package/web/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
- package/web/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
- package/web/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
- package/web/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
- package/web/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
- package/web/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
- package/web/dist/assets/hack-C5GPjiLV.js +1 -0
- package/web/dist/assets/haml-BQ5YR1DQ.js +1 -0
- package/web/dist/assets/handlebars-DnhRK9A1.js +1 -0
- package/web/dist/assets/haskell-D8IpX4py.js +1 -0
- package/web/dist/assets/haxe-C8oCnwJJ.js +1 -0
- package/web/dist/assets/hcl-Dh228itO.js +1 -0
- package/web/dist/assets/highlight.worker-CY-ZeDAl.js +1 -0
- package/web/dist/assets/hjson-CxZEssPk.js +1 -0
- package/web/dist/assets/hlsl-Cvrh5tZx.js +1 -0
- package/web/dist/assets/horizon-CE9ld1lL.js +1 -0
- package/web/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
- package/web/dist/assets/houston-CsvMBhTu.js +1 -0
- package/web/dist/assets/html-CmHiEKV9.js +1 -0
- package/web/dist/assets/html-derivative-BXE-rYIK.js +1 -0
- package/web/dist/assets/http-DPjjqtcL.js +1 -0
- package/web/dist/assets/hurl-AMBpSloO.js +1 -0
- package/web/dist/assets/hxml-D7Y24wKF.js +1 -0
- package/web/dist/assets/hy-CZbG8q4J.js +1 -0
- package/web/dist/assets/imba-DsUTQ-LC.js +1 -0
- package/web/dist/assets/index-DNPnl4sh.css +2 -0
- package/web/dist/assets/index-DQh72lEx.js +14 -0
- package/web/dist/assets/ini-B5eOa1yu.js +1 -0
- package/web/dist/assets/java-CEM7Tdw_.js +1 -0
- package/web/dist/assets/javascript-CU3PLowd.js +1 -0
- package/web/dist/assets/jinja-fR0x_9Xy.js +1 -0
- package/web/dist/assets/jison-BoKsxs5M.js +1 -0
- package/web/dist/assets/json-BM2FWsRB.js +1 -0
- package/web/dist/assets/json5-BR5RXkoi.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsonl-CmCQp5Yx.js +1 -0
- package/web/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
- package/web/dist/assets/jssm-DXw9l8Rf.js +1 -0
- package/web/dist/assets/jsx-B5fTecQP.js +1 -0
- package/web/dist/assets/julia-DGyDG39_.js +1 -0
- package/web/dist/assets/just-B-RyLcSR.js +1 -0
- package/web/dist/assets/kanagawa-dragon-Bozd7klA.js +1 -0
- package/web/dist/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
- package/web/dist/assets/kanagawa-wave-BML5mF2J.js +1 -0
- package/web/dist/assets/kdl-CsD5j6eV.js +1 -0
- package/web/dist/assets/kotlin-DhhofPvG.js +1 -0
- package/web/dist/assets/kusto-C7mF5XQf.js +1 -0
- package/web/dist/assets/langs-CciPKqaF.js +1 -0
- package/web/dist/assets/laserwave-C_8bwKvT.js +1 -0
- package/web/dist/assets/latex-CV1pQelm.js +1 -0
- package/web/dist/assets/lean-CewbzKMR.js +1 -0
- package/web/dist/assets/less-DVTAwKKz.js +1 -0
- package/web/dist/assets/lib-DlcVMUqT.js +1 -0
- package/web/dist/assets/light-plus-DVQuIRkW.js +1 -0
- package/web/dist/assets/liquid-9p05RSzQ.js +1 -0
- package/web/dist/assets/llvm-DqHOMS-s.js +1 -0
- package/web/dist/assets/log-BNLmms1o.js +1 -0
- package/web/dist/assets/logo-Cluzi2Zq.js +1 -0
- package/web/dist/assets/lua-CIPVh0S-.js +1 -0
- package/web/dist/assets/luau-VGWVLoqX.js +1 -0
- package/web/dist/assets/make-Dixweg8N.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/marko-h2h6YUMS.js +1 -0
- package/web/dist/assets/material-theme-Bm3Qr25_.js +1 -0
- package/web/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
- package/web/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
- package/web/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
- package/web/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
- package/web/dist/assets/matlab-D7qyCx1q.js +1 -0
- package/web/dist/assets/mdc-yAyZMdoC.js +1 -0
- package/web/dist/assets/mdx-DQZ5AkYe.js +1 -0
- package/web/dist/assets/mermaid-BxpfTIzt.js +1 -0
- package/web/dist/assets/message-font-size-DoaZfwkT.js +1 -0
- package/web/dist/assets/min-dark-BSWPekZh.js +1 -0
- package/web/dist/assets/min-light-DDpmG2fV.js +1 -0
- package/web/dist/assets/mipsasm-BMqwQI7S.js +1 -0
- package/web/dist/assets/mojo-fg40_ntQ.js +1 -0
- package/web/dist/assets/monokai-BscAjiQZ.js +1 -0
- package/web/dist/assets/moonbit-D9W784E_.js +1 -0
- package/web/dist/assets/move-BYnGhZoM.js +1 -0
- package/web/dist/assets/narrat-_X_XdTYD.js +1 -0
- package/web/dist/assets/nextflow-BoTnSBQ_.js +1 -0
- package/web/dist/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
- package/web/dist/assets/nginx-jWINucNT.js +1 -0
- package/web/dist/assets/night-owl-DhmEMT88.js +1 -0
- package/web/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
- package/web/dist/assets/nim-Dtt9DZ0t.js +1 -0
- package/web/dist/assets/nix-Cl_7024t.js +1 -0
- package/web/dist/assets/nord-Cb4Vim4T.js +1 -0
- package/web/dist/assets/nsis-CjNLYCLg.js +1 -0
- package/web/dist/assets/nushell--0fT3PI1.js +1 -0
- package/web/dist/assets/objective-c-D1A_Heim.js +1 -0
- package/web/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/web/dist/assets/ocaml-O90oeIOV.js +1 -0
- package/web/dist/assets/odin-B1RWQWA5.js +1 -0
- package/web/dist/assets/one-dark-pro-C6YyR8OD.js +1 -0
- package/web/dist/assets/one-light-D7Lr4KcI.js +1 -0
- package/web/dist/assets/openscad-BUDT5pXO.js +1 -0
- package/web/dist/assets/org-ClD5huOa.js +1 -0
- package/web/dist/assets/pascal-4ZHwLPI5.js +1 -0
- package/web/dist/assets/perl-BFegxyeJ.js +1 -0
- package/web/dist/assets/php-B-qOA3Y4.js +1 -0
- package/web/dist/assets/pkl-ot-7Btpt.js +1 -0
- package/web/dist/assets/plastic-DQwYfKfQ.js +1 -0
- package/web/dist/assets/plsql-DGHpHOYJ.js +1 -0
- package/web/dist/assets/po-BiJDBrnU.js +1 -0
- package/web/dist/assets/poimandres-DRFjx7u4.js +1 -0
- package/web/dist/assets/polar-C7UOKdEL.js +1 -0
- package/web/dist/assets/postcss-BXeXVLqQ.js +1 -0
- package/web/dist/assets/powerquery-DNMTfnFr.js +1 -0
- package/web/dist/assets/powershell-QtSnmXcu.js +1 -0
- package/web/dist/assets/prisma-DMgNhnYS.js +1 -0
- package/web/dist/assets/prolog-iXnhIJG7.js +1 -0
- package/web/dist/assets/proto-DB4EqR-F.js +1 -0
- package/web/dist/assets/pug-Cq0NBPu-.js +1 -0
- package/web/dist/assets/puppet-CDv2pdJW.js +1 -0
- package/web/dist/assets/purescript-9MfHhQsQ.js +1 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/qml-CYUsa87k.js +1 -0
- package/web/dist/assets/qmldir-DCQb3MpD.js +1 -0
- package/web/dist/assets/qss-Fe1Jh2GI.js +1 -0
- package/web/dist/assets/r-4xJhgbOf.js +1 -0
- package/web/dist/assets/racket-DcIDlBhZ.js +1 -0
- package/web/dist/assets/raku-B3gFvitq.js +1 -0
- package/web/dist/assets/razor-BlHaOHx8.js +1 -0
- package/web/dist/assets/rbs-C0MpOrj4.js +1 -0
- package/web/dist/assets/react-B4UGpcWC.js +1 -0
- package/web/dist/assets/red-CYlAq2b0.js +1 -0
- package/web/dist/assets/reg-CRGYupPL.js +1 -0
- package/web/dist/assets/regexp-BUSxjv2o.js +1 -0
- package/web/dist/assets/rel-BtDbiS_P.js +1 -0
- package/web/dist/assets/riscv-Ckw8ddFX.js +1 -0
- package/web/dist/assets/ron-VUp2lXgN.js +1 -0
- package/web/dist/assets/rose-pine-aMsaMD1D.js +1 -0
- package/web/dist/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
- package/web/dist/assets/rose-pine-moon-DO-qXg-I.js +1 -0
- package/web/dist/assets/rosmsg-CAekHB0j.js +1 -0
- package/web/dist/assets/rst-zjfguEh7.js +1 -0
- package/web/dist/assets/ruby-4oR9waBK.js +1 -0
- package/web/dist/assets/rust-DpiIf5JO.js +1 -0
- package/web/dist/assets/sas-Do4V_2i9.js +1 -0
- package/web/dist/assets/sass-DXrisJhu.js +1 -0
- package/web/dist/assets/scala-BiFt_7mu.js +1 -0
- package/web/dist/assets/scheme-DQCgrYNe.js +1 -0
- package/web/dist/assets/scss-CW6JPtag.js +1 -0
- package/web/dist/assets/sdbl-bTVj8UrX.js +1 -0
- package/web/dist/assets/shaderlab-C4rnI7x0.js +1 -0
- package/web/dist/assets/shellscript-zBmOMFJh.js +1 -0
- package/web/dist/assets/shellsession-DxGUTWaf.js +1 -0
- package/web/dist/assets/slack-dark-DnToyrRv.js +1 -0
- package/web/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
- package/web/dist/assets/smalltalk-BHpQb5nM.js +1 -0
- package/web/dist/assets/smithy-Dgaz7aur.js +1 -0
- package/web/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
- package/web/dist/assets/solarized-dark-b97czJOk.js +1 -0
- package/web/dist/assets/solarized-light-DFPxpIYQ.js +1 -0
- package/web/dist/assets/solidity-Bj7E1mRn.js +1 -0
- package/web/dist/assets/soy-DVtw20Yn.js +1 -0
- package/web/dist/assets/sparql-C0ia5K60.js +1 -0
- package/web/dist/assets/splunk-BC2Px7Mm.js +1 -0
- package/web/dist/assets/sql-DGCM-jag.js +1 -0
- package/web/dist/assets/ssh-config-BgfXC-Er.js +1 -0
- package/web/dist/assets/stata-DfuMOXna.js +1 -0
- package/web/dist/assets/stylus-B6D30XZt.js +1 -0
- package/web/dist/assets/surrealql-CBoRWxfR.js +1 -0
- package/web/dist/assets/svelte-g_uCUem5.js +1 -0
- package/web/dist/assets/swift-CyEgAFGc.js +1 -0
- package/web/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
- package/web/dist/assets/system-verilog-Dh5ihWbf.js +1 -0
- package/web/dist/assets/systemd-BxMlprV5.js +1 -0
- package/web/dist/assets/talonscript-CohzipZa.js +1 -0
- package/web/dist/assets/tasl-DMoTqEGO.js +1 -0
- package/web/dist/assets/tcl-CZd0xW_V.js +1 -0
- package/web/dist/assets/templ-N5C1hJ4Y.js +1 -0
- package/web/dist/assets/terraform-DswuEJGm.js +1 -0
- package/web/dist/assets/tex-mx6xDwuK.js +1 -0
- package/web/dist/assets/themes-CNElGaXw.js +1 -0
- package/web/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
- package/web/dist/assets/toml-CcmNWLt0.js +1 -0
- package/web/dist/assets/ts-tags-C-j71FRb.js +1 -0
- package/web/dist/assets/tsv-sltzmVWM.js +1 -0
- package/web/dist/assets/tsx-z04y7qR9.js +1 -0
- package/web/dist/assets/turtle-ByJddavk.js +1 -0
- package/web/dist/assets/twig-Bz57G_nl.js +1 -0
- package/web/dist/assets/typescript-RMqVTmzR.js +1 -0
- package/web/dist/assets/typespec-BGhR2SqG.js +1 -0
- package/web/dist/assets/typst-BgkqA2Pg.js +1 -0
- package/web/dist/assets/v-DBj4K9d_.js +1 -0
- package/web/dist/assets/vala-zf12oZj6.js +1 -0
- package/web/dist/assets/vb-DOwzaHZ0.js +1 -0
- package/web/dist/assets/verilog-B2gwz-sJ.js +1 -0
- package/web/dist/assets/vesper-DdrHHSXu.js +1 -0
- package/web/dist/assets/vhdl-BroJfC0k.js +1 -0
- package/web/dist/assets/viml-DvXPmvsu.js +1 -0
- package/web/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
- package/web/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
- package/web/dist/assets/vitesse-light-VbXTXTou.js +1 -0
- package/web/dist/assets/vue-CQgvQytb.js +1 -0
- package/web/dist/assets/vue-html-Dhl6fh9l.js +1 -0
- package/web/dist/assets/vue-vine-Dw_uU59K.js +1 -0
- package/web/dist/assets/vyper-CgoNMtux.js +1 -0
- package/web/dist/assets/wasm-ByWQv1Qj.js +1 -0
- package/web/dist/assets/wenyan-C8pVoKbM.js +1 -0
- package/web/dist/assets/wgsl-BsKzXJz4.js +1 -0
- package/web/dist/assets/wikitext-ClFFjSW2.js +1 -0
- package/web/dist/assets/wit-DdvCle-K.js +1 -0
- package/web/dist/assets/wolfram-DLL8P-h_.js +1 -0
- package/web/dist/assets/xml-B49klYQ3.js +1 -0
- package/web/dist/assets/xsl-D7tnD3qq.js +1 -0
- package/web/dist/assets/yaml-DNQ5_xkL.js +1 -0
- package/web/dist/assets/zenscript-BnlCZFoB.js +1 -0
- package/web/dist/assets/zig-CMLA9XwU.js +1 -0
- package/web/dist/icons/apple-touch-icon.png +0 -0
- package/web/dist/icons/badge-96.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon.svg +4 -0
- package/web/dist/icons/maskable-192.png +0 -0
- package/web/dist/icons/maskable-512.png +0 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +20 -0
- package/web/dist/sw.js +176 -0
- package/web/dist/version.json +1 -0
- package/web/src/App.tsx +97 -0
- package/web/src/agent-history-flow.test.ts +191 -0
- package/web/src/agent-history-flow.ts +58 -0
- package/web/src/agent-owners.ts +30 -0
- package/web/src/annotation-selection.test.ts +36 -0
- package/web/src/annotation-selection.ts +123 -0
- package/web/src/annotations.test.ts +279 -0
- package/web/src/annotations.ts +191 -0
- package/web/src/api.ts +27 -0
- package/web/src/auth-events.ts +11 -0
- package/web/src/auth-lifecycle.test.ts +105 -0
- package/web/src/clipboard.ts +32 -0
- package/web/src/components/ActivityNotifier.tsx +22 -0
- package/web/src/components/AnnotationEditor.tsx +135 -0
- package/web/src/components/AnnotationSelection.tsx +122 -0
- package/web/src/components/Automation.tsx +390 -0
- package/web/src/components/CodeView.tsx +121 -0
- package/web/src/components/Composer.tsx +552 -0
- package/web/src/components/ComposerAnnotations.tsx +45 -0
- package/web/src/components/Confirmation.tsx +91 -0
- package/web/src/components/CopyButton.tsx +23 -0
- package/web/src/components/DiffView.tsx +94 -0
- package/web/src/components/DirectoryPicker.tsx +230 -0
- package/web/src/components/FileCopyMenu.tsx +58 -0
- package/web/src/components/FileEntryActions.tsx +95 -0
- package/web/src/components/ImagePreview.tsx +68 -0
- package/web/src/components/Items.tsx +608 -0
- package/web/src/components/Markdown.tsx +73 -0
- package/web/src/components/MessageList.tsx +458 -0
- package/web/src/components/Modal.tsx +72 -0
- package/web/src/components/NotificationEntry.tsx +31 -0
- package/web/src/components/NotificationSettings.tsx +226 -0
- package/web/src/components/NotificationToasts.tsx +59 -0
- package/web/src/components/OpenTabs.tsx +112 -0
- package/web/src/components/Pending.tsx +175 -0
- package/web/src/components/ProjectExtras.tsx +299 -0
- package/web/src/components/ProjectNav.tsx +47 -0
- package/web/src/components/SessionActions.tsx +271 -0
- package/web/src/components/SessionChannels.tsx +288 -0
- package/web/src/components/SessionExtras.tsx +263 -0
- package/web/src/components/SettingsLink.tsx +21 -0
- package/web/src/components/Shell.tsx +320 -0
- package/web/src/components/SvgPreview.tsx +18 -0
- package/web/src/components/TodoPanel.tsx +37 -0
- package/web/src/components/VersionNotice.tsx +40 -0
- package/web/src/components/icons.tsx +43 -0
- package/web/src/diff.ts +62 -0
- package/web/src/drafts.test.ts +80 -0
- package/web/src/drafts.ts +64 -0
- package/web/src/format.ts +5 -0
- package/web/src/highlight.worker.ts +49 -0
- package/web/src/hooks.ts +59 -0
- package/web/src/item-view-state.test.ts +51 -0
- package/web/src/item-view-state.ts +43 -0
- package/web/src/live-history-flow.test.ts +77 -0
- package/web/src/live-merge.test.ts +64 -0
- package/web/src/live-merge.ts +50 -0
- package/web/src/live-message-hydration.test.ts +253 -0
- package/web/src/live.ts +404 -0
- package/web/src/main.tsx +27 -0
- package/web/src/markdown-assets.test.ts +70 -0
- package/web/src/markdown-assets.ts +78 -0
- package/web/src/markdown-html.test.ts +84 -0
- package/web/src/markdown-html.ts +60 -0
- package/web/src/markdown-svg.test.ts +37 -0
- package/web/src/markdown-svg.ts +42 -0
- package/web/src/markdown-tables.test.ts +34 -0
- package/web/src/markdown-tables.ts +17 -0
- package/web/src/message-bodies.ts +117 -0
- package/web/src/message-cache.test.ts +208 -0
- package/web/src/message-cache.ts +158 -0
- package/web/src/message-font-size.test.ts +41 -0
- package/web/src/message-font-size.ts +57 -0
- package/web/src/message-time.test.ts +32 -0
- package/web/src/message-time.ts +26 -0
- package/web/src/notify.ts +132 -0
- package/web/src/open-tabs.test.ts +69 -0
- package/web/src/open-tabs.ts +110 -0
- package/web/src/pages/AgentView.tsx +138 -0
- package/web/src/pages/ChangesView.tsx +193 -0
- package/web/src/pages/FilesView.tsx +406 -0
- package/web/src/pages/HomePage.tsx +96 -0
- package/web/src/pages/LoginPage.tsx +56 -0
- package/web/src/pages/OverviewPage.tsx +124 -0
- package/web/src/pages/ProjectHome.tsx +136 -0
- package/web/src/pages/SessionView.tsx +238 -0
- package/web/src/pages/WorktreesView.tsx +197 -0
- package/web/src/pages/settings/AccountPage.tsx +149 -0
- package/web/src/pages/settings/AgentsPage.tsx +182 -0
- package/web/src/pages/settings/ConfigPage.tsx +181 -0
- package/web/src/pages/settings/GeneralPage.tsx +27 -0
- package/web/src/pages/settings/HooksPage.tsx +244 -0
- package/web/src/pages/settings/McpPage.tsx +310 -0
- package/web/src/pages/settings/MemoryPage.tsx +309 -0
- package/web/src/pages/settings/PermissionsPage.tsx +160 -0
- package/web/src/pages/settings/PluginsPage.tsx +251 -0
- package/web/src/pages/settings/SettingsPage.tsx +141 -0
- package/web/src/pages/settings/SkillsPage.tsx +142 -0
- package/web/src/pages/settings/ui.tsx +165 -0
- package/web/src/preference-document.ts +133 -0
- package/web/src/project-actions.ts +18 -0
- package/web/src/project-files-state.test.ts +49 -0
- package/web/src/project-files-state.ts +64 -0
- package/web/src/project-images.ts +13 -0
- package/web/src/pwa.ts +33 -0
- package/web/src/queryClient.ts +5 -0
- package/web/src/reasoning-preference.test.ts +39 -0
- package/web/src/reasoning-preference.ts +42 -0
- package/web/src/session-confirmations.ts +36 -0
- package/web/src/stream-replay.test.ts +163 -0
- package/web/src/styles.css +231 -0
- package/web/src/tool-result-body.test.ts +115 -0
- package/web/src/transcript.test.ts +186 -0
- package/web/src/transcript.ts +583 -0
- package/web/src/turns.test.ts +338 -0
- package/web/src/turns.ts +178 -0
- package/web/src/ui/Button.tsx +60 -0
- package/web/src/ui/Controls.tsx +227 -0
- package/web/src/ui/Form.tsx +203 -0
- package/web/src/ui/MenuPopover.tsx +144 -0
- package/web/src/ui/Popover.tsx +147 -0
- package/web/src/ui/ResizablePane.tsx +110 -0
- package/web/src/ui/Select.tsx +150 -0
- package/web/src/ui/icons.tsx +270 -0
- package/web/src/unsaved.tsx +99 -0
- package/web/src/user-text.test.ts +60 -0
- package/web/src/user-text.ts +35 -0
- package/web/src/uuid.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { test, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { channelFixture } from "./http-channel-test-fixture.ts";
|
|
6
|
+
|
|
7
|
+
const fixture = await channelFixture();
|
|
8
|
+
after(() => fixture.close());
|
|
9
|
+
const { registerFileRoutes } = await import("./files.ts");
|
|
10
|
+
registerFileRoutes(fixture.app);
|
|
11
|
+
const read = (file: string, full = false) => fixture.app.request(`/api/projects/${fixture.project.id}/file?${new URLSearchParams({ path: file, ...(full ? { full: "1" } : {}) })}`);
|
|
12
|
+
|
|
13
|
+
test("common image and audio formats return native media bytes with seekable ranges", async () => {
|
|
14
|
+
const media = [["picture.PNG", "image/png"], ["picture.webp", "image/webp"], ["voice.mp3", "audio/mpeg"],
|
|
15
|
+
["voice.wav", "audio/wav"], ["voice.wave", "audio/wav"], ["voice.m4a", "audio/mp4"], ["voice.ogg", "audio/ogg"]];
|
|
16
|
+
const bytes = Buffer.from("0123456789abcdef");
|
|
17
|
+
for (const [name, mime] of media) {
|
|
18
|
+
fs.writeFileSync(path.join(fixture.tmp, name!), bytes);
|
|
19
|
+
const response = await read(name!);
|
|
20
|
+
assert.equal(response.headers.get("content-type"), mime);
|
|
21
|
+
assert.equal(response.headers.get("accept-ranges"), "bytes");
|
|
22
|
+
assert.deepEqual(Buffer.from(await response.arrayBuffer()), bytes);
|
|
23
|
+
}
|
|
24
|
+
const range = (value: string) => fixture.app.request(`/api/projects/${fixture.project.id}/file?path=voice.wav`, { headers: { range: value } });
|
|
25
|
+
const part = await range("bytes=3-7");
|
|
26
|
+
assert.equal(part.status, 206);
|
|
27
|
+
assert.equal(part.headers.get("content-range"), "bytes 3-7/16");
|
|
28
|
+
assert.equal(part.headers.get("content-length"), "5");
|
|
29
|
+
assert.equal(await part.text(), "34567");
|
|
30
|
+
assert.equal(await (await range("bytes=-4")).text(), "cdef");
|
|
31
|
+
assert.equal(await (await range("bytes=12-")).text(), "cdef");
|
|
32
|
+
assert.equal((await range("bytes=50-")).status, 416);
|
|
33
|
+
assert.equal((await range("bytes=-0")).status, 416);
|
|
34
|
+
assert.equal((await range("bytes=0-1,4-5")).status, 416);
|
|
35
|
+
assert.equal((await fixture.app.request(`/api/projects/${fixture.project.id}/file?path=voice.wav`, { method: "HEAD" })).headers.get("content-length"), "16");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("file previews stay bounded while explicit full copies include text beyond the preview", async () => {
|
|
39
|
+
const text = "a".repeat(1024 * 1024) + "\n完整的文件结尾。\n";
|
|
40
|
+
fs.writeFileSync(path.join(fixture.tmp, "large.txt"), text);
|
|
41
|
+
const preview = await (await read("large.txt")).json();
|
|
42
|
+
assert.equal(preview.truncated, true);
|
|
43
|
+
assert.equal(preview.content, text.slice(0, 1024 * 1024));
|
|
44
|
+
const complete = await (await read("large.txt", true)).json();
|
|
45
|
+
assert.equal(complete.truncated, false);
|
|
46
|
+
assert.equal(complete.content, text);
|
|
47
|
+
assert.equal(complete.size, Buffer.byteLength(text));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("full copies retain file size, binary and project-boundary protections", async () => {
|
|
51
|
+
const large = path.join(fixture.tmp, "too-large.txt");
|
|
52
|
+
const fd = fs.openSync(large, "w");
|
|
53
|
+
try { fs.ftruncateSync(fd, 50 * 1024 * 1024 + 1); } finally { fs.closeSync(fd); }
|
|
54
|
+
assert.equal((await read("too-large.txt", true)).status, 413);
|
|
55
|
+
fs.writeFileSync(path.join(fixture.tmp, "binary.dat"), Buffer.from([1, 0, 2]));
|
|
56
|
+
const binary = await (await read("binary.dat", true)).json();
|
|
57
|
+
assert.equal(binary.binary, true);
|
|
58
|
+
assert.equal(binary.content, undefined);
|
|
59
|
+
assert.equal((await read("../outside.txt", true)).status, 403);
|
|
60
|
+
fs.writeFileSync(path.join(fixture.tmp, "empty.txt"), "");
|
|
61
|
+
assert.equal((await (await read("empty.txt", true)).json()).content, "");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const transfer = (endpoint: string, dir: string, init?: RequestInit) => fixture.app.request(`/api/projects/${fixture.project.id}/${endpoint}?${new URLSearchParams({ path: dir })}`, init);
|
|
65
|
+
function uploadForm(files: [string, string][], overwrite: string[] = []) {
|
|
66
|
+
const form = new FormData();
|
|
67
|
+
files.forEach(([name, text]) => form.append("files", new File([text], name)));
|
|
68
|
+
overwrite.forEach((name) => form.append("overwrite", name));
|
|
69
|
+
return form;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test("file downloads preserve binary data and use attachment disposition", async () => {
|
|
73
|
+
const name = "下载 #1.bin", bytes = Buffer.from([0, 255, 1, 2, 3]);
|
|
74
|
+
fs.writeFileSync(path.join(fixture.tmp, name), bytes);
|
|
75
|
+
const response = await transfer("file/download", name);
|
|
76
|
+
assert.equal(response.status, 200);
|
|
77
|
+
assert.match(response.headers.get("content-disposition")!, /^attachment;/);
|
|
78
|
+
assert.ok(response.headers.get("content-disposition")!.includes(encodeURIComponent(name)));
|
|
79
|
+
assert.deepEqual(Buffer.from(await response.arrayBuffer()), bytes);
|
|
80
|
+
assert.equal((await transfer("file/download", "../outside.txt")).status, 403);
|
|
81
|
+
assert.equal((await transfer("file/download", "")).status, 400);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("folder uploads publish complete files and require exact overwrite confirmation", async () => {
|
|
85
|
+
const folder = path.join(fixture.tmp, "uploads"); fs.mkdirSync(folder);
|
|
86
|
+
const send = (form: FormData) => transfer("files/upload", "uploads", { method: "POST", body: form });
|
|
87
|
+
const first = await send(uploadForm([["a.txt", "first"], ["empty.bin", ""]]));
|
|
88
|
+
assert.equal(first.status, 200);
|
|
89
|
+
assert.deepEqual((await first.json()).files, ["uploads/a.txt", "uploads/empty.bin"]);
|
|
90
|
+
const conflict = await send(uploadForm([["a.txt", "replacement"], ["new.txt", "new"]]));
|
|
91
|
+
assert.equal(conflict.status, 409);
|
|
92
|
+
assert.deepEqual((await conflict.json()).conflicts, ["a.txt"]);
|
|
93
|
+
assert.equal(fs.readFileSync(path.join(folder, "a.txt"), "utf8"), "first");
|
|
94
|
+
assert.equal(fs.existsSync(path.join(folder, "new.txt")), false, "preflight conflicts do not partially upload a batch");
|
|
95
|
+
const overwritten = await send(uploadForm([["a.txt", "replacement"], ["new.txt", "new"]], ["a.txt"]));
|
|
96
|
+
assert.equal(overwritten.status, 200);
|
|
97
|
+
assert.equal(fs.readFileSync(path.join(folder, "a.txt"), "utf8"), "replacement");
|
|
98
|
+
assert.deepEqual(fs.readdirSync(folder).sort(), ["a.txt", "empty.bin", "new.txt"], "staging files are removed");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("uploads retain project boundaries and refuse directories, duplicates and oversized bodies", async () => {
|
|
102
|
+
const send = (dir: string, form: FormData) => transfer("files/upload", dir, { method: "POST", body: form });
|
|
103
|
+
assert.equal((await send("..", uploadForm([["escape.txt", "bad"]]))).status, 403);
|
|
104
|
+
assert.equal((await send("", uploadForm([["duplicate.txt", "one"], ["duplicate.txt", "two"]]))).status, 400);
|
|
105
|
+
fs.mkdirSync(path.join(fixture.tmp, "occupied.txt"));
|
|
106
|
+
assert.equal((await send("", uploadForm([["occupied.txt", "bad"]], ["occupied.txt"]))).status, 409);
|
|
107
|
+
const outside = path.join(fixture.tmp, "..", `${path.basename(fixture.tmp)}-external`);
|
|
108
|
+
fs.mkdirSync(outside); fs.writeFileSync(path.join(outside, "secret.txt"), "keep");
|
|
109
|
+
fs.symlinkSync(outside, path.join(fixture.tmp, "external"));
|
|
110
|
+
try {
|
|
111
|
+
assert.equal((await send("external", uploadForm([["secret.txt", "bad"]], ["secret.txt"]))).status, 403);
|
|
112
|
+
assert.equal(fs.readFileSync(path.join(outside, "secret.txt"), "utf8"), "keep");
|
|
113
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
114
|
+
const huge = await transfer("files/upload", "", { method: "POST", headers: { "content-length": String(52 * 1024 * 1024) }, body: "x" });
|
|
115
|
+
assert.equal(huge.status, 413);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("overwrite uploads retain executable and private file permissions", async () => {
|
|
119
|
+
for (const [name, mode] of [["run.sh", 0o755], ["private.txt", 0o600], ["owner-only.sh", 0o700]] as const) {
|
|
120
|
+
const file = path.join(fixture.tmp, name);
|
|
121
|
+
fs.writeFileSync(file, "before"); fs.chmodSync(file, mode);
|
|
122
|
+
const response = await transfer("files/upload", "", { method: "POST", body: uploadForm([[name, "after"]], [name]) });
|
|
123
|
+
assert.equal(response.status, 200);
|
|
124
|
+
assert.equal(fs.statSync(file).mode & 0o777, mode);
|
|
125
|
+
assert.equal(fs.readFileSync(file, "utf8"), "after");
|
|
126
|
+
}
|
|
127
|
+
});
|
package/server/files.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import type { Context, Hono } from "hono";
|
|
8
|
+
import type { ContentMatch, DirEntry, DirListing, FileContent, FileMatch } from "../shared/files.ts";
|
|
9
|
+
import { AUDIO_TYPES } from "../shared/files.ts";
|
|
10
|
+
import { FsHttpError, gitInfo, hasRipgrep, projectRoot, resolveSafe, route, run } from "./fsutil.ts";
|
|
11
|
+
import { registerGitRoutes } from "./git.ts";
|
|
12
|
+
import { registerFileTransferRoutes } from "./file-transfers.ts";
|
|
13
|
+
|
|
14
|
+
const MAX_TEXT_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_RAW_BYTES = 50 * 1024 * 1024;
|
|
16
|
+
const MAX_DIR_ENTRIES = 5000;
|
|
17
|
+
const MAX_FILE_LIST = 200_000;
|
|
18
|
+
|
|
19
|
+
export const IMAGE_TYPES: Record<string, string> = {
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".gif": "image/gif",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
".avif": "image/avif",
|
|
26
|
+
".bmp": "image/bmp",
|
|
27
|
+
".ico": "image/x-icon",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function registerFileRoutes(app: Hono) {
|
|
32
|
+
app.get("/api/projects/:pid/files", route(async (c) => c.json(await listDir(projectRoot(c), c.req.query("path")))));
|
|
33
|
+
app.get("/api/projects/:pid/file", route(async (c) => readFile(c, projectRoot(c), c.req.query("path"))));
|
|
34
|
+
app.get(
|
|
35
|
+
"/api/projects/:pid/search/files",
|
|
36
|
+
route(async (c) => c.json(await searchFiles(projectRoot(c), c.req.query("q") ?? "", limitParam(c, 50, 500)))),
|
|
37
|
+
);
|
|
38
|
+
app.get(
|
|
39
|
+
"/api/projects/:pid/search/content",
|
|
40
|
+
route(async (c) => c.json(await searchContent(projectRoot(c), c.req.query("q") ?? "", limitParam(c, 200, 2000)))),
|
|
41
|
+
);
|
|
42
|
+
registerGitRoutes(app);
|
|
43
|
+
registerFileTransferRoutes(app);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function limitParam(c: Context, def: number, max: number) {
|
|
47
|
+
const n = Number(c.req.query("limit"));
|
|
48
|
+
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), max) : def;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------- directory listing ----------
|
|
52
|
+
|
|
53
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
54
|
+
const out = new Array<R>(items.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
57
|
+
while (true) {
|
|
58
|
+
const i = next++;
|
|
59
|
+
if (i >= items.length) return;
|
|
60
|
+
out[i] = await fn(items[i]!);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
await Promise.all(workers);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function listDir(projectRoot: string, rel: string | undefined): Promise<DirListing> {
|
|
68
|
+
const sp = await resolveSafe(projectRoot, rel);
|
|
69
|
+
const st = await fs.stat(sp.abs);
|
|
70
|
+
if (!st.isDirectory()) throw new FsHttpError(400, "不是目录");
|
|
71
|
+
const dirents = (await fs.readdir(sp.abs, { withFileTypes: true })).filter((d) => !(d.name === ".git" && sp.rel === ""));
|
|
72
|
+
const truncated = dirents.length > MAX_DIR_ENTRIES;
|
|
73
|
+
const visible = dirents.slice(0, MAX_DIR_ENTRIES);
|
|
74
|
+
const entries = (
|
|
75
|
+
await mapLimit(visible, 16, async (d): Promise<DirEntry | undefined> => {
|
|
76
|
+
if (d.name === ".git") return undefined;
|
|
77
|
+
const relPath = sp.rel ? `${sp.rel}/${d.name}` : d.name;
|
|
78
|
+
const abs = path.join(sp.abs, d.name);
|
|
79
|
+
const entry: DirEntry = { name: d.name, path: relPath, type: "other" };
|
|
80
|
+
if (d.isSymbolicLink()) {
|
|
81
|
+
entry.link = true;
|
|
82
|
+
try {
|
|
83
|
+
const real = await fs.realpath(abs);
|
|
84
|
+
const r = path.relative(sp.root, real);
|
|
85
|
+
if (r.startsWith("..") || path.isAbsolute(r)) {
|
|
86
|
+
entry.outside = true;
|
|
87
|
+
} else {
|
|
88
|
+
const t = await fs.stat(real);
|
|
89
|
+
entry.type = t.isDirectory() ? "dir" : t.isFile() ? "file" : "other";
|
|
90
|
+
if (t.isFile()) entry.size = t.size;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// broken link
|
|
94
|
+
}
|
|
95
|
+
} else if (d.isDirectory()) {
|
|
96
|
+
entry.type = "dir";
|
|
97
|
+
} else if (d.isFile()) {
|
|
98
|
+
entry.type = "file";
|
|
99
|
+
try {
|
|
100
|
+
entry.size = (await fs.stat(abs)).size;
|
|
101
|
+
} catch {
|
|
102
|
+
// vanished
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return entry;
|
|
106
|
+
})
|
|
107
|
+
).filter((e): e is DirEntry => !!e);
|
|
108
|
+
|
|
109
|
+
const git = await gitInfo(sp.root);
|
|
110
|
+
if (git.isRepo && entries.length) {
|
|
111
|
+
// Paths are relative to the project root, which is the cwd.
|
|
112
|
+
const res = await run("git", ["check-ignore", "-z", "--stdin"], sp.root, {
|
|
113
|
+
input: entries.map((e) => (e.type === "dir" ? `${e.path}/` : e.path)).join("\0") + "\0",
|
|
114
|
+
}).catch(() => null);
|
|
115
|
+
if (res && res.code === 0) {
|
|
116
|
+
const ignored = new Set(res.stdout.split("\0").filter(Boolean).map((p) => p.replace(/\/$/, "")));
|
|
117
|
+
for (const e of entries) if (ignored.has(e.path)) e.ignored = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rank = (e: DirEntry) => (e.type === "dir" ? 0 : 1);
|
|
122
|
+
entries.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
123
|
+
return { path: sp.rel, entries, truncated, git: git.isRepo };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- file content ----------
|
|
127
|
+
|
|
128
|
+
async function readFile(c: Context, projectRoot: string, rel: string | undefined): Promise<Response> {
|
|
129
|
+
const sp = await resolveSafe(projectRoot, rel);
|
|
130
|
+
const st = await fs.stat(sp.abs);
|
|
131
|
+
if (st.isDirectory()) throw new FsHttpError(400, "这是一个目录");
|
|
132
|
+
if (!st.isFile()) throw new FsHttpError(400, "不是普通文件");
|
|
133
|
+
|
|
134
|
+
const extension = path.extname(sp.abs).toLowerCase();
|
|
135
|
+
const mime = IMAGE_TYPES[extension] ?? AUDIO_TYPES[extension];
|
|
136
|
+
if (mime) {
|
|
137
|
+
if (!AUDIO_TYPES[extension] && st.size > MAX_RAW_BYTES) throw new FsHttpError(413, "文件太大");
|
|
138
|
+
const real = await resolveSafe(projectRoot, await fs.realpath(sp.abs));
|
|
139
|
+
const handle = await fs.open(real.abs, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
140
|
+
try {
|
|
141
|
+
const stat = await handle.stat();
|
|
142
|
+
if (!stat.isFile()) throw new FsHttpError(400, "不是普通文件");
|
|
143
|
+
const headers: Record<string, string> = {
|
|
144
|
+
"content-type": mime,
|
|
145
|
+
"content-length": String(stat.size),
|
|
146
|
+
"accept-ranges": "bytes",
|
|
147
|
+
"cache-control": "no-cache",
|
|
148
|
+
"x-content-type-options": "nosniff",
|
|
149
|
+
// SVG can carry script; never let it run with our origin.
|
|
150
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
151
|
+
};
|
|
152
|
+
let start = 0, end = stat.size - 1, status = 200;
|
|
153
|
+
const range = c.req.header("range");
|
|
154
|
+
if (range) {
|
|
155
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
156
|
+
if (match && (match[1] || match[2])) {
|
|
157
|
+
start = match[1] ? Number(match[1]) : Math.max(0, stat.size - Number(match[2]));
|
|
158
|
+
end = match[1] && match[2] ? Math.min(Number(match[2]), stat.size - 1) : stat.size - 1;
|
|
159
|
+
} else start = stat.size;
|
|
160
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= stat.size || end < start) {
|
|
161
|
+
await handle.close();
|
|
162
|
+
return new Response(null, { status: 416, headers: { "content-range": `bytes */${stat.size}`, "accept-ranges": "bytes" } });
|
|
163
|
+
}
|
|
164
|
+
status = 206;
|
|
165
|
+
headers["content-range"] = `bytes ${start}-${end}/${stat.size}`;
|
|
166
|
+
headers["content-length"] = String(end - start + 1);
|
|
167
|
+
}
|
|
168
|
+
if (c.req.method === "HEAD") { await handle.close(); return new Response(null, { status, headers }); }
|
|
169
|
+
return new Response(Readable.toWeb(handle.createReadStream(stat.size ? { start, end } : {})) as ReadableStream<Uint8Array>, { status, headers });
|
|
170
|
+
} catch (error) { await handle.close(); throw error; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Full text is fetched only by an explicit copy action, never by the viewer.
|
|
174
|
+
const full = c.req.query("full") === "1";
|
|
175
|
+
if (full && st.size > MAX_RAW_BYTES) throw new FsHttpError(413, "文件太大,无法复制全文");
|
|
176
|
+
const len = Math.min(st.size, full ? MAX_RAW_BYTES : MAX_TEXT_BYTES);
|
|
177
|
+
const buf = Buffer.alloc(len);
|
|
178
|
+
const fh = await fs.open(sp.abs, "r");
|
|
179
|
+
try {
|
|
180
|
+
let off = 0;
|
|
181
|
+
while (off < len) {
|
|
182
|
+
const { bytesRead } = await fh.read(buf, off, len - off, off);
|
|
183
|
+
if (bytesRead === 0) break;
|
|
184
|
+
off += bytesRead;
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
await fh.close();
|
|
188
|
+
}
|
|
189
|
+
const body: FileContent = { path: sp.rel, size: st.size, truncated: st.size > len, binary: isBinary(buf) };
|
|
190
|
+
if (!body.binary) body.content = new TextDecoder("utf-8").decode(buf);
|
|
191
|
+
return c.json(body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isBinary(buf: Buffer) {
|
|
195
|
+
const n = Math.min(buf.length, 8000);
|
|
196
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------- file name search ----------
|
|
201
|
+
|
|
202
|
+
/** All non-ignored files of the project (relative paths), capped. */
|
|
203
|
+
export async function listProjectFiles(root: string): Promise<string[]> {
|
|
204
|
+
const git = await gitInfo(root);
|
|
205
|
+
if (git.isRepo) {
|
|
206
|
+
const r = await run("git", ["-c", "core.quotepath=off", "ls-files", "-co", "--exclude-standard", "-z"], root, { maxBuffer: 256 * 1024 * 1024 });
|
|
207
|
+
if (r.code === 0) {
|
|
208
|
+
const files = r.stdout.split("\0").filter(Boolean);
|
|
209
|
+
// ls-files lists deleted-but-tracked files too; that is fine for name search.
|
|
210
|
+
return files.slice(0, MAX_FILE_LIST);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (await hasRipgrep()) {
|
|
214
|
+
const lines = await streamLines("rg", ["--files", "--hidden", "--no-ignore-parent", "-g", "!.git", "--null"], root, MAX_FILE_LIST, "\0");
|
|
215
|
+
return lines.lines;
|
|
216
|
+
}
|
|
217
|
+
return walk(root, MAX_FILE_LIST);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function walk(root: string, max: number): Promise<string[]> {
|
|
221
|
+
const out: string[] = [];
|
|
222
|
+
const stack = [""];
|
|
223
|
+
while (stack.length && out.length < max) {
|
|
224
|
+
const rel = stack.pop()!;
|
|
225
|
+
let ds: Dirent[];
|
|
226
|
+
try {
|
|
227
|
+
ds = await fs.readdir(path.join(root, rel), { withFileTypes: true });
|
|
228
|
+
} catch {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
for (const d of ds) {
|
|
232
|
+
if (d.name === ".git" || d.name === "node_modules") continue;
|
|
233
|
+
const p = rel ? `${rel}/${d.name}` : d.name;
|
|
234
|
+
if (d.isDirectory()) stack.push(p);
|
|
235
|
+
else if (d.isFile()) out.push(p);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function searchFiles(projectRoot: string, q: string, limit: number): Promise<{ matches: FileMatch[] }> {
|
|
242
|
+
const query = q.trim().toLowerCase();
|
|
243
|
+
if (!query) return { matches: [] };
|
|
244
|
+
const root = (await resolveSafe(projectRoot, "")).root;
|
|
245
|
+
const files = await listProjectFiles(root);
|
|
246
|
+
const scored: FileMatch[] = [];
|
|
247
|
+
for (const f of files) {
|
|
248
|
+
const s = fuzzyScore(f, query);
|
|
249
|
+
if (s > 0) scored.push({ path: f, score: s });
|
|
250
|
+
}
|
|
251
|
+
scored.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
|
|
252
|
+
return { matches: scored.slice(0, limit) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Higher is better; 0 means no match. Prefers basename hits, then contiguous, then subsequence. */
|
|
256
|
+
export function fuzzyScore(filePath: string, q: string): number {
|
|
257
|
+
const p = filePath.toLowerCase();
|
|
258
|
+
const base = p.slice(p.lastIndexOf("/") + 1);
|
|
259
|
+
if (base === q) return 1000;
|
|
260
|
+
if (base.startsWith(q)) return 800 - base.length;
|
|
261
|
+
const bi = base.indexOf(q);
|
|
262
|
+
if (bi >= 0) return 600 - bi - base.length / 10;
|
|
263
|
+
const pi = p.indexOf(q);
|
|
264
|
+
if (pi >= 0) return 400 - p.length / 10;
|
|
265
|
+
// Subsequence match; reward consecutive characters and word-boundary starts.
|
|
266
|
+
let score = 0;
|
|
267
|
+
let last = -2;
|
|
268
|
+
let j = 0;
|
|
269
|
+
for (let i = 0; i < p.length && j < q.length; i++) {
|
|
270
|
+
if (p[i] !== q[j]) continue;
|
|
271
|
+
score += i === last + 1 ? 5 : 1;
|
|
272
|
+
if (i === 0 || "/._-".includes(p[i - 1]!)) score += 3;
|
|
273
|
+
last = i;
|
|
274
|
+
j++;
|
|
275
|
+
}
|
|
276
|
+
if (j < q.length) return 0;
|
|
277
|
+
return Math.max(1, 100 + score - p.length / 10);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------- content search ----------
|
|
281
|
+
|
|
282
|
+
async function searchContent(projectRoot: string, q: string, limit: number): Promise<{ matches: ContentMatch[]; truncated: boolean }> {
|
|
283
|
+
if (!q.trim()) return { matches: [], truncated: false };
|
|
284
|
+
const root = (await resolveSafe(projectRoot, "")).root;
|
|
285
|
+
const git = await gitInfo(root);
|
|
286
|
+
const matches: ContentMatch[] = [];
|
|
287
|
+
|
|
288
|
+
if (await hasRipgrep()) {
|
|
289
|
+
const args = ["--json", "-F", "-S", "--hidden", "-g", "!.git", "--max-count", "50", "--max-columns", "400", "--max-columns-preview", "--max-filesize", "5M"];
|
|
290
|
+
if (!git.isRepo) args.push("--no-ignore-parent");
|
|
291
|
+
args.push("-e", q, "--", ".");
|
|
292
|
+
let truncated = false;
|
|
293
|
+
await streamLines("rg", args, root, Infinity, "\n", (line) => {
|
|
294
|
+
if (matches.length >= limit) {
|
|
295
|
+
truncated = true;
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
let ev: { type: string; data?: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number; end: number }[] } };
|
|
299
|
+
try {
|
|
300
|
+
ev = JSON.parse(line);
|
|
301
|
+
} catch {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (ev.type !== "match" || !ev.data?.path?.text) return true;
|
|
305
|
+
const text = (ev.data.lines?.text ?? "").replace(/\r?\n$/, "");
|
|
306
|
+
const sm = ev.data.submatches?.[0];
|
|
307
|
+
matches.push({
|
|
308
|
+
path: ev.data.path.text.replace(/^\.\//, ""),
|
|
309
|
+
line: ev.data.line_number ?? 0,
|
|
310
|
+
text,
|
|
311
|
+
// rg offsets are bytes; convert to string indices.
|
|
312
|
+
...(sm ? byteRangeToChars(text, sm.start, sm.end) : {}),
|
|
313
|
+
});
|
|
314
|
+
return true;
|
|
315
|
+
});
|
|
316
|
+
return { matches, truncated };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (git.isRepo) {
|
|
320
|
+
const r = await run("git", ["-c", "core.quotepath=off", "grep", "-n", "-I", "-F", "-i", "--untracked", "--no-color", "-z", "-e", q, "--", "."], root);
|
|
321
|
+
const lines = r.stdout.split("\n").filter(Boolean);
|
|
322
|
+
for (const l of lines.slice(0, limit)) {
|
|
323
|
+
const [file = "", lineNo = "0", ...rest] = l.split("\0");
|
|
324
|
+
matches.push({ path: file, line: Number(lineNo), text: rest.join("\0").slice(0, 400) });
|
|
325
|
+
}
|
|
326
|
+
return { matches, truncated: lines.length > limit };
|
|
327
|
+
}
|
|
328
|
+
throw new FsHttpError(409, "内容搜索需要 ripgrep (rg) 或 git 仓库");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function byteRangeToChars(text: string, start: number, end: number) {
|
|
332
|
+
const bytes = Buffer.from(text, "utf8");
|
|
333
|
+
const s = bytes.subarray(0, start).toString("utf8").length;
|
|
334
|
+
const e = bytes.subarray(0, end).toString("utf8").length;
|
|
335
|
+
return { start: s, end: e };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Spawn a process and hand its output over line by line; stops (and kills it)
|
|
340
|
+
* once `max` lines were collected or `onLine` returns false.
|
|
341
|
+
*/
|
|
342
|
+
function streamLines(
|
|
343
|
+
cmd: string,
|
|
344
|
+
args: string[],
|
|
345
|
+
cwd: string,
|
|
346
|
+
max: number,
|
|
347
|
+
sep: string,
|
|
348
|
+
onLine?: (line: string) => boolean,
|
|
349
|
+
): Promise<{ lines: string[] }> {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
352
|
+
const lines: string[] = [];
|
|
353
|
+
let buf = "";
|
|
354
|
+
let done = false;
|
|
355
|
+
const finish = () => {
|
|
356
|
+
if (done) return;
|
|
357
|
+
done = true;
|
|
358
|
+
child.kill();
|
|
359
|
+
resolve({ lines });
|
|
360
|
+
};
|
|
361
|
+
child.stdout.setEncoding("utf8");
|
|
362
|
+
child.stdout.on("data", (chunk: string) => {
|
|
363
|
+
if (done) return;
|
|
364
|
+
buf += chunk;
|
|
365
|
+
let i: number;
|
|
366
|
+
while ((i = buf.indexOf(sep)) >= 0) {
|
|
367
|
+
const line = buf.slice(0, i);
|
|
368
|
+
buf = buf.slice(i + sep.length);
|
|
369
|
+
if (onLine) {
|
|
370
|
+
if (!onLine(line)) return finish();
|
|
371
|
+
} else {
|
|
372
|
+
lines.push(line);
|
|
373
|
+
if (lines.length >= max) return finish();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
child.on("error", (err) => (done ? undefined : reject(err)));
|
|
378
|
+
child.on("close", () => {
|
|
379
|
+
if (buf && !done) {
|
|
380
|
+
if (onLine) onLine(buf);
|
|
381
|
+
else lines.push(buf);
|
|
382
|
+
}
|
|
383
|
+
finish();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize read-modify-write sequences per file. Sync code used to be atomic
|
|
3
|
+
* because it could not yield; async code must re-acquire that guarantee.
|
|
4
|
+
*/
|
|
5
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
6
|
+
|
|
7
|
+
export function withFileLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
8
|
+
const previous = chains.get(key) ?? Promise.resolve();
|
|
9
|
+
const work = previous.catch(() => {}).then(fn);
|
|
10
|
+
chains.set(key, work);
|
|
11
|
+
return work.finally(() => {
|
|
12
|
+
if (chains.get(key) === work) chains.delete(key);
|
|
13
|
+
});
|
|
14
|
+
}
|
package/server/fsutil.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import { projectPath } from "./sessions.ts";
|
|
6
|
+
|
|
7
|
+
/** Errors from the file/git routes; mapped to JSON responses by the route wrapper. */
|
|
8
|
+
export class FsHttpError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
public status: 400 | 403 | 404 | 409 | 413,
|
|
11
|
+
message: string,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isInside = (root: string, p: string) => {
|
|
18
|
+
const rel = path.relative(root, p);
|
|
19
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SafePath = {
|
|
23
|
+
/** Real (symlink-free) project root */
|
|
24
|
+
root: string;
|
|
25
|
+
/** Lexically resolved absolute path (may not exist when `mustExist` is false) */
|
|
26
|
+
abs: string;
|
|
27
|
+
/** Path relative to the root with forward slashes ("" for the root itself) */
|
|
28
|
+
rel: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a client-supplied path against the project root. Rejects anything that
|
|
33
|
+
* escapes the root lexically (`..`, absolute paths) or physically (symlinks
|
|
34
|
+
* pointing outside). With `mustExist: false` the deepest existing ancestor is checked.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveSafe(projectRoot: string, input: string | undefined, opts: { mustExist?: boolean } = {}): Promise<SafePath> {
|
|
37
|
+
const mustExist = opts.mustExist ?? true;
|
|
38
|
+
const raw = (input ?? "").trim();
|
|
39
|
+
if (raw.includes("\0")) throw new FsHttpError(400, "非法路径");
|
|
40
|
+
let root: string;
|
|
41
|
+
try {
|
|
42
|
+
root = await fs.realpath(projectRoot);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new FsHttpError(404, "项目目录不存在");
|
|
45
|
+
}
|
|
46
|
+
// Absolute paths are accepted only when they name something under the project root
|
|
47
|
+
// (spelled either through the registered path or its realpath).
|
|
48
|
+
let rel = raw;
|
|
49
|
+
if (path.isAbsolute(raw)) {
|
|
50
|
+
const viaRegistered = path.resolve(raw);
|
|
51
|
+
rel = isInside(path.resolve(projectRoot), viaRegistered) ? path.relative(path.resolve(projectRoot), viaRegistered) : path.relative(root, viaRegistered);
|
|
52
|
+
}
|
|
53
|
+
const abs = path.resolve(root, rel);
|
|
54
|
+
if (!isInside(root, abs)) throw new FsHttpError(403, "路径超出项目目录");
|
|
55
|
+
|
|
56
|
+
let probe = abs;
|
|
57
|
+
for (;;) {
|
|
58
|
+
try {
|
|
59
|
+
const real = await fs.realpath(probe);
|
|
60
|
+
if (!isInside(root, real)) throw new FsHttpError(403, "路径超出项目目录");
|
|
61
|
+
break;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof FsHttpError) throw err;
|
|
64
|
+
if (mustExist) throw new FsHttpError(404, "文件不存在");
|
|
65
|
+
const parent = path.dirname(probe);
|
|
66
|
+
if (parent === probe) break;
|
|
67
|
+
probe = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { root, abs, rel: path.relative(root, abs).split(path.sep).join("/") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type RunResult = { code: number; stdout: string; stderr: string };
|
|
74
|
+
|
|
75
|
+
/** Run a command without a shell. Non-zero exit codes resolve (callers decide). */
|
|
76
|
+
export function run(cmd: string, args: string[], cwd: string, opts: { maxBuffer?: number; input?: string } = {}): Promise<RunResult> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const child = execFile(
|
|
79
|
+
cmd,
|
|
80
|
+
args,
|
|
81
|
+
{
|
|
82
|
+
cwd,
|
|
83
|
+
maxBuffer: opts.maxBuffer ?? 32 * 1024 * 1024,
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
// Don't take index.lock for read-only commands: Claude may be running git concurrently.
|
|
86
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C.UTF-8" },
|
|
87
|
+
},
|
|
88
|
+
(err, stdout, stderr) => {
|
|
89
|
+
if (err && typeof (err as NodeJS.ErrnoException).code === "string") {
|
|
90
|
+
// Spawn failure (ENOENT) or maxBuffer exceeded.
|
|
91
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
92
|
+
if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return resolve({ code: -2, stdout, stderr });
|
|
93
|
+
return reject(err);
|
|
94
|
+
}
|
|
95
|
+
const code = err ? ((err as { code?: number }).code ?? 1) : 0;
|
|
96
|
+
resolve({ code: typeof code === "number" ? code : 1, stdout, stderr });
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
if (opts.input !== undefined) child.stdin?.end(opts.input);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let rgAvailable: Promise<boolean> | undefined;
|
|
104
|
+
export function hasRipgrep(): Promise<boolean> {
|
|
105
|
+
rgAvailable ??= run("rg", ["--version"], process.cwd())
|
|
106
|
+
.then((r) => r.code === 0)
|
|
107
|
+
.catch(() => false);
|
|
108
|
+
return rgAvailable;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type GitInfo = { isRepo: false } | { isRepo: true; top: string; prefix: string };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Is the project a git working tree? A project directory that sits inside some
|
|
115
|
+
* other repository but is ignored by it (e.g. a scratch dir) counts as "not a repo".
|
|
116
|
+
*/
|
|
117
|
+
export async function gitInfo(root: string): Promise<GitInfo> {
|
|
118
|
+
const r = await run("git", ["rev-parse", "--show-toplevel", "--show-prefix"], root).catch(() => null);
|
|
119
|
+
if (!r || r.code !== 0) return { isRepo: false };
|
|
120
|
+
const [top = "", prefix = ""] = r.stdout.split("\n");
|
|
121
|
+
if (prefix) {
|
|
122
|
+
const ign = await run("git", ["check-ignore", "-q", "--", "."], root);
|
|
123
|
+
if (ign.code === 0) return { isRepo: false };
|
|
124
|
+
}
|
|
125
|
+
return { isRepo: true, top, prefix };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wrap a handler so FsHttpError becomes a JSON error; other errors go to app.onError. */
|
|
129
|
+
export function route(fn: (c: Context) => Promise<Response>) {
|
|
130
|
+
return async (c: Context) => {
|
|
131
|
+
try {
|
|
132
|
+
return await fn(c);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof FsHttpError) return c.json({ error: err.message }, err.status);
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
/** Registered project root for the `:pid` route param (404 when unknown). */
|
|
142
|
+
export function projectRoot(c: Context): string {
|
|
143
|
+
return projectPath(c.req.param("pid") ?? "");
|
|
144
|
+
}
|