@vantran-se/openchamber-web 1.23.1 → 1.24.4
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/cli.test.js +29 -6
- package/bin/lib/cli-http.js +5 -1
- package/bin/lib/commands-status.js +5 -0
- package/dist/assets/{CodeMirrorCommentWidgets-BOpqXpFU.js → CodeMirrorCommentWidgets-B4a0JkUz.js} +1 -1
- package/dist/assets/DiffView-B2CD9wC8.js +4 -0
- package/dist/assets/FilesView-C4vMFclc.js +13 -0
- package/dist/assets/GitView-Byu2DY7s.js +6 -0
- package/dist/assets/GuestAttachDialog-B808E0-d.js +1 -0
- package/dist/assets/{JsonTreeView-nd_E8eVM.js → JsonTreeView-VW_7czo3.js} +1 -1
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/LinearIssuesView-EzGNCGrd.js +1 -0
- package/dist/assets/{MarkdownImageGallery-CVYds3sQ.js → MarkdownImageGallery-B2YUDFaB.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +12 -0
- package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-CQHBSTEi.js} +1 -1
- package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DCjGhlYV.js} +1 -1
- package/dist/assets/PierreDiffViewer-CEDZNkU2.js +210 -0
- package/dist/assets/PlanView-D3x-pTkZ.js +5 -0
- package/dist/assets/PluginPane-DEcgDQ9h.js +3 -0
- package/dist/assets/SettingsView-CSinuH-1.js +29 -0
- package/dist/assets/{SettingsWindow-BccxY81J.js → SettingsWindow-B5CHoJFZ.js} +1 -1
- package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +1 -0
- package/dist/assets/ToolOutputDialog-tLNWmfcW.js +15 -0
- package/dist/assets/{ToolPartDiffPreview-BFVgk0UK.js → ToolPartDiffPreview-CflzmK-x.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-NU0FHFhT.js} +3 -3
- package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-BZyjhqHv.js} +2 -2
- package/dist/assets/appThemeRegistry-DLicw4dH.js +1 -0
- package/dist/assets/commitAndPush-BDftrjr6.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DlWlEM3x.js} +1 -1
- package/dist/assets/de-Bro6_zyl.js +10 -0
- package/dist/assets/es-Bb9XQZvd.js +16 -0
- package/dist/assets/fr-Dgfzn-IL.js +14 -0
- package/dist/assets/{index-eo1-D_7z.js → index-CtQNmqUL.js} +1 -1
- package/dist/assets/index-DqaMWNrC.css +1 -0
- package/dist/assets/index-Dqc6USl3.js +40 -0
- package/dist/assets/{isDiffPlainText-BvKH712-.js → isDiffPlainText-CO4e5J_S.js} +1 -1
- package/dist/assets/{isFilePlainText-BdVVPukf.js → isFilePlainText-ue6kCou7.js} +1 -1
- package/dist/assets/ja-BYsakGFh.js +16 -0
- package/dist/assets/ko-BSaQz9aN.js +16 -0
- package/dist/assets/main-B62_pjL5.js +2 -0
- package/dist/assets/main-BEdcOI2P.js +896 -0
- package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
- package/dist/assets/markdownCore-CK48LG0z.js +5 -0
- package/dist/assets/miniChat-LT8Rqofr.js +2 -0
- package/dist/assets/mobile-CqarEynh.js +2 -0
- package/dist/assets/{patchFileDiff-B7LjppSS.js → patchFileDiff-D7QVlovD.js} +3 -3
- package/dist/assets/pl-OIviPdoj.js +16 -0
- package/dist/assets/pt-BR-Cltg-E6s.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +2 -0
- package/dist/assets/renderMobileApp-BC1MHg6g.js +25 -0
- package/dist/assets/runtimeEndpointReset-DdrazOVH.js +25 -0
- package/dist/assets/{scheduledTasksApi-hceovDei.js → scheduledTasksApi-B2SWMGEU.js} +1 -1
- package/dist/assets/shikiHighlight-BPu3JiQs.js +1 -0
- package/dist/assets/textMateThemeFromAppTheme-DWqRPo9A.js +1 -0
- package/dist/assets/tr-CQI-KPre.js +7 -0
- package/dist/assets/uk-CqW7R4DA.js +16 -0
- package/dist/assets/useAppFontEffects-ne84JBVL.js +7333 -0
- package/dist/assets/useInlineCommentController-CgS0fnw_.js +2 -0
- package/dist/assets/useRootScrollLock-Cpf1qoGp.js +2 -0
- package/dist/assets/{vendor-base-ui-BY_5qla_.js → vendor-base-ui-CsXYnR5c.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DWA1bm1F.js → vendor-codemirror-language-data-Bx2EHc1O.js} +2 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
- package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
- package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
- package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
- package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
- package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
- package/dist/assets/{vendor-zod-B6551dwG.js → vendor-zod-CgAdXWZO.js} +4 -4
- package/dist/assets/{web-components-B_Z64So5.js → web-components-DxX8bZ1o.js} +2 -2
- package/dist/assets/zh-CN-CVZG_o_J.js +16 -0
- package/dist/assets/zh-TW-D5ZxzCZp.js +16 -0
- package/dist/index.html +6 -4
- package/dist/mini-chat.html +6 -4
- package/dist/mobile.html +6 -4
- package/package.json +12 -9
- package/server/built-in-extensions/registry.json +4 -0
- package/server/index.d.ts +2 -0
- package/server/index.js +44 -10
- package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
- package/server/lib/agent-tool/runtime.js +62 -15
- package/server/lib/agent-tool/runtime.test.js +88 -2
- package/server/lib/changelog/update-notes.js +2 -1
- package/server/lib/changelog/update-notes.test.js +19 -0
- package/server/lib/event-stream/DOCUMENTATION.md +23 -0
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +292 -0
- package/server/lib/event-stream/global-hub.js +53 -23
- package/server/lib/event-stream/global-hub.test.js +160 -0
- package/server/lib/event-stream/global-ws-bridge.js +1 -0
- package/server/lib/event-stream/index.js +4 -0
- package/server/lib/fs/DOCUMENTATION.md +10 -1
- package/server/lib/fs/git-process.test.js +77 -0
- package/server/lib/fs/routes.js +58 -5
- package/server/lib/fs/routes.test.js +116 -0
- package/server/lib/fs/search.js +2 -1
- package/server/lib/git/DOCUMENTATION.md +20 -5
- package/server/lib/git/routes.js +16 -3
- package/server/lib/git/serial-refresh.js +93 -0
- package/server/lib/git/serial-refresh.test.js +112 -0
- package/server/lib/git/service.js +429 -70
- package/server/lib/git/service.test.js +418 -0
- package/server/lib/github/pr-status.js +5 -5
- package/server/lib/github/pr-status.test.js +1 -1
- package/server/lib/github/routes.js +4 -4
- package/server/lib/guests/DOCUMENTATION.md +68 -0
- package/server/lib/guests/auth-store.js +184 -0
- package/server/lib/guests/auth-store.test.js +112 -0
- package/server/lib/guests/background.test.js +83 -0
- package/server/lib/guests/builtins.js +34 -0
- package/server/lib/guests/builtins.test.js +174 -0
- package/server/lib/guests/catalog.js +477 -0
- package/server/lib/guests/catalog.test.js +390 -0
- package/server/lib/guests/clone.js +297 -0
- package/server/lib/guests/extract-zip.js +136 -0
- package/server/lib/guests/extract-zip.test.js +109 -0
- package/server/lib/guests/files.js +308 -0
- package/server/lib/guests/files.test.js +262 -0
- package/server/lib/guests/grant-scope.js +81 -0
- package/server/lib/guests/grant-scope.test.js +75 -0
- package/server/lib/guests/host-session.js +73 -0
- package/server/lib/guests/host-session.test.js +30 -0
- package/server/lib/guests/html-styles.js +6 -0
- package/server/lib/guests/html-styles.test.js +45 -0
- package/server/lib/guests/html-tokens.js +73 -0
- package/server/lib/guests/html-tokens.test.js +53 -0
- package/server/lib/guests/install.js +406 -0
- package/server/lib/guests/install.test.js +338 -0
- package/server/lib/guests/oauth.js +501 -0
- package/server/lib/guests/oauth.test.js +481 -0
- package/server/lib/guests/persist.js +336 -0
- package/server/lib/guests/persist.test.js +288 -0
- package/server/lib/guests/request.js +112 -0
- package/server/lib/guests/request.test.js +206 -0
- package/server/lib/guests/routes.js +838 -0
- package/server/lib/guests/service.js +538 -0
- package/server/lib/guests/service.test.js +277 -0
- package/server/lib/guests/sockets.js +129 -0
- package/server/lib/guests/sockets.test.js +110 -0
- package/server/lib/guests/ssh-install.test.js +188 -0
- package/server/lib/guests/storage.js +68 -0
- package/server/lib/guests/storage.test.js +59 -0
- package/server/lib/guests/updates.js +294 -0
- package/server/lib/guests/updates.test.js +334 -0
- package/server/lib/guests/upload.js +50 -0
- package/server/lib/guests/upload.test.js +131 -0
- package/server/lib/linear/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/DOCUMENTATION.md +10 -1
- package/server/lib/message-queue/runtime.js +21 -1
- package/server/lib/message-queue/runtime.test.js +52 -5
- package/server/lib/notifications/routes.js +5 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
- package/server/lib/openchamber-sessions/routes.js +62 -29
- package/server/lib/openchamber-sessions/routes.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +55 -1
- package/server/lib/opencode/bootstrap-runtime.js +1 -0
- package/server/lib/opencode/core-routes.js +39 -1
- package/server/lib/opencode/feature-routes-runtime.js +15 -0
- package/server/lib/opencode/lifecycle-process.test.js +84 -0
- package/server/lib/opencode/lifecycle.js +54 -67
- package/server/lib/opencode/openchamber-routes.js +72 -14
- package/server/lib/opencode/openchamber-routes.test.js +71 -3
- package/server/lib/opencode/session-runtime.js +68 -0
- package/server/lib/opencode/session-runtime.test.js +43 -0
- package/server/lib/opencode/settings-helpers.js +8 -0
- package/server/lib/opencode/settings-helpers.test.js +13 -1
- package/server/lib/opencode/settings-normalization-runtime.js +2 -0
- package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
- package/server/lib/opencode/settings-registry.json +15 -0
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/shutdown-runtime.test.js +24 -0
- package/server/lib/opencode/theme-archive.js +53 -0
- package/server/lib/opencode/theme-catalog.js +171 -0
- package/server/lib/opencode/theme-catalog.test.js +125 -0
- package/server/lib/opencode/theme-import.test.js +144 -0
- package/server/lib/opencode/theme-runtime.js +100 -28
- package/server/lib/opencode/theme-runtime.test.js +15 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
- package/server/lib/permission-auto-accept/runtime.js +13 -0
- package/server/lib/permission-auto-accept/runtime.test.js +37 -1
- package/server/lib/projects/project-config.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +0 -1
- package/server/lib/quota/providers/index.js +0 -7
- package/server/lib/realtime-proxy-terminal.test.js +150 -0
- package/server/lib/realtime-proxy.js +2 -0
- package/server/lib/routing/DOCUMENTATION.md +83 -0
- package/server/lib/routing/defaults.js +98 -0
- package/server/lib/routing/feature-flag.js +11 -0
- package/server/lib/routing/history.js +34 -0
- package/server/lib/routing/jev.js +98 -0
- package/server/lib/routing/routes.http.test.js +100 -0
- package/server/lib/routing/routes.js +81 -0
- package/server/lib/routing/runtime.js +217 -0
- package/server/lib/routing/runtime.test.js +188 -0
- package/server/lib/routing/store.js +249 -0
- package/server/lib/routing/store.test.js +96 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -2
- package/server/lib/scheduled-tasks/runtime.test.js +14 -0
- package/server/lib/security/request-security.js +9 -5
- package/server/lib/security/request-security.test.js +37 -0
- package/server/lib/small-model/DOCUMENTATION.md +14 -2
- package/server/lib/small-model/call.js +37 -6
- package/server/lib/small-model/call.test.js +182 -0
- package/server/lib/terminal/DOCUMENTATION.md +23 -2
- package/server/lib/terminal/runtime.js +27 -5
- package/server/lib/terminal/runtime.test.js +67 -0
- package/server/lib/terminal/shutdown.js +106 -0
- package/server/lib/terminal/shutdown.test.js +84 -0
- package/server/lib/tts/service.test.js +78 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
- package/server/lib/ui-auth/session-cookie.js +51 -0
- package/server/lib/ui-auth/session-cookie.test.js +46 -0
- package/server/lib/ui-auth/ui-auth.js +56 -17
- package/server/lib/ui-auth/ui-auth.test.js +96 -0
- package/dist/assets/DiffView-CyLmb-wF.js +0 -4
- package/dist/assets/DirtyBranchSwitchDialog-pu5kcoir.js +0 -1
- package/dist/assets/FilesView-WtJB0y_l.js +0 -13
- package/dist/assets/GitView-DkNrV0BK.js +0 -6
- package/dist/assets/LinearIssuesView-B0H0Otii.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DSqVN2d8.js +0 -12
- package/dist/assets/PierreDiffViewer-Bsq9z-sw.js +0 -161
- package/dist/assets/PlanView-CAFziKH5.js +0 -5
- package/dist/assets/SettingsView-BRittavZ.js +0 -28
- package/dist/assets/ToolOutputDialog-B0uYaJio.js +0 -15
- package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
- package/dist/assets/de-c7CYLCAW.js +0 -10
- package/dist/assets/es-BKAVPQmQ.js +0 -16
- package/dist/assets/fr-Bj1glqfV.js +0 -14
- package/dist/assets/index-BYFyOu_D.css +0 -1
- package/dist/assets/index-BfwRClDe.js +0 -40
- package/dist/assets/ja-5jwSIbK0.js +0 -16
- package/dist/assets/ko-BaDbg-Eb.js +0 -16
- package/dist/assets/main-CGG2_uHq.js +0 -2
- package/dist/assets/main-CcrSaL4J.js +0 -930
- package/dist/assets/markdownCore-DsmQr3bt.js +0 -5
- package/dist/assets/miniChat-DEaAhP4b.js +0 -2
- package/dist/assets/mobile-BC3rsvv0.js +0 -2
- package/dist/assets/pl-BFKVBDFu.js +0 -16
- package/dist/assets/pt-BR-qKfHvkDL.js +0 -16
- package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-CR9d1Mr0.js +0 -2
- package/dist/assets/renderMobileApp-Da5dwobZ.js +0 -25
- package/dist/assets/runtimeEndpointReset-CLYVRw2H.js +0 -41
- package/dist/assets/shikiHighlight-D1Z9G58W.js +0 -1
- package/dist/assets/tr-DYXPoEDG.js +0 -7
- package/dist/assets/uk-CwAoloR8.js +0 -16
- package/dist/assets/useAppFontEffects-DeStGcUT.js +0 -7282
- package/dist/assets/useGitComparison-CpxioduU.js +0 -1
- package/dist/assets/useInlineCommentController-gmUnJtrb.js +0 -2
- package/dist/assets/useRootScrollLock--a3cPz8s.js +0 -1
- package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
- package/dist/assets/zh-CN-D9MG0lqF.js +0 -16
- package/dist/assets/zh-TW-1HNK7shh.js +0 -16
- package/server/lib/quota/providers/crof.js +0 -96
- package/server/lib/quota/providers/crof.test.js +0 -98
- /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
|
@@ -8,6 +8,7 @@ import { loadSourceSections, parseSource, sourceKey } from '../walkthrough/sourc
|
|
|
8
8
|
import { registerGitRoutes } from './routes.js';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
unsupportedRepositoryRootReason,
|
|
11
12
|
checkoutBranch,
|
|
12
13
|
checkoutCommit,
|
|
13
14
|
cherryPick,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
getCommitFiles,
|
|
22
23
|
getLog,
|
|
23
24
|
getStatus,
|
|
25
|
+
getTrackingBranch,
|
|
24
26
|
getWorktrees,
|
|
25
27
|
isGitRepository,
|
|
26
28
|
observeWorktreeTopology,
|
|
@@ -37,11 +39,14 @@ import {
|
|
|
37
39
|
unstageFiles,
|
|
38
40
|
applyHunk,
|
|
39
41
|
getDiff,
|
|
42
|
+
getPathDiff,
|
|
43
|
+
revertFile,
|
|
40
44
|
getUntrackedDiffs,
|
|
41
45
|
getFileDiff,
|
|
42
46
|
validateWorktreeCreate,
|
|
43
47
|
parseBranchCreationSource,
|
|
44
48
|
getRangeFiles,
|
|
49
|
+
push,
|
|
45
50
|
} from './service.js';
|
|
46
51
|
|
|
47
52
|
// ---------------------------------------------------------------------------
|
|
@@ -127,6 +132,23 @@ async function createTempRepo() {
|
|
|
127
132
|
// resolveBaseRefForLog
|
|
128
133
|
// ---------------------------------------------------------------------------
|
|
129
134
|
|
|
135
|
+
describe('unsupportedRepositoryRootReason', () => {
|
|
136
|
+
it('rejects a repository rooted at a filesystem root or the home directory', () => {
|
|
137
|
+
const home = path.join(os.tmpdir(), 'unsupported-root-home');
|
|
138
|
+
expect(unsupportedRepositoryRootReason('/', home)).toBe('filesystem-root');
|
|
139
|
+
expect(unsupportedRepositoryRootReason(path.parse(process.cwd()).root, home)).toBe('filesystem-root');
|
|
140
|
+
expect(unsupportedRepositoryRootReason(home, home)).toBe('home');
|
|
141
|
+
expect(unsupportedRepositoryRootReason(`${home}${path.sep}`, home)).toBe('home');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('accepts an ordinary project root, including one directly under home', () => {
|
|
145
|
+
const home = path.join(os.tmpdir(), 'unsupported-root-home');
|
|
146
|
+
expect(unsupportedRepositoryRootReason(path.join(home, 'project'), home)).toBeNull();
|
|
147
|
+
expect(unsupportedRepositoryRootReason(path.join(os.tmpdir(), 'repo'), home)).toBeNull();
|
|
148
|
+
expect(unsupportedRepositoryRootReason('', home)).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
130
152
|
describe('resolveBaseRefForLog', () => {
|
|
131
153
|
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
|
|
132
154
|
const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
|
|
@@ -507,6 +529,131 @@ describe('symlink diffs', () => {
|
|
|
507
529
|
});
|
|
508
530
|
});
|
|
509
531
|
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// Status paths that are not plain files (#3586)
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
describe.runIf(canRunGit())('diffs for status paths that are not plain files', () => {
|
|
537
|
+
const callDiffRoute = async (endpoint, query) => {
|
|
538
|
+
const routes = new Map();
|
|
539
|
+
registerGitRoutes({ get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {} });
|
|
540
|
+
let status = 200;
|
|
541
|
+
let body;
|
|
542
|
+
await routes.get(`/api/git/${endpoint}`)({ query }, {
|
|
543
|
+
status(value) { status = value; return this; },
|
|
544
|
+
json(value) { body = value; },
|
|
545
|
+
});
|
|
546
|
+
return { status, body };
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const createRepositoryWithSubmodule = () => {
|
|
550
|
+
const { repository } = createRepositoryWithRemote();
|
|
551
|
+
const library = createTempDir();
|
|
552
|
+
runGit(library, ['init', '-b', 'main']);
|
|
553
|
+
runGit(library, ['config', 'user.email', 'test@example.com']);
|
|
554
|
+
runGit(library, ['config', 'user.name', 'Test']);
|
|
555
|
+
fs.writeFileSync(path.join(library, 'lib.txt'), 'lib\n');
|
|
556
|
+
runGit(library, ['add', '.']);
|
|
557
|
+
runGit(library, ['commit', '-m', 'lib']);
|
|
558
|
+
runGit(repository, ['-c', 'protocol.file.allow=always', 'submodule', 'add', library, 'sub']);
|
|
559
|
+
runGit(repository, ['commit', '-m', 'add submodule']);
|
|
560
|
+
return { repository, recorded: runGit(repository, ['rev-parse', 'HEAD:sub']).trim() };
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
it('answers 404 with a code when a listed file is gone before its diff is requested', async () => {
|
|
564
|
+
const { repository } = createRepositoryWithRemote();
|
|
565
|
+
for (const endpoint of ['diff', 'file-diff']) {
|
|
566
|
+
const { status, body } = await callDiffRoute(endpoint, { directory: repository, path: 'removed.txt' });
|
|
567
|
+
expect(status).toBe(404);
|
|
568
|
+
expect(body).toEqual({ code: 'path_not_found', error: 'Path not found in working tree, index, or HEAD: removed.txt' });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('answers 422 for a nested repository that status lists as a directory', async () => {
|
|
573
|
+
const { repository } = createRepositoryWithRemote();
|
|
574
|
+
const nested = path.join(repository, 'nested');
|
|
575
|
+
fs.mkdirSync(nested);
|
|
576
|
+
runGit(nested, ['init', '-b', 'main']);
|
|
577
|
+
fs.writeFileSync(path.join(nested, 'inner.txt'), 'inner\n');
|
|
578
|
+
expect((await getStatus(repository)).files).toContainEqual(expect.objectContaining({ path: 'nested/' }));
|
|
579
|
+
|
|
580
|
+
for (const endpoint of ['diff', 'file-diff']) {
|
|
581
|
+
const { status, body } = await callDiffRoute(endpoint, { directory: repository, path: 'nested/' });
|
|
582
|
+
expect(status).toBe(422);
|
|
583
|
+
expect(body.code).toBe('nested_repository');
|
|
584
|
+
}
|
|
585
|
+
await expect(revertFile(repository, 'nested/')).rejects.toMatchObject({ code: 'nested_repository' });
|
|
586
|
+
expect(fs.existsSync(path.join(nested, 'inner.txt'))).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('describes a submodule whose checked-out commit moved', async () => {
|
|
590
|
+
const { repository, recorded } = createRepositoryWithSubmodule();
|
|
591
|
+
const submodulePath = path.join(repository, 'sub');
|
|
592
|
+
runGit(submodulePath, ['config', 'user.email', 'test@example.com']);
|
|
593
|
+
runGit(submodulePath, ['config', 'user.name', 'Test']);
|
|
594
|
+
runGit(submodulePath, ['commit', '--allow-empty', '-m', 'moved']);
|
|
595
|
+
const moved = runGit(submodulePath, ['rev-parse', 'HEAD']).trim();
|
|
596
|
+
const submodule = { headCommit: recorded, indexCommit: recorded, worktreeCommit: moved, hasTrackedChanges: false, hasUntrackedFiles: false, hasConflict: false };
|
|
597
|
+
|
|
598
|
+
const patch = await callDiffRoute('diff', { directory: repository, path: 'sub' });
|
|
599
|
+
expect(patch.status).toBe(200);
|
|
600
|
+
expect(patch.body.diff).toContain(`+Subproject commit ${moved}`);
|
|
601
|
+
expect(patch.body.submodule).toEqual(submodule);
|
|
602
|
+
|
|
603
|
+
const split = await callDiffRoute('file-diff', { directory: repository, path: 'sub' });
|
|
604
|
+
expect(split.body).toEqual({
|
|
605
|
+
original: `Subproject commit ${recorded}\n`,
|
|
606
|
+
modified: `Subproject commit ${moved}\n`,
|
|
607
|
+
path: 'sub',
|
|
608
|
+
isBinary: false,
|
|
609
|
+
submodule,
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('reports a submodule merge conflict instead of an unchanged commit', async () => {
|
|
614
|
+
const { repository } = createRepositoryWithRemote();
|
|
615
|
+
const library = createTempDir();
|
|
616
|
+
runGit(library, ['init', '-b', 'main']);
|
|
617
|
+
runGit(library, ['config', 'user.email', 'test@example.com']);
|
|
618
|
+
runGit(library, ['config', 'user.name', 'Test']);
|
|
619
|
+
runGit(library, ['commit', '--allow-empty', '-m', 'base']);
|
|
620
|
+
runGit(library, ['checkout', '-b', 'left']);
|
|
621
|
+
runGit(library, ['commit', '--allow-empty', '-m', 'left']);
|
|
622
|
+
runGit(library, ['checkout', '-b', 'right', 'main']);
|
|
623
|
+
runGit(library, ['commit', '--allow-empty', '-m', 'right']);
|
|
624
|
+
runGit(library, ['checkout', 'main']);
|
|
625
|
+
runGit(repository, ['-c', 'protocol.file.allow=always', 'submodule', 'add', library, 'sub']);
|
|
626
|
+
runGit(repository, ['commit', '-m', 'add submodule']);
|
|
627
|
+
const submodulePath = path.join(repository, 'sub');
|
|
628
|
+
for (const [branch, commit] of [['other', 'right'], ['next', 'left']]) {
|
|
629
|
+
if (branch === 'other') runGit(repository, ['checkout', '-b', 'other']);
|
|
630
|
+
else runGit(repository, ['checkout', 'next']);
|
|
631
|
+
runGit(submodulePath, ['checkout', commit]);
|
|
632
|
+
runGit(repository, ['add', 'sub']);
|
|
633
|
+
runGit(repository, ['commit', '-m', `move to ${commit}`]);
|
|
634
|
+
}
|
|
635
|
+
expect(() => runGit(repository, ['merge', 'other'])).toThrow();
|
|
636
|
+
|
|
637
|
+
const { submodule } = await getPathDiff(repository, { path: 'sub' });
|
|
638
|
+
expect(submodule).toMatchObject({
|
|
639
|
+
headCommit: runGit(repository, ['rev-parse', 'HEAD:sub']).trim(),
|
|
640
|
+
indexCommit: null,
|
|
641
|
+
hasConflict: true,
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('reports untracked files inside a submodule even though its patch is empty', async () => {
|
|
646
|
+
const { repository, recorded } = createRepositoryWithSubmodule();
|
|
647
|
+
fs.writeFileSync(path.join(repository, 'sub', 'scratch.txt'), 'scratch\n');
|
|
648
|
+
|
|
649
|
+
const result = await getPathDiff(repository, { path: 'sub' });
|
|
650
|
+
expect(result).toEqual({
|
|
651
|
+
diff: '',
|
|
652
|
+
submodule: { headCommit: recorded, indexCommit: recorded, worktreeCommit: recorded, hasTrackedChanges: false, hasUntrackedFiles: true, hasConflict: false },
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
510
657
|
// ---------------------------------------------------------------------------
|
|
511
658
|
// getStatus
|
|
512
659
|
// ---------------------------------------------------------------------------
|
|
@@ -603,6 +750,142 @@ describe('getStatus', () => {
|
|
|
603
750
|
process.chdir(previousCwd);
|
|
604
751
|
}
|
|
605
752
|
});
|
|
753
|
+
|
|
754
|
+
it('scopes diff stats by staged and working instead of combining a partially staged file', async () => {
|
|
755
|
+
if (!canRunGit()) return;
|
|
756
|
+
|
|
757
|
+
const repo = createTempDir();
|
|
758
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
759
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
760
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
761
|
+
const file = 'test.txt';
|
|
762
|
+
const filePath = path.join(repo, file);
|
|
763
|
+
fs.writeFileSync(filePath, 'one\ntwo\nthree\n');
|
|
764
|
+
runGit(repo, ['add', file]);
|
|
765
|
+
runGit(repo, ['commit', '-m', 'initial']);
|
|
766
|
+
|
|
767
|
+
// Stage one new line, then keep editing without staging another.
|
|
768
|
+
fs.writeFileSync(filePath, 'one\ntwo\nthree\nstaged\n');
|
|
769
|
+
runGit(repo, ['add', file]);
|
|
770
|
+
fs.writeFileSync(filePath, 'one\ntwo\nthree\nstaged\nworking\n');
|
|
771
|
+
|
|
772
|
+
const status = await getStatus(repo);
|
|
773
|
+
|
|
774
|
+
expect(status.diffStats.staged[file]).toEqual({ insertions: 1, deletions: 0 });
|
|
775
|
+
expect(status.diffStats.working[file]).toEqual({ insertions: 1, deletions: 0 });
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('scopes untracked files to working stats and staged additions to staged stats', async () => {
|
|
779
|
+
if (!canRunGit()) return;
|
|
780
|
+
|
|
781
|
+
const repo = createTempDir();
|
|
782
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
783
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
784
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
785
|
+
fs.writeFileSync(path.join(repo, 'tracked.txt'), 'tracked\n');
|
|
786
|
+
runGit(repo, ['add', 'tracked.txt']);
|
|
787
|
+
runGit(repo, ['commit', '-m', 'initial']);
|
|
788
|
+
|
|
789
|
+
fs.writeFileSync(path.join(repo, 'untracked.txt'), 'a\nb\n');
|
|
790
|
+
fs.writeFileSync(path.join(repo, 'staged.txt'), 'c\nd\ne\n');
|
|
791
|
+
runGit(repo, ['add', 'staged.txt']);
|
|
792
|
+
|
|
793
|
+
const status = await getStatus(repo);
|
|
794
|
+
|
|
795
|
+
expect(status.diffStats.working['untracked.txt']).toEqual({ insertions: 2, deletions: 0 });
|
|
796
|
+
expect(status.diffStats.staged['staged.txt']).toEqual({ insertions: 3, deletions: 0 });
|
|
797
|
+
expect(status.diffStats.working['staged.txt']).toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
describe('push', () => {
|
|
802
|
+
it('publishes the current branch with an upstream and leaves other local branches alone', async () => {
|
|
803
|
+
if (!canRunGit()) return;
|
|
804
|
+
|
|
805
|
+
const remote = createTempDir();
|
|
806
|
+
const repo = createTempDir();
|
|
807
|
+
runGit(remote, ['init', '--bare']);
|
|
808
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
809
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
810
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
811
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
812
|
+
runGit(repo, ['add', 'README.md']);
|
|
813
|
+
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
814
|
+
runGit(repo, ['remote', 'add', 'fork', remote]);
|
|
815
|
+
runGit(repo, ['branch', 'unrelated']);
|
|
816
|
+
runGit(repo, ['checkout', '-b', 'feature']);
|
|
817
|
+
fs.writeFileSync(path.join(repo, 'feature.txt'), 'published\n');
|
|
818
|
+
runGit(repo, ['add', 'feature.txt']);
|
|
819
|
+
runGit(repo, ['commit', '-m', 'Add feature']);
|
|
820
|
+
|
|
821
|
+
const published = await push(repo, { remote: 'fork' });
|
|
822
|
+
expect(published.pushed).toEqual([{ local: 'refs/heads/feature', remote: 'fork' }]);
|
|
823
|
+
|
|
824
|
+
expect(runGit(repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).trim()).toBe('fork/feature');
|
|
825
|
+
expect(runGit(remote, ['rev-parse', 'refs/heads/feature']).trim()).toBe(runGit(repo, ['rev-parse', 'HEAD']).trim());
|
|
826
|
+
expect(() => runGit(remote, ['rev-parse', 'refs/heads/unrelated'])).toThrow();
|
|
827
|
+
expect(() => runGit(remote, ['rev-parse', 'refs/heads/main'])).toThrow();
|
|
828
|
+
|
|
829
|
+
expect((await push(repo)).pushed).toEqual([]);
|
|
830
|
+
fs.appendFileSync(path.join(repo, 'feature.txt'), 'next commit\n');
|
|
831
|
+
runGit(repo, ['commit', '-am', 'Update feature']);
|
|
832
|
+
expect((await push(repo)).pushed).toEqual([{ local: 'refs/heads/feature', remote: 'fork' }]);
|
|
833
|
+
expect(runGit(remote, ['rev-parse', 'feature']).trim()).toBe(runGit(repo, ['rev-parse', 'HEAD']).trim());
|
|
834
|
+
|
|
835
|
+
runGit(repo, ['reset', '--hard', 'HEAD~1']);
|
|
836
|
+
await expect(push(repo)).rejects.toThrow();
|
|
837
|
+
expect((await push(repo, { options: ['--force-with-lease'] })).pushed)
|
|
838
|
+
.toEqual([{ local: 'refs/heads/feature', remote: 'fork' }]);
|
|
839
|
+
expect(runGit(remote, ['rev-parse', 'feature']).trim()).toBe(runGit(repo, ['rev-parse', 'HEAD']).trim());
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it.each(['remote.pushDefault', 'branch.next.pushRemote'])('preserves the %s destination independently of the fetch remote', async (key) => {
|
|
843
|
+
const { repository, remote: upstream } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'next' });
|
|
844
|
+
const fork = createTempDir();
|
|
845
|
+
runGit(fork, ['init', '--bare']);
|
|
846
|
+
runGit(repository, ['remote', 'add', 'fork', fork]);
|
|
847
|
+
runGit(repository, ['branch', '--set-upstream-to=upstream/next']);
|
|
848
|
+
if (key === 'branch.next.pushRemote') runGit(repository, ['config', 'remote.pushDefault', 'upstream']);
|
|
849
|
+
runGit(repository, ['config', key, 'fork']);
|
|
850
|
+
const upstreamHead = runGit(upstream, ['rev-parse', 'next']).trim();
|
|
851
|
+
fs.appendFileSync(path.join(repository, 'README.md'), 'fork change\n');
|
|
852
|
+
runGit(repository, ['commit', '-am', 'Change for fork']);
|
|
853
|
+
|
|
854
|
+
expect((await push(repository)).pushed).toEqual([{ local: 'refs/heads/next', remote: 'fork' }]);
|
|
855
|
+
expect(runGit(fork, ['rev-parse', 'next']).trim()).toBe(runGit(repository, ['rev-parse', 'HEAD']).trim());
|
|
856
|
+
expect(runGit(upstream, ['rev-parse', 'next']).trim()).toBe(upstreamHead);
|
|
857
|
+
expect(readBranchConfig(repository, 'next', 'remote')).toBe('upstream');
|
|
858
|
+
expect((await push(repository)).pushed).toEqual([]);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it.each(['remote.pushDefault', 'branch.next.pushRemote'])('uses %s for first publication without an upstream', async (key) => {
|
|
862
|
+
const { repository, remote: origin } = createRepositoryWithRemote();
|
|
863
|
+
const fork = createTempDir();
|
|
864
|
+
runGit(fork, ['init', '--bare']);
|
|
865
|
+
runGit(repository, ['remote', 'add', 'fork', fork]);
|
|
866
|
+
runGit(repository, ['config', key, 'fork']);
|
|
867
|
+
runGit(repository, ['config', 'push.autoSetupRemote', 'false']);
|
|
868
|
+
|
|
869
|
+
expect((await push(repository)).pushed).toEqual([{ local: 'refs/heads/next', remote: 'fork' }]);
|
|
870
|
+
expect(readBranchConfig(repository, 'next', 'remote')).toBe('fork');
|
|
871
|
+
expect(() => runGit(origin, ['rev-parse', 'refs/heads/next'])).toThrow();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('lets an explicit push remote override configured destinations', async () => {
|
|
875
|
+
const { repository, remote: origin } = createRepositoryWithRemote({ defaultBranch: 'next' });
|
|
876
|
+
const fork = createTempDir();
|
|
877
|
+
runGit(fork, ['init', '--bare']);
|
|
878
|
+
runGit(repository, ['remote', 'add', 'fork', fork]);
|
|
879
|
+
runGit(repository, ['branch', '--set-upstream-to=origin/next']);
|
|
880
|
+
runGit(repository, ['config', 'branch.next.pushRemote', 'fork']);
|
|
881
|
+
fs.appendFileSync(path.join(repository, 'README.md'), 'origin change\n');
|
|
882
|
+
runGit(repository, ['commit', '-am', 'Change for origin']);
|
|
883
|
+
|
|
884
|
+
expect((await push(repository, { remote: 'origin' })).pushed)
|
|
885
|
+
.toEqual([{ local: 'refs/heads/next', remote: 'origin' }]);
|
|
886
|
+
expect(runGit(origin, ['rev-parse', 'next']).trim()).toBe(runGit(repository, ['rev-parse', 'HEAD']).trim());
|
|
887
|
+
expect(() => runGit(fork, ['rev-parse', 'next'])).toThrow();
|
|
888
|
+
});
|
|
606
889
|
});
|
|
607
890
|
|
|
608
891
|
// ---------------------------------------------------------------------------
|
|
@@ -2261,3 +2544,138 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
|
|
2261
2544
|
expect(copyEntry.path).toBe('copied destination.md');
|
|
2262
2545
|
});
|
|
2263
2546
|
});
|
|
2547
|
+
|
|
2548
|
+
// ---------------------------------------------------------------------------
|
|
2549
|
+
// getTrackingBranch
|
|
2550
|
+
// ---------------------------------------------------------------------------
|
|
2551
|
+
|
|
2552
|
+
describe('getTrackingBranch', () => {
|
|
2553
|
+
const createCommittedRepo = () => {
|
|
2554
|
+
const repo = createTempDir();
|
|
2555
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
2556
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
2557
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
2558
|
+
runGit(repo, ['commit', '--allow-empty', '-m', 'Initial commit']);
|
|
2559
|
+
return repo;
|
|
2560
|
+
};
|
|
2561
|
+
|
|
2562
|
+
it('reports the same upstream name as status, including a gone upstream', async () => {
|
|
2563
|
+
if (!canRunGit()) return;
|
|
2564
|
+
|
|
2565
|
+
const repo = createCommittedRepo();
|
|
2566
|
+
await expect(getTrackingBranch(repo)).resolves.toBeNull();
|
|
2567
|
+
|
|
2568
|
+
runGit(repo, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']);
|
|
2569
|
+
runGit(repo, ['config', 'branch.main.remote', 'origin']);
|
|
2570
|
+
runGit(repo, ['config', 'branch.main.merge', 'refs/heads/main']);
|
|
2571
|
+
await expect(getTrackingBranch(repo)).resolves.toBe('origin/main');
|
|
2572
|
+
expect((await getStatus(repo)).tracking).toBe('origin/main');
|
|
2573
|
+
|
|
2574
|
+
runGit(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD']);
|
|
2575
|
+
await expect(getTrackingBranch(repo)).resolves.toBe('origin/main');
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
it('is null for a detached HEAD and outside a repository', async () => {
|
|
2579
|
+
if (!canRunGit()) return;
|
|
2580
|
+
|
|
2581
|
+
const repo = createCommittedRepo();
|
|
2582
|
+
runGit(repo, ['checkout', '--detach']);
|
|
2583
|
+
await expect(getTrackingBranch(repo)).resolves.toBeNull();
|
|
2584
|
+
await expect(getTrackingBranch(createTempDir())).resolves.toBeNull();
|
|
2585
|
+
});
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
describe('getStatus concurrency', () => {
|
|
2589
|
+
it('answers overlapping reads of one repository and reflects changes made while a read ran', async () => {
|
|
2590
|
+
if (!canRunGit()) return;
|
|
2591
|
+
|
|
2592
|
+
const repo = createTempDir();
|
|
2593
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
2594
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
2595
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
2596
|
+
runGit(repo, ['commit', '--allow-empty', '-m', 'Initial commit']);
|
|
2597
|
+
|
|
2598
|
+
const first = getStatus(repo);
|
|
2599
|
+
fs.writeFileSync(path.join(repo, 'late.txt'), 'added after the first read was admitted\n');
|
|
2600
|
+
const second = getStatus(repo, { mode: 'light' });
|
|
2601
|
+
const third = getStatus(repo);
|
|
2602
|
+
|
|
2603
|
+
const [firstStatus, secondStatus, thirdStatus] = await Promise.all([first, second, third]);
|
|
2604
|
+
expect(firstStatus.current).toBe('main');
|
|
2605
|
+
expect(secondStatus.files.map((file) => file.path)).toContain('late.txt');
|
|
2606
|
+
expect(thirdStatus.files.map((file) => file.path)).toContain('late.txt');
|
|
2607
|
+
// The follow-up run served both later callers at the widest requested mode.
|
|
2608
|
+
expect(secondStatus.diffStats).toBeDefined();
|
|
2609
|
+
expect(thirdStatus.diffStats).toBeDefined();
|
|
2610
|
+
});
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
describe('getStatus untracked directories', () => {
|
|
2614
|
+
const callDiffRoute = async (endpoint, query) => {
|
|
2615
|
+
const routes = new Map();
|
|
2616
|
+
registerGitRoutes({ get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {} });
|
|
2617
|
+
let status = 200;
|
|
2618
|
+
let body;
|
|
2619
|
+
await routes.get(`/api/git/${endpoint}`)({ query }, {
|
|
2620
|
+
status(value) { status = value; return this; },
|
|
2621
|
+
json(value) { body = value; },
|
|
2622
|
+
});
|
|
2623
|
+
return { status, body };
|
|
2624
|
+
};
|
|
2625
|
+
|
|
2626
|
+
const createCommittedRepo = () => {
|
|
2627
|
+
const repo = createTempDir();
|
|
2628
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
2629
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
2630
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
2631
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
2632
|
+
runGit(repo, ['add', 'README.md']);
|
|
2633
|
+
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
2634
|
+
return repo;
|
|
2635
|
+
};
|
|
2636
|
+
|
|
2637
|
+
const writeFiles = (root, count) => {
|
|
2638
|
+
fs.mkdirSync(root, { recursive: true });
|
|
2639
|
+
for (let index = 0; index < count; index += 1) {
|
|
2640
|
+
fs.writeFileSync(path.join(root, `file-${String(index).padStart(5, '0')}.txt`), `${index}\n`);
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
|
|
2644
|
+
it('lists the files of an ordinary new directory one by one', async () => {
|
|
2645
|
+
if (!canRunGit()) return;
|
|
2646
|
+
|
|
2647
|
+
const repo = createCommittedRepo();
|
|
2648
|
+
writeFiles(path.join(repo, 'feature', 'deep'), 3);
|
|
2649
|
+
fs.writeFileSync(path.join(repo, 'loose.txt'), 'loose\n');
|
|
2650
|
+
|
|
2651
|
+
const paths = (await getStatus(repo)).files.map((file) => file.path);
|
|
2652
|
+
expect(paths).toEqual([
|
|
2653
|
+
'feature/deep/file-00000.txt',
|
|
2654
|
+
'feature/deep/file-00001.txt',
|
|
2655
|
+
'feature/deep/file-00002.txt',
|
|
2656
|
+
'loose.txt',
|
|
2657
|
+
]);
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
it('keeps a directory with more than a thousand new files as one entry the diff routes explain', async () => {
|
|
2661
|
+
if (!canRunGit()) return;
|
|
2662
|
+
|
|
2663
|
+
const repo = createCommittedRepo();
|
|
2664
|
+
writeFiles(path.join(repo, 'node_modules', 'pkg'), 1001);
|
|
2665
|
+
writeFiles(path.join(repo, 'small'), 2);
|
|
2666
|
+
|
|
2667
|
+
const status = await getStatus(repo);
|
|
2668
|
+
expect(status.files.map((file) => file.path)).toEqual([
|
|
2669
|
+
'node_modules/',
|
|
2670
|
+
'small/file-00000.txt',
|
|
2671
|
+
'small/file-00001.txt',
|
|
2672
|
+
]);
|
|
2673
|
+
expect(status.files[0]).toMatchObject({ index: '?', working_dir: '?' });
|
|
2674
|
+
|
|
2675
|
+
for (const endpoint of ['diff', 'file-diff']) {
|
|
2676
|
+
const { status: httpStatus, body } = await callDiffRoute(endpoint, { directory: repo, path: 'node_modules/' });
|
|
2677
|
+
expect(httpStatus).toBe(422);
|
|
2678
|
+
expect(body).toEqual({ code: 'untracked_directory', error: 'Path is a directory of untracked files: node_modules/' });
|
|
2679
|
+
}
|
|
2680
|
+
});
|
|
2681
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises';
|
|
2
|
-
import { getRemotes,
|
|
2
|
+
import { getRemotes, getTrackingBranch } from '../git/index.js';
|
|
3
3
|
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
|
|
4
4
|
import { noteIfGitHubRateLimit } from './rate-limit.js';
|
|
5
5
|
|
|
@@ -644,13 +644,13 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote
|
|
|
644
644
|
const normalizedBranch = normalizeText(branch);
|
|
645
645
|
const normalizedRemoteName = normalizeText(remoteName) || 'origin';
|
|
646
646
|
|
|
647
|
-
const [
|
|
648
|
-
|
|
647
|
+
const [tracking, remotes] = await Promise.all([
|
|
648
|
+
getTrackingBranch(directory).catch(() => null),
|
|
649
649
|
getRemotes(directory).catch(() => []),
|
|
650
650
|
]);
|
|
651
651
|
|
|
652
|
-
const trackingRemoteName = parseTrackingRemoteName(
|
|
653
|
-
const trackingBranchName = parseTrackingBranchName(
|
|
652
|
+
const trackingRemoteName = parseTrackingRemoteName(tracking);
|
|
653
|
+
const trackingBranchName = parseTrackingBranchName(tracking);
|
|
654
654
|
const branchCandidates = [];
|
|
655
655
|
pushUnique(branchCandidates, normalizedBranch);
|
|
656
656
|
pushUnique(branchCandidates, trackingBranchName);
|
|
@@ -781,14 +781,14 @@ export function registerGitHubRoutes(app) {
|
|
|
781
781
|
// Determine the source remote for the head branch
|
|
782
782
|
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
|
|
783
783
|
let sourceRemote = headRemote;
|
|
784
|
-
const {
|
|
784
|
+
const { getTrackingBranch, getRemotes } = await import('../git/index.js');
|
|
785
785
|
|
|
786
786
|
// If no explicit headRemote, check the branch's tracking info
|
|
787
787
|
if (!sourceRemote) {
|
|
788
|
-
const
|
|
789
|
-
if (
|
|
788
|
+
const tracking = await getTrackingBranch(directory).catch(() => null);
|
|
789
|
+
if (tracking) {
|
|
790
790
|
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
|
|
791
|
-
const trackingRemote =
|
|
791
|
+
const trackingRemote = tracking.split('/')[0];
|
|
792
792
|
if (trackingRemote) {
|
|
793
793
|
sourceRemote = trackingRemote;
|
|
794
794
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Guest panels
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
OpenChamber-owned install and static serve for iframe guests. This is not the OpenCode plugin manager (`plugin-routes.js` / Settings → Plugins).
|
|
6
|
+
|
|
7
|
+
## Routes
|
|
8
|
+
|
|
9
|
+
- `POST /api/guests/:id/storage` accepts the SDK's `GuestStorageRequest` and returns `GuestStorageResult`. It requires an installed, enabled, fully approved guest with either a panel or background entry, but no additional capability. The UI host supplies the frame's own identity. Guest-scoped asset tokens cannot call this route. Data lives beside `extensions.json` in `guest-storage/<id>.json`. Each key is 1 to 128 characters, each JSON value at most 64 KiB UTF-8, the whole namespace at most 2 MiB and 2,000 keys. `storage.js` owns serialized authorization/read/write/removal; writes are atomic and failures preserve prior bytes. `uninstallGuest` removes storage for every install source. Missing keys return `found: false`, including after removal; stored JSON null returns `found: true`.
|
|
10
|
+
- `GET /api/guests` also exposes `pageEntry` and optional `pageTitle` for `contributes.page`. Full-screen page HTML and its built scripts pass the same install checks as attach HTML. No new serving/auth path is required.
|
|
11
|
+
|
|
12
|
+
- `GET /api/guests` returns installed guests. `attach` is `"panel"` or `"dialog"` when declared, and `attachEntry` is the dialog's separate HTML. `version` is the package semver. `integration` is the public slice: name, description, `auth`, and settings; OAuth URLs and tokens stay on the server. `service` includes `runtime`, optional `permissions`, optional `socketBindings`, and `granted`. `capabilities` carries `{ requested, granted }`, derived through the SDK's `requestedGuestCapabilities`. `actions`, `commands`, `tools`, and `filesystem` retain their parsed declarations; UI owns menu placement, command collisions, and tool precedence. `entry` is the optional visible panel and `backgroundEntry` is independent on-demand execution HTML. Background-only extensions get no rail surface; tools-only extensions with neither entry mount no frame. A guest without every requested grant is inactive until approval. `enabled: false` pauses it. A failed listing is 500, never an empty list.
|
|
13
|
+
- `GET /api/guests/:id/oauth/status` — `{ connected, account, hasClient, settings, redirectUri }`. No secrets. Host Linear merges first-party Linear status. `redirectUri` is empty unless `auth` is `oauth`.
|
|
14
|
+
- `PUT /api/guests/:id/oauth/client` — client id and optional secret from the Integrations form. OAuth guests only.
|
|
15
|
+
- `PUT /api/guests/:id/token` — pasted API token. Token guests only. The host probes the declared account path. The token never goes to the iframe.
|
|
16
|
+
- `PUT /api/guests/:id/settings` — declared keys only. The Integrations card calls it when a declared field loses focus with a changed value; there is no Save button. Connect saves the token (and username for `basic`) or the OAuth client id and then the declared fields.
|
|
17
|
+
- `POST /api/guests/:id/oauth/start` — PKCE S256 for OAuth guests. Host Linear starts the first-party Linear authorize URL. Token guests get `NO_INTEGRATION`.
|
|
18
|
+
- `GET /api/guests/:id/oauth/callback` — public HTML page. The provider redirects here with no UI bearer. State and verifier stay on the server. A callback without a matching `state` is refused; nothing is inferred from a pending exchange. Host Linear uses the first-party Linear callback, not this URL.
|
|
19
|
+
- `DELETE /api/guests/:id/oauth` — drops tokens for that guest. Client and settings stay. Host Linear disconnects the first-party Linear workspace.
|
|
20
|
+
- `POST /api/guests/:id/request` — authenticated UI session. Attaches Authorization. Path must stay on that guest's `apiOrigin`. Token guests send the pasted token as is, `Bearer <token>` when the manifest sets `token.scheme: "bearer"`, or `Basic <pair>` for `"basic"`, where `PUT /api/guests/:id/token` took `{ token, username }` and stored `base64(username:token)` as the access token (`USERNAME_MISSING` without a username). Host Linear sends the Linear bearer to `https://api.linear.app` only. Linear issue file URLs stay on `GET /api/linear/issues/get`. The guest proxy does not add Linear headers. A paused extension (`enabled: false`) is 400 `DISABLED`; tokens stay. A guest without the `network` capability granted is 400 `NOT_GRANTED`.
|
|
21
|
+
- `POST /api/guests/:id/service/request` — authenticated UI session. Proxies to that guest's host-spawned service on `127.0.0.1`. Spawns on first use after the grant; every service needs one. Missing grant, missing service, or bad path is 400 with `NO_SERVICE` / `BAD_PATH`. Paused extension is 400 `DISABLED` (service stopped). Ready failure is 502 `SERVICE_FAILED`.
|
|
22
|
+
- `POST /api/guests/:id/files` — authenticated UI session. `{ op: 'read' | 'write' | 'list' | 'stat', path, content? }` (`content` required for `write`, at most `GUEST_FILE_CONTENT_MAX` characters; `path` at most `GUEST_FILE_PATH_MAX`). The open project comes from `resolveOptionalProjectDirectory(req)` (`x-opencode-directory` header or `directory` query). `files.js` resolves the path: relative → joined to the project's realpath, and the deepest existing ancestor is realpath'd so a symlink out of the project is `BAD_PATH`; a relative path with no project is `NO_DIRECTORY`. `/…` or `~/…` → home expanded, normalized, canonicalized the same way, then matched against the package's `contributes.filesystem` globs whose literal prefixes were canonicalized too (`/tmp` → `/private/tmp` on macOS); no match is `BAD_PATH`. Any `..` segment, NUL, or backslash is `BAD_PATH`. Then the grant: project scope needs `files`, outside needs `filesystem`, else `NOT_GRANTED`. Then the operation: read is UTF-8 and refuses a file over the cap with `FILE_TOO_LARGE`; write makes parent directories and lands through a temp file plus rename (a reader never sees a truncated file); list returns `{ name, kind }` sorted by name and capped at `GUEST_FILE_LIST_MAX`, with symlinks as `other`; stat answers `kind: 'missing'` for nothing there. Answers are `{ ok: true, result }`, or `{ error: code, message }` with 400 (404 for `NOT_FOUND`). Paused extension is 400 `DISABLED`. `ENOENT` is `NOT_FOUND`, `EACCES` / `EPERM` is `DENIED`, `EISDIR` / `ENOTDIR` is `BAD_PATH`. Neither the path nor the content is ever logged.
|
|
23
|
+
- `POST /api/guests/:id/generate` — authenticated UI session. `{ prompt, system?, maxOutputTokens? }` (prompt at most `GUEST_GENERATE_PROMPT_MAX`, system `GUEST_GENERATE_SYSTEM_MAX`, tokens `GUEST_GENERATE_OUTPUT_TOKENS_MAX`). Paused extension is 400 `DISABLED`; missing `model` grant is 400 `NOT_GRANTED`. The call goes to `generateSmallModelText` in `lib/small-model`, which resolves and authenticates the Small Model the same way session titles and notes do (settings override → OpenCode config → signed-in provider); the optional `x-opencode-directory` header only picks which OpenCode config is read. No model (404) or Claude Code as the only candidate (422) is 400 `NO_MODEL`; a provider failure is 502 `MODEL_FAILED`. Answer is `{ ok: true, result: { text } }` capped at `GUEST_GENERATE_TEXT_MAX`. Prompt and answer are never logged.
|
|
24
|
+
- `GET /api/guests/:id/service/status` — `{ status }` for that guest's service runtime (`stopped` / `starting` / `ready` / `failed`).
|
|
25
|
+
- `PUT /api/guests/:id/capabilities` — `{ granted: GuestCapability[] }`. The list must equal the package's requested list, or be empty to withdraw approval (which also stops a running service). Anything else is 400 `invalid-request`. `service.granted` mirrors whether `service` is in the granted list. Approval also records the grant's scope (`grant-scope.js` `guestGrantScope`: the sorted `filesystem` patterns, the integration's `apiOrigin` and OAuth `authorizeUrl` / `tokenUrl`, the service's exec names and socket ids) as `capabilityScopes[id]`. When the recorded origin or OAuth endpoints differ from the new ones (`sameCredentialTarget`) the guest's stored tokens are dropped: a token pasted for one API, or a refresh token and client secret minted for one token endpoint, must not follow the integration to another.
|
|
26
|
+
- `PUT /api/guests/:id/service/sockets` — `{ id, path }` for a declared socket binding. Empty or null `path` clears the override. Stops a running service for that guest. Catalog `service.socketBindings` shows `resolved` and `override` for this host.
|
|
27
|
+
- `PUT /api/guests/:id/enabled` — `{ enabled }`. False is a full pause: catalog `enabled: false`, rail/attach/Integrations hide the guest, open panels close, cloud `request` and `service/request` return `DISABLED`, and a running service stops. Tokens and grants stay until Remove.
|
|
28
|
+
- `POST /api/guests` accepts exactly one of `{ path }` or `{ url }`, optional `replace`, and optional `gitIdentityId` for Git installs. `path` is an absolute folder or local ZIP. `url` is HTTPS Git/ZIP, `ssh://` Git, or scp-style SSH. Folder installs persist their realpath; Git and ZIP installs copy into `{openchamberDataDir}/extensions/{id}`. Success is 201, or 200 after replacement. Invalid input is `invalid-path`/`invalid-url`; clone and extraction failures are `clone-failed`/`extract-failed`. A missing selected identity fails cloning rather than falling back to global auth. Missing built scripts are `missing-build`, invalid package/version is `invalid-manifest`, and a newer required host is `host-too-old`. Duplicate installs are 409 `id-taken`/`already-installed`. Other install failures are 400. Clone uses the host's resolved Git binary; orphaned copy folders from interrupted installs can be replaced on retry. Each OpenChamber data directory owns its own catalog.
|
|
29
|
+
- `POST /api/guests/upload` — the archive itself as the body, `Content-Type: application/octet-stream`, for a browser or a remote instance that cannot name a path on the host. Query `replace=true` mirrors the JSON route's `replace`; `name` (the original filename) is accepted and unused, never logged. A wrong content type is 415 `unsupported-media-type`. A `Content-Length` over the cap is refused with 413 `too-large` before the body is read, and a body that grows past the cap without a declared length stops with the same answer. The cap is `OPENCHAMBER_GUEST_UPLOAD_MAX_BYTES`, default 50 MB (`upload.js` `guestUploadMaxBytes`). The bytes go to `installGuestFromZipBuffer`, the same function a local `.zip` path and an https `.zip` URL end in, so results and errors are those of `POST /api/guests` with a zip: 201 / 200 after replace with `{ guest }` (`source: "zip"`), 409 `already-installed` / `id-taken` with `id`, 400 `invalid-manifest`, `missing-build`, `host-too-old`, `extract-failed`. Registered before the `:id` routes because `upload` is a valid panel id shape. Archive contents are never logged.
|
|
30
|
+
- `POST /api/guests/updates/check` — `{ force?: boolean }` → `{ updates: Record<guestId, { version }> }` for every git install whose origin now carries a newer `package.json` `version`. The check runs `git fetch --depth 1 origin <ref|HEAD>` inside the installed clone and reads `FETCH_HEAD:package.json` through the SDK manifest parser; the only signal is semver (`updates.js` `compareSemver`, prerelease below release), no commit hashes. Results sit in a memory cache per store file and guest id for `UPDATE_CHECK_TTL_MS` (one hour); `force` asks the network again. That cache is separate from the catalog cache, so a store write does not drop it. One guest's failed fetch never hides another's update. Folder and zip installs are never checked.
|
|
31
|
+
- `POST /api/guests/:id/update` — replace the installed git copy with a fresh clone of its stored origin. Answers `{ guest }` (the refreshed catalog row) or 400 `{ error }` with `not-git` (folder, zip, or no stored origin), `clone-failed`, `invalid-manifest` (also when the clone declares a different panel id), `missing-build`, `host-too-old` (body may include `required`), or `swap-failed`. Unknown id is 404. Grants, tokens, settings, and the paused flag are keyed by guest id and stay as they were; a version that asks for more capabilities shows up as needs-approval in the catalog and goes through the usual review dialog.
|
|
32
|
+
- `DELETE /api/guests/:id` — drops the store row, any service grant, and socket overrides; stops a running service. Path-install does not delete the user's folder. Zip and git copies under `{openchamberDataDir}/extensions/` are deleted. Unknown id is 404.
|
|
33
|
+
- `GET /api/guests/:id/{*filePath}` serves package-local files. Unknown IDs, escapes, missing files, and unknown types return 404. HTML and JS require either `entry` or `backgroundEntry`, expressed by `resolveGuestServedFile`'s `hasRuntime` option. Tools-only extensions still serve their SVG icons but no executable content. Guest HTML propagates the scoped `oc_url_token` onto relative script/link/image URLs. The token is minted for `guest:<id>` and works only under that guest's asset route, never `/api/fs/raw` or event streams. Every response carries `Content-Security-Policy: sandbox allow-scripts`, including direct document navigation. The host serves built files from disk and never compiles guest sources.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
`contributes.background.entry` is optional package-local `.html`, separate from `panel.entry`. Install validates its HTML and every referenced built script using the same path confinement as visible panels. `backgroundEntry` is exposed on the catalog independently of `entry`; adding it does not create a rail icon or a visible page. Capabilities, services, integrations, and storage work with either execution entry. The SDK refuses visible contributions without `panel.entry` and all runtime contributions when neither entry exists. See `packages/sdk/DOCUMENTATION.md` for on-demand invocation ownership.
|
|
38
|
+
|
|
39
|
+
`html-styles.js` appends the SDK's shared scrollbar stylesheet to served HTML, including requests without an asset token. This applies to existing panel, page, and attach bundles in direct and relay mode; the relay loader retains the inline style in `srcDoc`. Appending preserves the doctype and avoids rewriting tag-like text inside scripts or comments. Authored CSP stays intact and can block inline styling. Package files on disk and non-HTML responses are unchanged. Scrollbar colors follow the SDK theme variables inside the frame.
|
|
40
|
+
|
|
41
|
+
Built-ins ship with the app; user extensions are installed separately. Add a folder, local ZIP, HTTPS Git/ZIP URL, or SSH Git address from Settings → Extensions. The ZIP picker and drop zone upload the archive on web and remote desktop instances. A guest with a page must ship built classic-IIFE JavaScript, for example from `bunx openchamber-guest-bundle panel/main.ts panel/main.js`; the sandbox does not support ESM imports. `package.json` needs a semver version. A missing panel SVG or invalid manifest fails installation; page-less tools-only packages skip HTML/script checks. Optional `engines.openchamber` is checked against this host. Provider accounts are connected separately in Settings → Integrations.
|
|
42
|
+
|
|
43
|
+
Folder installs store their realpath with `source: "path"`. ZIP and Git copies live in `{openchamberDataDir}/extensions/{id}`. Git URLs may end in `#branch-or-tag`; clones use `--branch <ref> --depth 1`. `gitOrigins` stores `{ url, ref?, gitIdentityId? }` per install path and is removed on uninstall. Legacy origins without an identity retain global authentication. Malformed origins are dropped individually. The public catalog exposes the origin URL/ref, never the key path. Named profiles are re-resolved on each clone or update fetch so key rotation applies; missing profiles fail closed. Global auth preserves the server's SSH agent and Git/SSH configuration. ZIP limits remain 20MB for path/URL installs, 500 files and 40MB expanded, store/deflate only, no encryption, zip64 or parent-path escapes. Uninstall deletes copies and leaves folder-install sources intact.
|
|
44
|
+
|
|
45
|
+
OAuth and pasted tokens live in `{openchamberDataDir}/guest-auth.json`, each entry with the `target` it was minted for (`apiOrigin`, and for OAuth `authorizeUrl` and `tokenUrl`). `takeUsableGuestAuth` runs before every use (proxy, refresh, the status card): tokens whose target differs from what the package declares now, or that carry no target, are dropped on the spot, whatever the grants say. A pending authorization carries the same target; the callback refuses the exchange (`TARGET_CHANGED`) when the package changed its endpoints in between, and the exchange itself goes to the pending target's `tokenUrl`, never to the current manifest's. The client id and secret carry their own `clientTarget`, written by `PUT /api/guests/:id/oauth/client` (`saveGuestOAuthClient`: an empty secret field keeps the stored secret only for the same client id and the same endpoints, decided under the store lock; a new id or moved endpoints drop it); `usableClientCredentials` treats a client entered for other endpoints as absent (`CLIENT_MISSING` on Connect, `hasClient: false` on the card), so a moved package gets no old secret and the user enters new credentials. Every token write that follows a network round trip (the code exchange, a refresh) goes through `patchGuestAuth` with an `expect` predicate checked under the store lock: the entry must still hold the tokens and target the round trip started from, else the answer is dropped and the entry left as it is. The proxy's cleanup after a 401 is scoped the same way: `dropGuestTokens` with an `expect` on the access token the request was refused with, so a refresh that came back empty because the user reconnected meanwhile never erases the new connection. Host Linear tokens stay in `linear-auth.json`. Writes are atomic and `0o600`. The file is per OpenChamber instance, same as `extensions.json`. Web and desktop share it. VS Code and mobile set the guest catalog to `unsupported`. That is not an empty ready list. Those cards stay hidden.
|
|
46
|
+
|
|
47
|
+
A missing folder is omitted from the list. Other guests stay. A corrupt `extensions.json` is 500, not an empty catalog. A missing or blank `guest-auth.json` is an empty store. Corrupt JSON there is 500, not a disconnected card. This is not Settings → Plugins.
|
|
48
|
+
|
|
49
|
+
## Invariants
|
|
50
|
+
|
|
51
|
+
- Built-in packages come from the app-owned registry bound by `registerBuiltInGuests` at server startup. Source/build/packaging rules are in `packages/extensions/DOCUMENTATION.md`. They receive only their declared grants automatically; ordinary runtime and enabled checks still apply. `openchamber-builtin-` IDs are reserved from user installs, including replacement. DELETE, Git update and grant-edit routes refuse built-ins. Disable preserves account/storage data and stops their services.
|
|
52
|
+
|
|
53
|
+
- Grants are narrowed on read, never trusted as stored. `listInstalledGuestsUncached` passes each guest's stored list through `effectiveGrants(granted, storedScope, currentScope)`: `filesystem`, `network`, and `service` only count while the scope recorded at approval equals what the package declares now, and never count without a recorded scope. A newer version that widens its patterns, moves its API origin, or asks for more exec names or sockets shows up as needs-approval and is refused by every proxy meanwhile. Routes and `proxyGuestServiceRequest` take the grant list from the catalog row (`guest.capabilityGrants`), not from the store.
|
|
54
|
+
- Store changes are read-modify-write under one lock per file (`updateExtensionStore`): Allow, Pause, a socket override, an install, and an uninstall landing at the same time all keep each other's result. `guest-auth.json` has the same lock (`withAuthStoreLock`) and every temp file name is unique.
|
|
55
|
+
- ZIP downloads validate and pin each HTTPS hop, at most five. Git clones and update fetches share `prepareGuestGitNetwork`. HTTPS retains `http.followRedirects=false` and `http.curloptResolve` pinning. SSH validates the username/host/path, rejects password-bearing URLs and private DNS answers, pins `Hostname` to a resolved public address, and retains the original host for host-key checks through `HostKeyAlias`. Default SSH options use batch mode and `StrictHostKeyChecking=accept-new`, with ProxyCommand/ProxyJump disabled. Identity key paths are shell-quoted; keys and SSH agent sockets stay on the server. Public Git hosts are supported, not local SSH aliases or private-network targets. Existing user-configured Git/SSH commands remain trusted server configuration.
|
|
56
|
+
- The catalog cache is version-guarded: `invalidateGuestCatalog` bumps a per-path version, the store notifies before and after each write, and a listing only caches its result when the version did not move while it read. A read that straddled a withdrawal therefore never serves the old grants for the TTL.
|
|
57
|
+
- Stopping a service also cancels a request in flight: `stopGuestService` (Pause, Remove, withdrawn approval, socket change, host quit) bumps a per-guest epoch. `proxyGuestServiceRequest` reads the epoch before its first store read, hands it to the start, and checks it again after the service is up; `startGuestService` checks it after every await, right before `spawn`, and while polling readiness. Pause anywhere between the request's enabled check and the proxied call ends with no process and `NO_SERVICE`. Internal cleanup of a dead or failed runtime before a restart is `discardRuntime`, which does not bump the epoch: a crash is not a Pause, so the request that triggers the restart goes through.
|
|
58
|
+
|
|
59
|
+
- Paths stay inside the package root (`realpath`). `..`, absolute paths, and URLs do not serve.
|
|
60
|
+
- Only an allowlisted content type is served.
|
|
61
|
+
- Host and guest speak `@openchamber/sdk`. Do not copy those types into `packages/ui`.
|
|
62
|
+
- `contributes.actions`, `contributes.commands`, and `contributes.tools` are validated by the SDK parse at install (`invalid-actions` / `invalid-commands` / `invalid-tools` surface as `invalid-manifest`) and copied to the catalog row unchanged. Tool presentations are applied in the UI only (`packages/ui/src/lib/guests/tool-presentation.ts`); no server route reads them. No server route runs an action or a command: the message or session item is built in the UI from the same records Export as Markdown uses, and a slash command is answered by the guest frame over `postMessage`. The rail badge is UI memory only.
|
|
63
|
+
- `contributes.attach` is copied as `"panel"` or `"dialog"`. The composer + menu and New Worktree read that field. Omitted guests stay off those menus. The object form's `entry` (dialog only) is checked at install like `panel.entry`: missing HTML is `invalid-manifest`, an unbuilt script it loads is `missing-build`; the catalog exposes it as `attachEntry` and the attach window loads that page instead of `panel.entry`. The optional attach `data` payload lives only in the UI's chip, queued context part, and session metadata snapshot; no server route parses it.
|
|
64
|
+
- `request` never leaves the declared `apiOrigin`. A 401 refreshes once. If that fails, tokens for that guest are dropped. Host Linear does not drop Linear auth on a failed GraphQL query. A paused guest never proxies `request` or `serviceRequest`.
|
|
65
|
+
- `serviceRequest` never leaves that guest's loopback port. Auth to the service is `Authorization: Bearer` with a host-minted token. The service env is an allowlist (PATH, HOME, temp, locale, Windows system variables) plus `OPENCHAMBER_SERVICE_*`; host secrets are not inherited. There is no sandbox: an allowed service has the user's full access, and the grant card says so. Ready is `GET /health` → 200. Parallel first requests share one spawn; they must not kill each other mid-start. Host quit stops every service. Disabling a guest stops its service without clearing OAuth or grants.
|
|
66
|
+
- File access is decided in `files.js` on canonical paths, never on the string the guest sent. `matchesFilesystemPattern`, `expandHome`, and `globToRegExp` are pure; `resolveGuestFilePath` and `runGuestFileOperation` take `fsPromises` / `nodePath` so tests can stub the OS. The project directory is whatever `resolveOptionalProjectDirectory` validated for this request, so a guest cannot name a project the UI did not open.
|
|
67
|
+
- Update swap (`updates.js` `updateGuest`): the new clone lands in `{dataDir}/extensions/.tmp-<id>-<random>` and is inspected exactly like an install before anything else moves. Only then: stop the guest's service, rename current → `.old-<id>`, rename temp → final, delete `.old-<id>`. Any failure before the swap deletes the temp dir and leaves the install untouched; a failed second rename moves `.old-<id>` back. The store row is not rewritten (same path, same id), the catalog cache and that guest's update cache entry are dropped.
|
|
68
|
+
- The OAuth callback skips UI auth. Everything else under `/api/guests` stays behind the usual session.
|