@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import { resolveBunExecutable } from '../../../../../scripts/lib/bun-executable.mjs';
|
|
8
|
+
|
|
9
|
+
import { cloneGitRepository, parseGitInstallUrl, prepareGuestGitNetwork } from './clone.js';
|
|
10
|
+
import { installGuestFromGitSource, parseInstallRequest } from './install.js';
|
|
11
|
+
import { listInstalledGuests } from './catalog.js';
|
|
12
|
+
import { readExtensionStore } from './persist.js';
|
|
13
|
+
import { checkGuestUpdate, updateGuest } from './updates.js';
|
|
14
|
+
|
|
15
|
+
const lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
|
16
|
+
const source = 'git@github.com:acme/fixture.git';
|
|
17
|
+
const testPath = fileURLToPath(import.meta.url);
|
|
18
|
+
const bunExecutable = resolveBunExecutable();
|
|
19
|
+
const savedEnv = new Map();
|
|
20
|
+
let root;
|
|
21
|
+
let repo;
|
|
22
|
+
let profilesPath;
|
|
23
|
+
let logPath;
|
|
24
|
+
let globalKey;
|
|
25
|
+
let workKey;
|
|
26
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], {
|
|
27
|
+
cwd: root,
|
|
28
|
+
env: { ...process.env, HOME: root, GIT_CONFIG_GLOBAL: path.join(root, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1' },
|
|
29
|
+
stdio: 'pipe',
|
|
30
|
+
});
|
|
31
|
+
const setEnv = (key, value) => {
|
|
32
|
+
savedEnv.set(key, process.env[key]);
|
|
33
|
+
if (value === undefined) delete process.env[key];
|
|
34
|
+
else process.env[key] = value;
|
|
35
|
+
};
|
|
36
|
+
const writeProfiles = (key) => fs.writeFile(profilesPath, JSON.stringify({ profiles: key ? [{ id: 'work', name: 'Work', userName: 'Fixture', userEmail: 'fixture@example.com', sshKey: key }] : [] }));
|
|
37
|
+
const publishVersion = async (version) => {
|
|
38
|
+
await fs.writeFile(path.join(repo, 'package.json'), JSON.stringify({ name: 'fixture', version,
|
|
39
|
+
openchamber: { apiVersion: 1, contributes: { panel: { id: 'ssh-fixture', name: 'Fixture', icon: 'window', entry: 'panel/index.html' } } } }));
|
|
40
|
+
git('add', '.');
|
|
41
|
+
git('commit', '-m', `fixture ${version}`);
|
|
42
|
+
};
|
|
43
|
+
const sshCalls = async () => (await fs.readFile(logPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line));
|
|
44
|
+
|
|
45
|
+
if (!process.env.GUEST_SSH_TEST_HOME) {
|
|
46
|
+
test('SSH integration suite uses an isolated server home', async () => {
|
|
47
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'guest-ssh-test-'));
|
|
48
|
+
const protectedPaths = [path.join(os.homedir(), '.gitconfig'), path.resolve(path.dirname(testPath), '../../../../../.git/config')];
|
|
49
|
+
const readProtected = async (file) => {
|
|
50
|
+
try { return await fs.readFile(file); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const before = await Promise.all(protectedPaths.map(readProtected));
|
|
57
|
+
const env = { ...process.env };
|
|
58
|
+
for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key];
|
|
59
|
+
Object.assign(env, {
|
|
60
|
+
HOME: home,
|
|
61
|
+
USERPROFILE: home,
|
|
62
|
+
XDG_CONFIG_HOME: path.join(home, '.config'),
|
|
63
|
+
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'),
|
|
64
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
65
|
+
GIT_CEILING_DIRECTORIES: path.dirname(home),
|
|
66
|
+
GUEST_SSH_TEST_HOME: home,
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
const refused = spawnSync(bunExecutable, ['test', testPath], {
|
|
70
|
+
env, cwd: path.dirname(home), stdio: 'pipe', timeout: 60_000,
|
|
71
|
+
});
|
|
72
|
+
expect(refused.status).not.toBe(0);
|
|
73
|
+
expect(refused.stderr.toString()).toContain('SSH fixture refused to run without an isolated home');
|
|
74
|
+
// Bun's default child-process environment can ignore late process.env
|
|
75
|
+
// edits. Establish isolation at process startup and always pass env/cwd.
|
|
76
|
+
execFileSync(bunExecutable, ['test', testPath], {
|
|
77
|
+
env, cwd: home, stdio: 'pipe', timeout: 60_000,
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
const after = await Promise.all(protectedPaths.map(readProtected));
|
|
81
|
+
await fs.rm(home, { recursive: true, force: true });
|
|
82
|
+
// Compare bytes without printing configuration contents on a failure.
|
|
83
|
+
for (let index = 0; index < before.length; index++) {
|
|
84
|
+
const unchanged = before[index] === null ? after[index] === null : after[index] !== null && before[index].equals(after[index]);
|
|
85
|
+
expect(unchanged).toBe(true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
root = process.env.GUEST_SSH_TEST_HOME;
|
|
92
|
+
const realRoot = await fs.realpath(root);
|
|
93
|
+
if (path.dirname(realRoot) !== await fs.realpath(os.tmpdir())
|
|
94
|
+
|| !path.basename(realRoot).startsWith('guest-ssh-test-')
|
|
95
|
+
|| await fs.realpath(os.homedir()) !== realRoot
|
|
96
|
+
|| await fs.realpath(process.cwd()) !== realRoot
|
|
97
|
+
|| process.env.GIT_CONFIG_GLOBAL !== path.join(root, '.gitconfig')) {
|
|
98
|
+
throw new Error('SSH fixture refused to run without an isolated home, cwd, and Git config');
|
|
99
|
+
}
|
|
100
|
+
repo = path.join(root, 'repository');
|
|
101
|
+
const bin = path.join(root, 'bin');
|
|
102
|
+
profilesPath = path.join(root, '.config/openchamber/git-identities.json');
|
|
103
|
+
logPath = path.join(root, 'ssh-calls.jsonl');
|
|
104
|
+
globalKey = path.join(root, 'global key');
|
|
105
|
+
workKey = path.join(root, 'work key');
|
|
106
|
+
await fs.mkdir(path.dirname(profilesPath), { recursive: true });
|
|
107
|
+
await fs.mkdir(bin);
|
|
108
|
+
await fs.mkdir(path.join(repo, 'panel'), { recursive: true });
|
|
109
|
+
setEnv('HOME', root);
|
|
110
|
+
setEnv('GIT_CONFIG_GLOBAL', path.join(root, '.gitconfig'));
|
|
111
|
+
setEnv('GIT_CONFIG_NOSYSTEM', '1');
|
|
112
|
+
setEnv('GIT_SSH_COMMAND', undefined);
|
|
113
|
+
setEnv('GIT_SSH', undefined);
|
|
114
|
+
setEnv('SSH_AUTH_SOCK', path.join(root, 'fixture-agent.sock'));
|
|
115
|
+
setEnv('PATH', `${bin}${path.delimiter}${process.env.PATH}`);
|
|
116
|
+
setEnv('GUEST_TEST_REPO', repo);
|
|
117
|
+
setEnv('GUEST_TEST_SSH_LOG', logPath);
|
|
118
|
+
// Real Git protocol over a local test transport: no server key or network is used.
|
|
119
|
+
await fs.writeFile(path.join(bin, 'ssh'), `#!${process.execPath}\nimport { appendFileSync } from 'node:fs';\nimport { spawnSync } from 'node:child_process';\nappendFileSync(process.env.GUEST_TEST_SSH_LOG, JSON.stringify({ args: process.argv.slice(2), agent: process.env.SSH_AUTH_SOCK }) + '\\n');\nconst result = spawnSync('git', ['upload-pack', process.env.GUEST_TEST_REPO], { stdio: 'inherit' });\nprocess.exit(result.status ?? 1);\n`, { mode: 0o755 });
|
|
120
|
+
// Seed only this explicit fixture file. Never run `git config --global` here.
|
|
121
|
+
await fs.writeFile(path.join(root, '.gitconfig'), `[user]\n name = Fixture\n email = fixture@example.com\n[core]\n sshCommand = ${JSON.stringify(`ssh -i '${globalKey}'`)}\n`);
|
|
122
|
+
git('init', '--initial-branch=main');
|
|
123
|
+
await fs.writeFile(path.join(repo, 'panel/index.html'), '<script src="main.js"></script>');
|
|
124
|
+
await fs.writeFile(path.join(repo, 'panel/main.js'), 'console.log("fixture");');
|
|
125
|
+
await writeProfiles(workKey);
|
|
126
|
+
await publishVersion('1.0.0');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
for (const [key, value] of savedEnv) {
|
|
131
|
+
if (value === undefined) delete process.env[key];
|
|
132
|
+
else process.env[key] = value;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('SSH extension installs', () => {
|
|
137
|
+
test('accepts SSH and scp URLs with refs, but rejects credentials, private targets and shell syntax', () => {
|
|
138
|
+
expect(parseGitInstallUrl(`${source}#main`)).toEqual({ url: source, ref: 'main' });
|
|
139
|
+
expect(parseGitInstallUrl('ssh://git@github.com:2222/acme/fixture.git')).toEqual({ url: 'ssh://git@github.com:2222/acme/fixture.git' });
|
|
140
|
+
expect(parseGitInstallUrl('SSH://git@github.com/acme/fixture.git')).toEqual({ url: 'ssh://git@github.com/acme/fixture.git' });
|
|
141
|
+
for (const url of ['ssh://git:password@github.com/acme/repo', 'ssh://git@127.0.0.1/repo', 'git@localhost:repo', 'ssh://git@github.com:99999/repo', 'git@github.com:repo;id', 'ssh://git@github.com/repo?command=id', 'ssh://git@bad\'host.com/repo', `${source}\n`]) {
|
|
142
|
+
expect(parseGitInstallUrl(url)).toBeNull();
|
|
143
|
+
}
|
|
144
|
+
expect(parseInstallRequest({ url: source, gitIdentityId: 'work' })).toEqual({ url: source, gitIdentityId: 'work' });
|
|
145
|
+
expect(parseInstallRequest({ url: source, gitIdentityId: {} })).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('global identity uses host SSH configuration and inherits its agent', async () => {
|
|
149
|
+
expect(await cloneGitRepository(source, path.join(root, 'global-clone'), { lookup })).toEqual({ ok: true });
|
|
150
|
+
const call = (await sshCalls()).at(-1);
|
|
151
|
+
expect(call.args).toContain(globalKey);
|
|
152
|
+
expect(call.agent).toBe(process.env.SSH_AUTH_SOCK);
|
|
153
|
+
expect(call.args).toContain('BatchMode=yes');
|
|
154
|
+
expect(call.args).toContain('Hostname=93.184.216.34');
|
|
155
|
+
expect(call.args).toContain('HostKeyAlias=github.com');
|
|
156
|
+
expect(await cloneGitRepository('ssh://git@github.com:2222/acme/fixture.git', path.join(root, 'port-clone'), { lookup })).toEqual({ ok: true });
|
|
157
|
+
expect((await sshCalls()).at(-1).args).toContain('2222');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('rejects DNS answers into private networks', async () => {
|
|
161
|
+
expect(await prepareGuestGitNetwork(source, { lookup: async () => [{ address: '10.0.0.1', family: 4 }] })).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('persists the selected identity and re-resolves it for update checks and replacement clones', async () => {
|
|
165
|
+
const persistPath = path.join(root, 'extensions.json');
|
|
166
|
+
const result = await installGuestFromGitSource(source, persistPath, { gitIdentityId: 'work', ref: 'main', lookup });
|
|
167
|
+
expect(result.ok).toBe(true);
|
|
168
|
+
const [guest] = await listInstalledGuests({ persistPath });
|
|
169
|
+
expect(guest.gitOrigin).toEqual({ url: source, ref: 'main', gitIdentityId: 'work' });
|
|
170
|
+
expect((await sshCalls()).at(-1).args).toContain(workKey);
|
|
171
|
+
expect(JSON.stringify(await readExtensionStore(persistPath))).not.toContain(workKey);
|
|
172
|
+
|
|
173
|
+
const rotatedKey = path.join(root, 'rotated key');
|
|
174
|
+
await writeProfiles(rotatedKey);
|
|
175
|
+
await publishVersion('1.1.0');
|
|
176
|
+
expect(await checkGuestUpdate({ guest, origin: guest.gitOrigin, lookup })).toMatchObject({ available: true, version: '1.1.0' });
|
|
177
|
+
expect((await sshCalls()).at(-1).args).toContain(rotatedKey);
|
|
178
|
+
expect(await updateGuest({ guest, origin: guest.gitOrigin, persistPath, lookup })).toMatchObject({ ok: true });
|
|
179
|
+
expect((await sshCalls()).at(-1).args).toContain(rotatedKey);
|
|
180
|
+
|
|
181
|
+
await writeProfiles(null);
|
|
182
|
+
expect(await checkGuestUpdate({ guest, origin: guest.gitOrigin, lookup })).toMatchObject({ available: false, error: 'fetch-failed' });
|
|
183
|
+
expect(await updateGuest({ guest, origin: guest.gitOrigin, persistPath, lookup })).toMatchObject({ ok: false, code: 'clone-failed' });
|
|
184
|
+
const [preserved] = await listInstalledGuests({ persistPath });
|
|
185
|
+
expect(preserved.version).toBe('1.1.0');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { GUEST_STORAGE_KEY_MAX, GUEST_STORAGE_KEYS_MAX, GUEST_STORAGE_TOTAL_BYTES, GUEST_STORAGE_VALUE_BYTES } from '@openchamber/sdk';
|
|
6
|
+
|
|
7
|
+
const stores = new Map();
|
|
8
|
+
const documentSchema = z.record(z.string().min(1).max(GUEST_STORAGE_KEY_MAX), z.json());
|
|
9
|
+
const storagePath = (persistPath, id) => {
|
|
10
|
+
if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('Invalid extension identity');
|
|
11
|
+
return path.join(path.dirname(persistPath), 'guest-storage', `${id}.json`);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const withStorageLock = async (file, operation) => {
|
|
15
|
+
const previous = stores.get(file) ?? Promise.resolve();
|
|
16
|
+
const pending = previous.catch(() => {}).then(operation);
|
|
17
|
+
stores.set(file, pending);
|
|
18
|
+
try {
|
|
19
|
+
return await pending;
|
|
20
|
+
} finally {
|
|
21
|
+
if (stores.get(file) === pending) stores.delete(file);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const removeGuestStorage = (persistPath, id) => {
|
|
26
|
+
const file = storagePath(persistPath, id);
|
|
27
|
+
return withStorageLock(file, () => fs.rm(file, { force: true }));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Authorization is rechecked inside the same lock that serializes removal. */
|
|
31
|
+
export const runGuestStorage = (persistPath, id, request, authorize) => {
|
|
32
|
+
const file = storagePath(persistPath, id);
|
|
33
|
+
return withStorageLock(file, async () => {
|
|
34
|
+
await authorize();
|
|
35
|
+
let entries = new Map();
|
|
36
|
+
try {
|
|
37
|
+
const stat = await fs.stat(file);
|
|
38
|
+
if (stat.size > GUEST_STORAGE_TOTAL_BYTES) throw new Error('Storage exceeds its size limit');
|
|
39
|
+
const parsed = documentSchema.parse(JSON.parse(await fs.readFile(file, 'utf8')));
|
|
40
|
+
entries = new Map(Object.entries(parsed));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
43
|
+
}
|
|
44
|
+
if (request.op === 'get') {
|
|
45
|
+
return entries.has(request.key)
|
|
46
|
+
? { storage: true, op: 'get', found: true, value: entries.get(request.key) }
|
|
47
|
+
: { storage: true, op: 'get', found: false };
|
|
48
|
+
}
|
|
49
|
+
if (request.op === 'keys') return { storage: true, op: 'keys', keys: [...entries.keys()].sort() };
|
|
50
|
+
if (request.op === 'set') {
|
|
51
|
+
if (Buffer.byteLength(JSON.stringify(request.value)) > GUEST_STORAGE_VALUE_BYTES) throw new Error('Storage value exceeds 64 KiB');
|
|
52
|
+
entries.set(request.key, request.value);
|
|
53
|
+
} else {
|
|
54
|
+
entries.delete(request.key);
|
|
55
|
+
}
|
|
56
|
+
const data = JSON.stringify(Object.fromEntries(entries));
|
|
57
|
+
if (entries.size > GUEST_STORAGE_KEYS_MAX || Buffer.byteLength(data) > GUEST_STORAGE_TOTAL_BYTES) throw new Error('Extension storage is full');
|
|
58
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
59
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
60
|
+
try {
|
|
61
|
+
await fs.writeFile(temporary, data, { mode: 0o600, flag: 'wx' });
|
|
62
|
+
await fs.rename(temporary, file);
|
|
63
|
+
} finally {
|
|
64
|
+
await fs.rm(temporary, { force: true });
|
|
65
|
+
}
|
|
66
|
+
return { storage: true, op: request.op };
|
|
67
|
+
});
|
|
68
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { runGuestStorage, removeGuestStorage } from './storage.js';
|
|
6
|
+
|
|
7
|
+
const roots = [];
|
|
8
|
+
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
|
|
9
|
+
const fixture = async () => {
|
|
10
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-storage-'));
|
|
11
|
+
roots.push(root);
|
|
12
|
+
const persistPath = path.join(root, 'extensions.json');
|
|
13
|
+
return { root, persistPath, run: (request, id = 'board') => runGuestStorage(persistPath, id, request, async () => {}) };
|
|
14
|
+
};
|
|
15
|
+
describe('extension storage', () => {
|
|
16
|
+
test('concurrent keys survive, namespaces are isolated, and null is present', async () => {
|
|
17
|
+
const { run, persistPath } = await fixture();
|
|
18
|
+
await Promise.all(Array.from({ length: 30 }, (_, index) => run({ op: 'set', key: `key-${index}`, value: index })));
|
|
19
|
+
expect((await run({ op: 'keys' })).keys).toHaveLength(30);
|
|
20
|
+
expect(await run({ op: 'get', key: 'key-1' }, 'other')).toMatchObject({ found: false });
|
|
21
|
+
await run({ op: 'set', key: 'null', value: null });
|
|
22
|
+
expect(await run({ op: 'get', key: 'null' })).toMatchObject({ found: true, value: null });
|
|
23
|
+
await removeGuestStorage(persistPath, 'board');
|
|
24
|
+
expect(await run({ op: 'keys' })).toMatchObject({ keys: [] });
|
|
25
|
+
});
|
|
26
|
+
test('limits and malformed reads preserve prior bytes', async () => {
|
|
27
|
+
const { run, root } = await fixture();
|
|
28
|
+
await run({ op: 'set', key: 'board', value: { columns: ['Todo'] } });
|
|
29
|
+
await expect(run({ op: 'set', key: 'board', value: 'x'.repeat(65_536) })).rejects.toThrow();
|
|
30
|
+
expect(await run({ op: 'get', key: 'board' })).toMatchObject({ value: { columns: ['Todo'] } });
|
|
31
|
+
const file = path.join(root, 'guest-storage', 'board.json');
|
|
32
|
+
await fs.writeFile(file, '{broken');
|
|
33
|
+
await expect(run({ op: 'set', key: 'other', value: 1 })).rejects.toThrow();
|
|
34
|
+
expect(await fs.readFile(file, 'utf8')).toBe('{broken');
|
|
35
|
+
});
|
|
36
|
+
test('authorization runs after earlier writes and refusal performs no write', async () => {
|
|
37
|
+
const { run, persistPath } = await fixture();
|
|
38
|
+
await run({ op: 'set', key: 'kept', value: 1 });
|
|
39
|
+
await expect(runGuestStorage(persistPath, 'board', { op: 'set', key: 'kept', value: 2 }, async () => { throw new Error('Removed'); })).rejects.toThrow('Removed');
|
|
40
|
+
expect(await run({ op: 'get', key: 'kept' })).toMatchObject({ value: 1 });
|
|
41
|
+
});
|
|
42
|
+
test('a failed atomic rename leaves the old value and does not poison subsequent writes', async () => {
|
|
43
|
+
const { run } = await fixture();
|
|
44
|
+
await run({ op: 'set', key: 'kept', value: 1 });
|
|
45
|
+
const rename = vi.spyOn(fs, 'rename').mockRejectedValueOnce(new Error('Disk failure'));
|
|
46
|
+
try {
|
|
47
|
+
await expect(run({ op: 'set', key: 'kept', value: 2 })).rejects.toThrow('Disk failure');
|
|
48
|
+
} finally { rename.mockRestore(); }
|
|
49
|
+
expect(await run({ op: 'get', key: 'kept' })).toMatchObject({ value: 1 });
|
|
50
|
+
await run({ op: 'set', key: 'kept', value: 3 });
|
|
51
|
+
expect(await run({ op: 'get', key: 'kept' })).toMatchObject({ value: 3 });
|
|
52
|
+
});
|
|
53
|
+
test('the total byte limit retains keys written before the overflowing request', async () => {
|
|
54
|
+
const { run } = await fixture();
|
|
55
|
+
const results = await Promise.allSettled(Array.from({ length: 35 }, (_, index) => run({ op: 'set', key: `key-${index}`, value: 'x'.repeat(60_000) })));
|
|
56
|
+
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
|
57
|
+
expect((await run({ op: 'keys' })).keys).toHaveLength(34);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { parseManifestJson } from '@openchamber/sdk/schemas';
|
|
6
|
+
import { requestedGuestCapabilities } from '@openchamber/sdk';
|
|
7
|
+
|
|
8
|
+
import { inspectGuestPackage, invalidateGuestCatalog, listInstalledGuests } from './catalog.js';
|
|
9
|
+
import { cloneGitRepository, prepareGuestGitNetwork, runGit } from './clone.js';
|
|
10
|
+
import { unwrapGuestRoot } from './extract-zip.js';
|
|
11
|
+
import { guestCopiesDir, isCopiedGuestRoot } from './persist.js';
|
|
12
|
+
import { stopGuestService } from './service.js';
|
|
13
|
+
|
|
14
|
+
/** A remote check runs at most this often per guest unless the user forces it. */
|
|
15
|
+
export const UPDATE_CHECK_TTL_MS = 60 * 60 * 1000;
|
|
16
|
+
const CHECK_TIMEOUT_MS = 20_000;
|
|
17
|
+
|
|
18
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} value
|
|
22
|
+
* @returns {{ core: number[], pre: string[] | null } | null}
|
|
23
|
+
*/
|
|
24
|
+
const parseSemver = (value) => {
|
|
25
|
+
const match = SEMVER.exec(value.trim());
|
|
26
|
+
if (!match) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
31
|
+
pre: match[4] ? match[4].split('.') : null,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const compareIdentifier = (a, b) => {
|
|
36
|
+
const aNumeric = /^\d+$/.test(a);
|
|
37
|
+
const bNumeric = /^\d+$/.test(b);
|
|
38
|
+
if (aNumeric && bNumeric) {
|
|
39
|
+
return Math.sign(Number(a) - Number(b));
|
|
40
|
+
}
|
|
41
|
+
if (aNumeric) return -1;
|
|
42
|
+
if (bNumeric) return 1;
|
|
43
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Semver order: `-1` when `a < b`, `1` when `a > b`, `0` when equal. A
|
|
48
|
+
* prerelease sorts below its release (`1.1.0-beta.1 < 1.1.0`). Build metadata
|
|
49
|
+
* is ignored. Either side not being semver is `null`.
|
|
50
|
+
*/
|
|
51
|
+
export const compareSemver = (a, b) => {
|
|
52
|
+
const left = parseSemver(a);
|
|
53
|
+
const right = parseSemver(b);
|
|
54
|
+
if (!left || !right) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
for (let index = 0; index < 3; index += 1) {
|
|
58
|
+
if (left.core[index] !== right.core[index]) {
|
|
59
|
+
return left.core[index] < right.core[index] ? -1 : 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!left.pre && !right.pre) return 0;
|
|
63
|
+
if (!left.pre) return 1;
|
|
64
|
+
if (!right.pre) return -1;
|
|
65
|
+
const length = Math.min(left.pre.length, right.pre.length);
|
|
66
|
+
for (let index = 0; index < length; index += 1) {
|
|
67
|
+
const order = compareIdentifier(left.pre[index], right.pre[index]);
|
|
68
|
+
if (order !== 0) {
|
|
69
|
+
return order;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Math.sign(left.pre.length - right.pre.length);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ask the installed clone's remote for its current `package.json` and compare
|
|
77
|
+
* the version with what is on disk. Runs inside the installed copy so the
|
|
78
|
+
* clone's own credentials-free remote is used; nothing is written. Every
|
|
79
|
+
* failure is `{ available: false, error }`; nothing throws to the route.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ guest: { version?: string, packageRoot: string }, origin: { url: string, ref?: string, gitIdentityId?: string }, gitBinary?: string, timeoutMs?: number, lookup?: Parameters<typeof import('./clone.js').publicAddressesOf>[1] }} input
|
|
82
|
+
* @returns {Promise<{ available: true, version: string, requested: string[] } | { available: false, version?: string, error?: string }>}
|
|
83
|
+
*/
|
|
84
|
+
export const checkGuestUpdate = async ({ guest, origin, gitBinary, timeoutMs = CHECK_TIMEOUT_MS, lookup }) => {
|
|
85
|
+
if (!origin?.url) {
|
|
86
|
+
return { available: false, error: 'not-git' };
|
|
87
|
+
}
|
|
88
|
+
if (!guest.version) {
|
|
89
|
+
return { available: false, error: 'invalid-manifest' };
|
|
90
|
+
}
|
|
91
|
+
const cwd = guest.packageRoot;
|
|
92
|
+
// The fetch is a network operation like the clone: no redirects, and the
|
|
93
|
+
// connection pinned to the addresses the public hostname resolves to now.
|
|
94
|
+
const network = await prepareGuestGitNetwork(origin.url, { gitIdentityId: origin.gitIdentityId, lookup }).catch(() => null);
|
|
95
|
+
if (!network) {
|
|
96
|
+
return { available: false, error: 'fetch-failed' };
|
|
97
|
+
}
|
|
98
|
+
const fetched = await runGit(
|
|
99
|
+
[...network.args, 'fetch', '--depth', '1', '--', 'origin', origin.ref ?? 'HEAD'],
|
|
100
|
+
{ gitBinary, cwd, timeoutMs, env: network.env },
|
|
101
|
+
);
|
|
102
|
+
if (!fetched.ok) {
|
|
103
|
+
return { available: false, error: 'fetch-failed' };
|
|
104
|
+
}
|
|
105
|
+
const shown = await runGit(['show', 'FETCH_HEAD:package.json'], { gitBinary, cwd, timeoutMs, capture: true });
|
|
106
|
+
if (!shown.ok) {
|
|
107
|
+
return { available: false, error: 'invalid-manifest' };
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseManifestJson(shown.stdout ?? '');
|
|
110
|
+
if (!parsed.ok || !parsed.version) {
|
|
111
|
+
return { available: false, error: 'invalid-manifest' };
|
|
112
|
+
}
|
|
113
|
+
const order = compareSemver(guest.version, parsed.version);
|
|
114
|
+
if (order === null) {
|
|
115
|
+
return { available: false, error: 'invalid-manifest' };
|
|
116
|
+
}
|
|
117
|
+
if (order >= 0) {
|
|
118
|
+
return { available: false, version: parsed.version };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
available: true,
|
|
122
|
+
version: parsed.version,
|
|
123
|
+
requested: requestedGuestCapabilities(parsed.manifest.contributes),
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check results per store file, then per guest id. This is memory only and
|
|
129
|
+
* separate from the catalog cache, so a store write (enable, approve) does
|
|
130
|
+
* not throw away an hour-old answer from the network.
|
|
131
|
+
* @type {Map<string, Map<string, { checkedAt: number, result: Awaited<ReturnType<typeof checkGuestUpdate>> }>>}
|
|
132
|
+
*/
|
|
133
|
+
const updateCache = new Map();
|
|
134
|
+
|
|
135
|
+
const cacheFor = (persistPath) => {
|
|
136
|
+
let byGuest = updateCache.get(persistPath);
|
|
137
|
+
if (!byGuest) {
|
|
138
|
+
byGuest = new Map();
|
|
139
|
+
updateCache.set(persistPath, byGuest);
|
|
140
|
+
}
|
|
141
|
+
return byGuest;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const clearGuestUpdateCache = (persistPath, guestId) => {
|
|
145
|
+
if (!persistPath) {
|
|
146
|
+
updateCache.clear();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (guestId === undefined) {
|
|
150
|
+
updateCache.delete(persistPath);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
updateCache.get(persistPath)?.delete(guestId);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** The cached `{ version }` for a guest with a pending update, else `null`. */
|
|
157
|
+
export const getCachedGuestUpdate = (persistPath, guestId) => {
|
|
158
|
+
const entry = updateCache.get(persistPath)?.get(guestId);
|
|
159
|
+
return entry?.result.available ? { version: entry.result.version } : null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** The catalog row plus `update` when the last check found a newer version. */
|
|
163
|
+
export const withGuestUpdate = (guest, persistPath) => {
|
|
164
|
+
const update = getCachedGuestUpdate(persistPath, guest.id);
|
|
165
|
+
return update ? { ...guest, update } : guest;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check every git install. A guest checked within `UPDATE_CHECK_TTL_MS`
|
|
170
|
+
* answers from the cache unless `force`. Checks run in parallel; one guest's
|
|
171
|
+
* failure never hides another's update.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<Record<string, { version: string }>>} guests with an update
|
|
174
|
+
*/
|
|
175
|
+
export const checkAllGuestUpdates = async ({ persistPath, force = false, gitBinary, now = Date.now() }) => {
|
|
176
|
+
const guests = await listInstalledGuests({ persistPath });
|
|
177
|
+
const byGuest = cacheFor(persistPath);
|
|
178
|
+
await Promise.all(guests.map(async (guest) => {
|
|
179
|
+
if (guest.source !== 'git' || !guest.gitOrigin) {
|
|
180
|
+
byGuest.delete(guest.id);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const cached = byGuest.get(guest.id);
|
|
184
|
+
if (!force && cached && now - cached.checkedAt < UPDATE_CHECK_TTL_MS) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const result = await checkGuestUpdate({ guest, origin: guest.gitOrigin, gitBinary });
|
|
188
|
+
byGuest.set(guest.id, { checkedAt: now, result });
|
|
189
|
+
}));
|
|
190
|
+
/** @type {Record<string, { version: string }>} */
|
|
191
|
+
const updates = {};
|
|
192
|
+
for (const guest of guests) {
|
|
193
|
+
const update = getCachedGuestUpdate(persistPath, guest.id);
|
|
194
|
+
if (update) {
|
|
195
|
+
updates[guest.id] = update;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return updates;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const removeDir = async (dir) => {
|
|
202
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const exists = async (target) => {
|
|
206
|
+
try {
|
|
207
|
+
await fs.lstat(target);
|
|
208
|
+
return true;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Replace the installed git copy with a fresh clone of its origin.
|
|
216
|
+
*
|
|
217
|
+
* The new clone lands in a sibling temp dir and is inspected exactly like an
|
|
218
|
+
* install (manifest, built scripts, engines). Only then is the live folder
|
|
219
|
+
* swapped: current → `.old-<id>`, temp → final, then `.old-<id>` is deleted.
|
|
220
|
+
* A failure before the swap removes the temp dir and leaves the install as
|
|
221
|
+
* it was. A failed second rename moves the old folder back. Grants, tokens,
|
|
222
|
+
* settings, and the paused flag are keyed by guest id, so they survive
|
|
223
|
+
* unchanged; the catalog row for the id is re-read from disk.
|
|
224
|
+
*
|
|
225
|
+
* @returns {Promise<{ ok: true, id: string } | { ok: false, code: string, required?: string }>}
|
|
226
|
+
*/
|
|
227
|
+
export const updateGuest = async ({ guest, origin, persistPath, openchamberVersion, gitBinary, lookup }) => {
|
|
228
|
+
if (guest.source !== 'git' || !origin?.url || !isCopiedGuestRoot(guest.packageRoot, persistPath)) {
|
|
229
|
+
return { ok: false, code: 'not-git' };
|
|
230
|
+
}
|
|
231
|
+
const copies = guestCopiesDir(persistPath);
|
|
232
|
+
await fs.mkdir(copies, { recursive: true });
|
|
233
|
+
const staging = path.join(copies, `.tmp-${guest.id}-${crypto.randomBytes(6).toString('hex')}`);
|
|
234
|
+
let packageRoot;
|
|
235
|
+
try {
|
|
236
|
+
const cloned = await cloneGitRepository(origin.url, staging, { gitBinary, ref: origin.ref, gitIdentityId: origin.gitIdentityId, lookup });
|
|
237
|
+
if (!cloned.ok) {
|
|
238
|
+
await removeDir(staging);
|
|
239
|
+
return cloned;
|
|
240
|
+
}
|
|
241
|
+
packageRoot = await unwrapGuestRoot(staging);
|
|
242
|
+
const inspected = await inspectGuestPackage(packageRoot, { openchamberVersion });
|
|
243
|
+
if (!inspected.ok) {
|
|
244
|
+
await removeDir(staging);
|
|
245
|
+
return inspected;
|
|
246
|
+
}
|
|
247
|
+
// A repo that now declares a different panel id is not an update of this
|
|
248
|
+
// extension; installing it would orphan grants and tokens under the old id.
|
|
249
|
+
if (inspected.guest.id !== guest.id) {
|
|
250
|
+
await removeDir(staging);
|
|
251
|
+
return { ok: false, code: 'invalid-manifest' };
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
await removeDir(staging);
|
|
255
|
+
return { ok: false, code: 'clone-failed' };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The service (if running) holds files from the old copy; on Windows that
|
|
259
|
+
// blocks the rename. It restarts on the next call from the new code.
|
|
260
|
+
await stopGuestService(guest.id);
|
|
261
|
+
|
|
262
|
+
const dest = guest.packageRoot;
|
|
263
|
+
const old = path.join(copies, `.old-${guest.id}`);
|
|
264
|
+
try {
|
|
265
|
+
await removeDir(old);
|
|
266
|
+
await fs.rename(dest, old);
|
|
267
|
+
} catch {
|
|
268
|
+
await removeDir(staging);
|
|
269
|
+
return { ok: false, code: 'swap-failed' };
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await fs.rename(packageRoot, dest);
|
|
273
|
+
} catch {
|
|
274
|
+
// Put the previous copy back so the user keeps a working extension.
|
|
275
|
+
try {
|
|
276
|
+
if (!await exists(dest)) {
|
|
277
|
+
await fs.rename(old, dest);
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
// Nothing more can be done; the next catalog read omits this guest and
|
|
281
|
+
// the user can reinstall from the same URL.
|
|
282
|
+
}
|
|
283
|
+
await removeDir(staging);
|
|
284
|
+
invalidateGuestCatalog(persistPath);
|
|
285
|
+
return { ok: false, code: 'swap-failed' };
|
|
286
|
+
}
|
|
287
|
+
await removeDir(old);
|
|
288
|
+
if (packageRoot !== staging) {
|
|
289
|
+
await removeDir(staging);
|
|
290
|
+
}
|
|
291
|
+
invalidateGuestCatalog(persistPath);
|
|
292
|
+
clearGuestUpdateCache(persistPath, guest.id);
|
|
293
|
+
return { ok: true, id: guest.id };
|
|
294
|
+
};
|