@kabmo-labs/kabmo 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +27 -28
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How each native shell's release asset is NAMED, and what version it declares.
|
|
3
|
+
*
|
|
4
|
+
* Split from `shell-release.ts` because that file owns the two READS (the REST
|
|
5
|
+
* API and the public-pages fallback) and this one owns the naming rules, and
|
|
6
|
+
* because the repo's ceiling is 350 lines per file — the two concerns together
|
|
7
|
+
* crossed it.
|
|
8
|
+
*
|
|
9
|
+
* The naming is the load-bearing half: the asset NAME is the only place a shell
|
|
10
|
+
* artifact's version exists. The release TAG names the SERVER version, and the
|
|
11
|
+
* two move independently, so a parser that read the tag would compare a server
|
|
12
|
+
* version against a shell version.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
16
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One shell's asset scheme, plus the spelling it carried BEFORE the rename.
|
|
20
|
+
*
|
|
21
|
+
* ONE scheme for every shell in the project — `<App>-<version>-<platform>…`:
|
|
22
|
+
* `Kabmo-0.5.0-android.apk`, `Kabmo-0.1.0-ios.ipa`, and the desktop trio's
|
|
23
|
+
* `Kabmo-0.2.0-mac-arm64.dmg` / `-win-x64-setup.exe` / `-linux-x86_64.tar.gz`.
|
|
24
|
+
* The mobile pair carries no architecture because neither artifact is per-arch:
|
|
25
|
+
* the APK is one universal build (the shell is pure Kotlin with no native
|
|
26
|
+
* libraries, so there is nothing to split), and the IPA contains only the device
|
|
27
|
+
* slice (`arm64-apple-ios`).
|
|
28
|
+
*
|
|
29
|
+
* The desktop entries match on the segment AFTER the version rather than on the
|
|
30
|
+
* whole name, because one release carries several files per platform
|
|
31
|
+
* (`-mac-arm64.zip` and `-mac-arm64.dmg`, four Linux formats, two arches). Any
|
|
32
|
+
* one of them answers the same version, which is all `pattern` is for.
|
|
33
|
+
*
|
|
34
|
+
* WHICH file the console hands back is a second question, and `selfUpdate` is
|
|
35
|
+
* the answer: a desktop shell that knows its architecture installs one exact
|
|
36
|
+
* artifact, and a probe that does not know it still gets the release page. Both
|
|
37
|
+
* live in this table so the route, the pages fallback and the tests read one
|
|
38
|
+
* spelling.
|
|
39
|
+
*
|
|
40
|
+
* **`legacy` is not dead code.** These names are PARSED to answer "is a newer
|
|
41
|
+
* shell published", and every release already on GitHub carries the old mobile
|
|
42
|
+
* spelling. Dropping it would make the console answer "no update" for an
|
|
43
|
+
* installed shell the moment this ships — silently, because a name that does not
|
|
44
|
+
* match reads exactly like a release with no artifact. The desktop trio has no
|
|
45
|
+
* legacy spelling: they were born under the current scheme.
|
|
46
|
+
*/
|
|
47
|
+
export interface AssetScheme {
|
|
48
|
+
/** The whole-name pattern for the mobile pair, the segment for the desktops. */
|
|
49
|
+
pattern: RegExp;
|
|
50
|
+
/** The prefix that marks the CURRENT scheme, for "prefer the new spelling". */
|
|
51
|
+
current: RegExp;
|
|
52
|
+
/** The old mobile spelling, or null for a shell that never had one. */
|
|
53
|
+
legacy: { prefix: string; extension: string } | null;
|
|
54
|
+
/**
|
|
55
|
+
* Whether the payload's `url` should be the artifact itself. True for the
|
|
56
|
+
* mobile pair, whose artifact is the same file on every device.
|
|
57
|
+
*/
|
|
58
|
+
directDownload: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The ONE file a desktop shell installs ITSELF from, per architecture.
|
|
61
|
+
*
|
|
62
|
+
* `null` for the mobile pair: an APK is installed by `PackageInstaller` and an
|
|
63
|
+
* IPA cannot be installed at all, so neither is this shell's own business.
|
|
64
|
+
*
|
|
65
|
+
* The desktop entry is deliberately narrower than `pattern`: one release
|
|
66
|
+
* carries several files per platform (`.zip` and `.dmg`, `.tar.gz`/`.deb`/
|
|
67
|
+
* `.rpm`/`.AppImage`), and only ONE of each is something the shell can install
|
|
68
|
+
* without a human opening it. The choice is stated here rather than at the
|
|
69
|
+
* call site so the route, the pages fallback and the tests cannot disagree
|
|
70
|
+
* about which file "the update" means.
|
|
71
|
+
*/
|
|
72
|
+
selfUpdate: SelfUpdateScheme | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How one desktop shell's self-update artifact is named.
|
|
77
|
+
*
|
|
78
|
+
* `pattern` captures the version AND the architecture, because the two answers
|
|
79
|
+
* come from the same string: the version is what the console compares against
|
|
80
|
+
* the installed build, and the architecture is what makes this file the right
|
|
81
|
+
* one for the machine asking.
|
|
82
|
+
*/
|
|
83
|
+
export interface SelfUpdateScheme {
|
|
84
|
+
/** `Kabmo-<version>-<platform>-<arch><suffix>` → `[name, version, arch]`. */
|
|
85
|
+
pattern: RegExp;
|
|
86
|
+
/** How each canonical arch is spelled in the ASSET name. */
|
|
87
|
+
assetArch: Record<ShellArch, string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const VERSION = String.raw`(\d+\.\d+\.\d+)`;
|
|
91
|
+
|
|
92
|
+
/** `arm64`/`x64` as macOS and Windows spell them. */
|
|
93
|
+
const SAME_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x64' };
|
|
94
|
+
|
|
95
|
+
/** Linux says `x86_64` where the other two say `x64`. */
|
|
96
|
+
const LINUX_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x86_64' };
|
|
97
|
+
|
|
98
|
+
export const SHELL_ASSETS: Record<ShellKind, AssetScheme> = {
|
|
99
|
+
android: {
|
|
100
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-android\\.apk$`),
|
|
101
|
+
current: /^Kabmo-\d/,
|
|
102
|
+
legacy: { prefix: 'kabmo-android-v', extension: '.apk' },
|
|
103
|
+
directDownload: true,
|
|
104
|
+
selfUpdate: null,
|
|
105
|
+
},
|
|
106
|
+
ios: {
|
|
107
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-ios\\.ipa$`),
|
|
108
|
+
current: /^Kabmo-\d/,
|
|
109
|
+
legacy: { prefix: 'kabmo-ios-v', extension: '.ipa' },
|
|
110
|
+
directDownload: true,
|
|
111
|
+
selfUpdate: null,
|
|
112
|
+
},
|
|
113
|
+
macos: {
|
|
114
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-mac-`),
|
|
115
|
+
current: /^Kabmo-\d/,
|
|
116
|
+
legacy: null,
|
|
117
|
+
directDownload: false,
|
|
118
|
+
// The `.zip`, not the `.dmg`: `ditto -x -k` extracts it without mounting a
|
|
119
|
+
// volume, and the shell has to be able to do that with no human present.
|
|
120
|
+
// Both shapes are published; only one is installable by the app itself.
|
|
121
|
+
selfUpdate: {
|
|
122
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-mac-(arm64|x64)\\.zip$`),
|
|
123
|
+
assetArch: SAME_ARCH,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
windows: {
|
|
127
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-win-`),
|
|
128
|
+
current: /^Kabmo-\d/,
|
|
129
|
+
legacy: null,
|
|
130
|
+
directDownload: false,
|
|
131
|
+
// The INSTALLER, not the `.zip`-shaped publish output: Inno already owns the
|
|
132
|
+
// upgrade semantics (a stable `AppId` is what makes an install an upgrade
|
|
133
|
+
// rather than a second copy), and `/CLOSEAPPLICATIONS` is how it replaces a
|
|
134
|
+
// running exe.
|
|
135
|
+
selfUpdate: {
|
|
136
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-win-(arm64|x64)-setup\\.exe$`),
|
|
137
|
+
assetArch: SAME_ARCH,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
linux: {
|
|
141
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-linux-`),
|
|
142
|
+
current: /^Kabmo-\d/,
|
|
143
|
+
legacy: null,
|
|
144
|
+
directDownload: false,
|
|
145
|
+
// The AppImage, and only it: `.deb`/`.rpm` need root and a package manager,
|
|
146
|
+
// so a shell cannot install them — it says so instead. An AppImage is ONE
|
|
147
|
+
// file the shell can replace atomically and re-exec.
|
|
148
|
+
selfUpdate: {
|
|
149
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-linux-(arm64|x86_64)\\.AppImage$`),
|
|
150
|
+
assetArch: LINUX_ARCH,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The self-update artifact for one architecture, if this name is it.
|
|
157
|
+
*
|
|
158
|
+
* Null for a shell that has no self-update, for an arch it does not publish, and
|
|
159
|
+
* for every other file in the release (a `.dmg`, a `.deb`, the OTHER
|
|
160
|
+
* architecture's build). Callers treat null as "not the file this machine
|
|
161
|
+
* installs", which is why the route falls back to the release page rather than
|
|
162
|
+
* guessing.
|
|
163
|
+
*/
|
|
164
|
+
export function selfUpdateArtifact(
|
|
165
|
+
kind: ShellKind,
|
|
166
|
+
arch: ShellArch,
|
|
167
|
+
name: string,
|
|
168
|
+
): { version: string } | null {
|
|
169
|
+
const scheme = SHELL_ASSETS[kind].selfUpdate;
|
|
170
|
+
if (!scheme) return null;
|
|
171
|
+
|
|
172
|
+
const match = scheme.pattern.exec(name);
|
|
173
|
+
if (!match) return null;
|
|
174
|
+
// The captured arch is compared against the ASSET spelling, so a Linux build
|
|
175
|
+
// reporting `x64` matches `…-linux-x86_64.AppImage` and nothing else.
|
|
176
|
+
if (match[2] !== scheme.assetArch[arch]) return null;
|
|
177
|
+
return { version: match[1] ?? '' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `Kabmo-0.5.0-android.apk` → `0.5.0`, the `.ipa` twin, every desktop spelling,
|
|
182
|
+
* plus the legacy `kabmo-android-v0.5.0.apk` the older releases still carry.
|
|
183
|
+
*/
|
|
184
|
+
export function versionFromAssetName(kind: ShellKind, name: string): string | null {
|
|
185
|
+
const scheme = SHELL_ASSETS[kind];
|
|
186
|
+
|
|
187
|
+
const current = scheme.pattern.exec(name);
|
|
188
|
+
if (current) return current[1] ?? null;
|
|
189
|
+
|
|
190
|
+
// Legacy: `kabmo-android-v<version>.apk`. Kept for the releases already
|
|
191
|
+
// published; no new release writes it.
|
|
192
|
+
const legacy = scheme.legacy;
|
|
193
|
+
if (legacy && name.startsWith(legacy.prefix) && name.endsWith(legacy.extension)) {
|
|
194
|
+
const middle = name.slice(legacy.prefix.length, -legacy.extension.length);
|
|
195
|
+
const version = middle.startsWith('v') ? middle.slice(1) : middle;
|
|
196
|
+
return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shell-release answer cache, and where the GitHub reads go.
|
|
8
|
+
*
|
|
9
|
+
* Split from `shell-release.ts` for two reasons that are not cosmetic.
|
|
10
|
+
*
|
|
11
|
+
* 1. **The key is per SHELL AND per ARCHITECTURE.** A desktop shell asks for the
|
|
12
|
+
* artifact of its own machine, so a single per-kind slot would answer an
|
|
13
|
+
* arm64 window with the x64 installer the moment both hit one console — and
|
|
14
|
+
* the console is routinely shared by a whole team.
|
|
15
|
+
* 2. **The cache outlives a test file.** It lives on `globalThis` so `bun --hot`
|
|
16
|
+
* cannot orphan it, which is also why `resetShellReleaseCache` exists: without
|
|
17
|
+
* that seam the second case in a suite reads the first case's answer and
|
|
18
|
+
* passes for the wrong reason (measured — the "malformed body" case returned
|
|
19
|
+
* the previous test's release).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
23
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
24
|
+
|
|
25
|
+
/** How long an answer is reused. */
|
|
26
|
+
export const CACHE_MS = 5 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** One shell's (and, for a desktop, one architecture's) cached answer. */
|
|
29
|
+
export interface CacheSlot<T> {
|
|
30
|
+
entry: T | null;
|
|
31
|
+
at: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ShellCacheHost = typeof globalThis & {
|
|
35
|
+
__kabmoShellRelease?: Record<string, CacheSlot<unknown>>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The cache key for one question.
|
|
40
|
+
*
|
|
41
|
+
* The arch is part of the KEY rather than a field on the value, so a stale entry
|
|
42
|
+
* can never be read by the wrong machine: a miss is a miss, and there is no
|
|
43
|
+
* comparison left to get wrong.
|
|
44
|
+
*/
|
|
45
|
+
export function shellCacheKey(kind: ShellKind, arch: ShellArch | null): string {
|
|
46
|
+
return arch ? `${kind}:${arch}` : kind;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The cache slot for one key, created on first use. */
|
|
50
|
+
export function cacheSlot<T>(key: string): CacheSlot<T> {
|
|
51
|
+
const host = globalThis as ShellCacheHost;
|
|
52
|
+
host.__kabmoShellRelease ??= {};
|
|
53
|
+
host.__kabmoShellRelease[key] ??= { entry: null, at: 0 };
|
|
54
|
+
return host.__kabmoShellRelease[key] as CacheSlot<T>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drop the cached answer for one shell (or every shell, with no argument).
|
|
59
|
+
*
|
|
60
|
+
* Production has no caller; it is the test seam described above.
|
|
61
|
+
*/
|
|
62
|
+
export function resetShellReleaseCache(kind?: ShellKind): void {
|
|
63
|
+
const host = globalThis as ShellCacheHost;
|
|
64
|
+
if (!kind) {
|
|
65
|
+
host.__kabmoShellRelease = {};
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const prefix = `${kind}:`;
|
|
69
|
+
const slots = host.__kabmoShellRelease ?? {};
|
|
70
|
+
for (const key of Object.keys(slots)) {
|
|
71
|
+
if (key === kind || key.startsWith(prefix)) delete slots[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where the release read goes.
|
|
77
|
+
*
|
|
78
|
+
* Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
|
|
79
|
+
* test and a rehearsal need to exercise the real parsing against a stub instead
|
|
80
|
+
* of a live GitHub call, and hardcoding the host makes that impossible without
|
|
81
|
+
* intercepting `fetch` globally.
|
|
82
|
+
*/
|
|
83
|
+
export function githubApiBase(): string {
|
|
84
|
+
const configured = Bun.env.KABMO_GITHUB_API ?? '';
|
|
85
|
+
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A token, when the operator has one, which lifts the API quota from 60 an hour
|
|
90
|
+
* to 5000 — the difference between the precise read working on a shared address
|
|
91
|
+
* and falling through to the pages every time.
|
|
92
|
+
*
|
|
93
|
+
* Read from the environment rather than a setting: it is a deployment fact, it
|
|
94
|
+
* must never reach the browser, and `kabmo serve` already passes its own
|
|
95
|
+
* environment through.
|
|
96
|
+
*/
|
|
97
|
+
export function gitHubHeaders(): Record<string, string> {
|
|
98
|
+
const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
|
|
99
|
+
const headers: Record<string, string> = {
|
|
100
|
+
// GitHub rejects a request with no User-Agent.
|
|
101
|
+
'user-agent': 'kabmo-console',
|
|
102
|
+
};
|
|
103
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
104
|
+
return headers;
|
|
105
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { fetchShellRelease, resetShellReleaseCache } from '@/server/lib/updates/shell-release';
|
|
9
|
+
import { releaseTags } from '@/server/lib/updates/shell-pages';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A double that routes by URL, for the two-path probe.
|
|
14
|
+
*
|
|
15
|
+
* `api` answers the REST read (or fails, which is what a spent quota does) and
|
|
16
|
+
* the pages answer the Atom feed and each release's asset fragment. Returning
|
|
17
|
+
* `undefined` for a route means "this request fails", which is how the fallback
|
|
18
|
+
* is entered at all.
|
|
19
|
+
*/
|
|
20
|
+
function routedFetch(routes: {
|
|
21
|
+
api?: unknown;
|
|
22
|
+
atom?: string;
|
|
23
|
+
assets?: Record<string, string>;
|
|
24
|
+
}) {
|
|
25
|
+
const calls: string[] = [];
|
|
26
|
+
const impl = (async (input: string | URL | Request) => {
|
|
27
|
+
const url = String(input);
|
|
28
|
+
calls.push(url);
|
|
29
|
+
|
|
30
|
+
if (url.includes('/repos/')) {
|
|
31
|
+
if (routes.api === undefined) return { ok: false, json: async () => ({}), text: async () => '' } as Response;
|
|
32
|
+
return { ok: true, json: async () => routes.api, text: async () => JSON.stringify(routes.api) } as Response;
|
|
33
|
+
}
|
|
34
|
+
if (url.endsWith('/releases.atom')) {
|
|
35
|
+
const body = routes.atom ?? '';
|
|
36
|
+
return { ok: Boolean(routes.atom), json: async () => ({}), text: async () => body } as Response;
|
|
37
|
+
}
|
|
38
|
+
const tag = url.slice(url.lastIndexOf('/') + 1);
|
|
39
|
+
const body = routes.assets?.[decodeURIComponent(tag)];
|
|
40
|
+
return { ok: body !== undefined, json: async () => ({}), text: async () => body ?? '' } as Response;
|
|
41
|
+
}) as unknown as typeof fetch;
|
|
42
|
+
return { impl, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The public-pages fallback, and the reason it exists.
|
|
47
|
+
*
|
|
48
|
+
* The REST read is unauthenticated at 60 requests an hour per source IP, and on a
|
|
49
|
+
* mobile network that IP is a carrier NAT shared with an entire subscriber base.
|
|
50
|
+
* Measured on the author's machine: `remaining: 0`, so the probe answered `null`
|
|
51
|
+
* and the console drew no update button for an APK two versions behind the
|
|
52
|
+
* published one — a missing button being indistinguishable from "you are up to
|
|
53
|
+
* date". These cases pin the second path, which is what the report needed.
|
|
54
|
+
*/
|
|
55
|
+
describe('the fallback when the API is unavailable', () => {
|
|
56
|
+
beforeEach(() => resetShellReleaseCache());
|
|
57
|
+
|
|
58
|
+
const ATOM = `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
60
|
+
<title>Release notes from kabmo</title>
|
|
61
|
+
<entry>
|
|
62
|
+
<id>tag:github.com,2008:Repository/1/v0.7.0</id>
|
|
63
|
+
<link rel="alternate" type="text/html" href="https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0"/>
|
|
64
|
+
<title>v0.7.0</title>
|
|
65
|
+
</entry>
|
|
66
|
+
<entry>
|
|
67
|
+
<link rel="alternate" type="text/html" href="https://github.com/kabmo-labs/kabmo/releases/tag/kabmo-sdk/v0.1.0"/>
|
|
68
|
+
<title>kabmo-sdk/v0.1.0</title>
|
|
69
|
+
</entry>
|
|
70
|
+
<entry>
|
|
71
|
+
<link rel="alternate" type="text/html" href="https://github.com/kabmo-labs/kabmo/releases/tag/v0.6.1"/>
|
|
72
|
+
<title>v0.6.1</title>
|
|
73
|
+
</entry>
|
|
74
|
+
</feed>`;
|
|
75
|
+
|
|
76
|
+
/** The fragment GitHub's release page loads, trimmed to its asset links. */
|
|
77
|
+
const ASSETS_V070 = `
|
|
78
|
+
<div class="Box">
|
|
79
|
+
<li><a href="/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk"><span class="text-bold">Kabmo-0.6.0-android.apk</span></a></li>
|
|
80
|
+
<li><a href="/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.2.0-mac-arm64.zip"><span class="text-bold">Kabmo-0.2.0-mac-arm64.zip</span></a></li>
|
|
81
|
+
</div>`;
|
|
82
|
+
|
|
83
|
+
test('resolves the APK when the API read is refused', async () => {
|
|
84
|
+
// `api: undefined` is a spent quota: the route answers 403 with a body that
|
|
85
|
+
// is not a release list.
|
|
86
|
+
const { impl, calls } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
|
|
87
|
+
|
|
88
|
+
expect(await fetchShellRelease('android', impl)).toEqual({
|
|
89
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
|
|
90
|
+
latest: '0.6.0',
|
|
91
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
92
|
+
direct: true,
|
|
93
|
+
});
|
|
94
|
+
expect(calls.some((url) => url.includes('/repos/'))).toBe(true);
|
|
95
|
+
expect(calls.some((url) => url.endsWith('/releases.atom'))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('walks past a release that carries no artifact of this shell', async () => {
|
|
99
|
+
// The packages are released FIRST, so the newest tag is often a
|
|
100
|
+
// `kabmo-sdk/v*` release with no shell in it — the same shape that made
|
|
101
|
+
// `/releases/latest` wrong.
|
|
102
|
+
const { impl } = routedFetch({
|
|
103
|
+
atom: ATOM,
|
|
104
|
+
assets: { 'kabmo-sdk/v0.1.0': '<div>no assets</div>', 'v0.7.0': ASSETS_V070 },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect((await fetchShellRelease('android', impl))?.latest).toBe('0.6.0');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('answers the release page for a desktop shell with no arch', async () => {
|
|
111
|
+
const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
|
|
112
|
+
|
|
113
|
+
expect(await fetchShellRelease('macos', impl)).toEqual({
|
|
114
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
115
|
+
latest: '0.2.0',
|
|
116
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
117
|
+
direct: false,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('answers that arch’s own artifact when the fragment lists it', async () => {
|
|
122
|
+
// The pages fallback carries the same arch rule as the API read: a desktop
|
|
123
|
+
// shell that named its machine gets that machine's installer, and a build it
|
|
124
|
+
// does not publish is never substituted.
|
|
125
|
+
const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
|
|
126
|
+
|
|
127
|
+
expect(await fetchShellRelease('macos', impl, 'arm64')).toEqual({
|
|
128
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.2.0-mac-arm64.zip',
|
|
129
|
+
latest: '0.2.0',
|
|
130
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
131
|
+
direct: true,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// x64 is not in the fragment, so the page is the answer rather than the
|
|
135
|
+
// arm64 file.
|
|
136
|
+
const again = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
|
|
137
|
+
expect(await fetchShellRelease('macos', again.impl, 'x64')).toEqual({
|
|
138
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
139
|
+
latest: '0.2.0',
|
|
140
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
141
|
+
direct: false,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a release with no artifact anywhere answers empty, not null', async () => {
|
|
146
|
+
// "The read worked and there is nothing" and "the read failed" must stay
|
|
147
|
+
// distinguishable: the first is cacheable and the second keeps the last good
|
|
148
|
+
// answer.
|
|
149
|
+
const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': '<div></div>' } });
|
|
150
|
+
expect(await fetchShellRelease('android', impl)).toEqual({
|
|
151
|
+
url: null,
|
|
152
|
+
latest: null,
|
|
153
|
+
releaseUrl: null,
|
|
154
|
+
direct: false,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('an unreachable feed answers null rather than an empty result', async () => {
|
|
159
|
+
const { impl } = routedFetch({});
|
|
160
|
+
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the API answer wins when it works, so the fallback costs nothing', async () => {
|
|
164
|
+
const { impl, calls } = routedFetch({
|
|
165
|
+
api: [
|
|
166
|
+
{
|
|
167
|
+
html_url: 'https://x/api',
|
|
168
|
+
assets: [{ name: 'Kabmo-1.0.0-android.apk', browser_download_url: 'https://x/a.apk' }],
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
atom: ATOM,
|
|
172
|
+
assets: { 'v0.7.0': ASSETS_V070 },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect((await fetchShellRelease('android', impl))?.latest).toBe('1.0.0');
|
|
176
|
+
expect(calls.some((url) => url.endsWith('/releases.atom'))).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('releaseTags reads the feed in order, with each page', () => {
|
|
180
|
+
expect(releaseTags(ATOM)).toEqual([
|
|
181
|
+
{ name: 'v0.7.0', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0' },
|
|
182
|
+
{ name: 'kabmo-sdk/v0.1.0', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/kabmo-sdk/v0.1.0' },
|
|
183
|
+
{ name: 'v0.6.1', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.6.1' },
|
|
184
|
+
]);
|
|
185
|
+
expect(releaseTags('')).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PUBLIC release pages, for when the GitHub API is unavailable.
|
|
3
|
+
*
|
|
4
|
+
* Split from `shell-release.ts` because it is a second, independent read: the API
|
|
5
|
+
* path parses JSON, this one parses an Atom feed and an HTML fragment, and the two
|
|
6
|
+
* share nothing but the asset-naming rules in `shell-assets`.
|
|
7
|
+
*
|
|
8
|
+
* ## Why it exists
|
|
9
|
+
*
|
|
10
|
+
* The REST API is unauthenticated at 60 requests an hour **per source IP** — and
|
|
11
|
+
* on a mobile network that IP is shared with an entire carrier NAT. Measured on
|
|
12
|
+
* the author's machine: `remaining: 0`, so the probe answered `null` and the
|
|
13
|
+
* console drew no update button for an APK two versions behind the published one.
|
|
14
|
+
* A missing button is indistinguishable from "you are up to date", which is why
|
|
15
|
+
* that failed silently for as long as it did.
|
|
16
|
+
*
|
|
17
|
+
* `github.com/<repo>/releases.atom` is a plain Atom feed of published releases
|
|
18
|
+
* (newest first) and `github.com/<repo>/releases/expanded_assets/<tag>` is the
|
|
19
|
+
* HTML fragment the release page itself loads. Both are served without an API
|
|
20
|
+
* budget — verified live: 200 on both, and the fragment carries every asset link.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { REPO } from '@/server/lib/updates/release-url';
|
|
24
|
+
import { SHELL_ASSETS, selfUpdateArtifact, versionFromAssetName } from '@/server/lib/updates/shell-assets';
|
|
25
|
+
import type { ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
26
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
27
|
+
|
|
28
|
+
/** How many tags to walk before giving up. */
|
|
29
|
+
const PAGE_SCAN = 6;
|
|
30
|
+
|
|
31
|
+
/** A tag from the Atom feed, with the release page it names. */
|
|
32
|
+
export interface ReleaseTag {
|
|
33
|
+
name: string;
|
|
34
|
+
url: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One asset found in a release's fragment. */
|
|
38
|
+
export interface PageAsset {
|
|
39
|
+
name: string;
|
|
40
|
+
downloadUrl: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the pages read concluded.
|
|
45
|
+
*
|
|
46
|
+
* THREE cases, not two, because "the feed could not be read" and "the feed was
|
|
47
|
+
* read and carries no artifact of this shell" are different facts: the first
|
|
48
|
+
* keeps the last good answer, the second is a cacheable empty result. Collapsing
|
|
49
|
+
* them makes a momentary outage look like "no update published", which is the
|
|
50
|
+
* same silence the API path's `null` exists to avoid.
|
|
51
|
+
*/
|
|
52
|
+
export type PagesResult =
|
|
53
|
+
| { kind: 'found'; asset: PageAsset; releaseUrl: string | null }
|
|
54
|
+
| { kind: 'none' }
|
|
55
|
+
| { kind: 'unreachable' };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The newest tag that carries this shell's artifact.
|
|
59
|
+
*
|
|
60
|
+
* The FIRST tag with a matching asset wins, because the feed is newest-first and
|
|
61
|
+
* a release that carries no artifact of this shell (a `kabmo-sdk/v*` package
|
|
62
|
+
* release, which the pipeline publishes before the app) must not stop the walk.
|
|
63
|
+
*/
|
|
64
|
+
export async function findAssetInPages(
|
|
65
|
+
kind: ShellKind,
|
|
66
|
+
fetchImpl: typeof fetch,
|
|
67
|
+
headers: Record<string, string>,
|
|
68
|
+
arch: ShellArch | null = null,
|
|
69
|
+
): Promise<PagesResult> {
|
|
70
|
+
const feed = await fetchImpl(`https://github.com/${REPO}/releases.atom`, {
|
|
71
|
+
headers: { accept: 'application/atom+xml, text/xml', ...headers },
|
|
72
|
+
});
|
|
73
|
+
if (!feed.ok) return { kind: 'unreachable' };
|
|
74
|
+
|
|
75
|
+
const tags = releaseTags(await feed.text());
|
|
76
|
+
if (tags.length === 0) return { kind: 'none' };
|
|
77
|
+
|
|
78
|
+
for (const tag of tags.slice(0, PAGE_SCAN)) {
|
|
79
|
+
const page = await fetchImpl(
|
|
80
|
+
`https://github.com/${REPO}/releases/expanded_assets/${encodeURIComponent(tag.name)}`,
|
|
81
|
+
{ headers: { accept: 'text/html', ...headers } },
|
|
82
|
+
);
|
|
83
|
+
if (!page.ok) continue;
|
|
84
|
+
|
|
85
|
+
const asset = shellAssetFromPage(kind, await page.text(), arch);
|
|
86
|
+
if (!asset) continue;
|
|
87
|
+
|
|
88
|
+
return { kind: 'found', asset, releaseUrl: tag.url };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { kind: 'none' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The feed's entries, in the order GitHub lists them (newest first).
|
|
96
|
+
*
|
|
97
|
+
* Parsed with two regexes rather than an XML parser: the feed is a fixed shape
|
|
98
|
+
* GitHub emits, and a dependency for two fields would be the larger risk. The
|
|
99
|
+
* entity decode covers the one escape that appears in practice.
|
|
100
|
+
*/
|
|
101
|
+
export function releaseTags(xml: string): ReleaseTag[] {
|
|
102
|
+
const tags: ReleaseTag[] = [];
|
|
103
|
+
const entries = xml.split('<entry>').slice(1);
|
|
104
|
+
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const title = /<title>([^<]*)<\/title>/.exec(entry)?.[1];
|
|
107
|
+
if (!title) continue;
|
|
108
|
+
const href = /<link[^>]*rel="alternate"[^>]*href="([^"]+)"/.exec(entry)?.[1] ?? null;
|
|
109
|
+
tags.push({ name: decodeEntities(title.trim()), url: href ? decodeEntities(href) : null });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return tags;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The asset links inside a release's `expanded_assets` fragment.
|
|
117
|
+
*
|
|
118
|
+
* The name is read out of the DOWNLOAD URL rather than the link's text: the
|
|
119
|
+
* fragment wraps it in a `<span class="text-bold">` whose markup has changed
|
|
120
|
+
* before, while the URL is the release's own path and carries the same name.
|
|
121
|
+
*
|
|
122
|
+
* The CURRENT naming scheme wins over the legacy one, for the reason
|
|
123
|
+
* `shell-release`'s API path documents: a release carrying both spellings must
|
|
124
|
+
* not resolve to the older version.
|
|
125
|
+
*/
|
|
126
|
+
export function shellAssetFromPage(kind: ShellKind, html: string, arch: ShellArch | null = null): PageAsset | null {
|
|
127
|
+
const found: PageAsset[] = [];
|
|
128
|
+
|
|
129
|
+
for (const match of html.matchAll(ASSET_HREF)) {
|
|
130
|
+
const path = decodeEntities(match[1] ?? '');
|
|
131
|
+
const name = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
|
|
132
|
+
if (versionFromAssetName(kind, name) !== null) found.push({ name, downloadUrl: `https://github.com${path}` });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The architecture decides FIRST, for the reason the API path documents: a
|
|
136
|
+
// desktop shell that named its own machine must not be handed the other
|
|
137
|
+
// architecture's installer just because the fragment lists it first.
|
|
138
|
+
if (arch) {
|
|
139
|
+
const exact = found.find((asset) => selfUpdateArtifact(kind, arch, asset.name) !== null);
|
|
140
|
+
if (exact) return exact;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return found.find((asset) => SHELL_ASSETS[kind].current.test(asset.name)) ?? found[0] ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One asset link in a release fragment. Stated once: the loop and the regex must agree. */
|
|
147
|
+
const ASSET_HREF = /href="(\/[^"]+\/releases\/download\/[^"]+)"/g;
|
|
148
|
+
|
|
149
|
+
/** The entities that appear in a feed title or a link. */
|
|
150
|
+
function decodeEntities(text: string): string {
|
|
151
|
+
return text
|
|
152
|
+
.replace(/</g, '<')
|
|
153
|
+
.replace(/>/g, '>')
|
|
154
|
+
.replace(/"/g, '"')
|
|
155
|
+
.replace(/'/g, "'")
|
|
156
|
+
.replace(/&/g, '&');
|
|
157
|
+
}
|