@kabmo-labs/kabmo 0.8.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.
Files changed (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
1
6
  /**
2
7
  * Which published native-shell artifact a browser or an installed shell should
3
8
  * download, and whether the installed one is behind.
@@ -19,11 +24,12 @@
19
24
  *
20
25
  * - `url` is what to download. For the mobile pair it is the artifact itself (an
21
26
  * APK the shell installs, an IPA the release page explains). For the DESKTOP
22
- * trio it is the RELEASE PAGE, deliberately: every desktop artifact is
23
- * per-architecture (`Kabmo-0.2.0-mac-arm64.zip`), and this server cannot know
24
- * which architecture the window asking is running on — it may be a remote
25
- * console on another machine entirely. Naming one arch's file would hand half
26
- * the readers a build that will not launch.
27
+ * trio it is the artifact when the caller reported its ARCHITECTURE, and the
28
+ * release page otherwise: every desktop artifact is per-architecture, and a
29
+ * console served to another machine cannot guess. Naming the wrong arch's file
30
+ * would hand half the readers a build that will not launch.
31
+ * - `direct` says which of the two `url` is, so the client knows whether the
32
+ * offer can be installed from inside the shell.
27
33
  * - `latest` is the version that asset ships, read from the asset NAME (see
28
34
  * `shell-assets`). The release TAG cannot be used: the artifact is attached to
29
35
  * the app's release, whose tag names the SERVER version.
@@ -34,10 +40,25 @@
34
40
  */
35
41
 
36
42
  import { REPO } from '@/server/lib/updates/release-url';
37
- import { SHELL_ASSETS, versionFromAssetName } from '@/server/lib/updates/shell-assets';
43
+ import {
44
+ cacheSlot,
45
+ CACHE_MS,
46
+ githubApiBase,
47
+ gitHubHeaders,
48
+ shellCacheKey,
49
+ } from '@/server/lib/updates/shell-cache';
50
+ import { SHELL_ASSETS, selfUpdateArtifact, versionFromAssetName } from '@/server/lib/updates/shell-assets';
38
51
  import { findAssetInPages } from '@/server/lib/updates/shell-pages';
52
+ import type { ShellArch } from '@/shared/lib/updates/shell-arch';
39
53
  import type { ShellKind } from '@/shared/lib/updates/shell-ua';
40
54
 
55
+ /**
56
+ * The test seam for the process-wide answer cache, re-exported here because this
57
+ * module is what owns the answers. The cache itself lives next door (`shell-cache`)
58
+ * so its key — shell AND architecture — is stated in one place.
59
+ */
60
+ export { resetShellReleaseCache } from '@/server/lib/updates/shell-cache';
61
+
41
62
  /** One published shell artifact, as the console needs it. */
42
63
  export interface ShellRelease {
43
64
  /** Asset download URL, or the release page for a per-arch desktop build. */
@@ -46,8 +67,21 @@ export interface ShellRelease {
46
67
  latest: string | null;
47
68
  /** The release page, for a "what changed" link. */
48
69
  releaseUrl: string | null;
70
+ /**
71
+ * True when `url` names the artifact itself rather than the release page.
72
+ *
73
+ * This is what tells the console whether the offer can be INSTALLED from
74
+ * inside the shell. It is true for the mobile pair (one file for every
75
+ * device) and for a desktop shell that reported its architecture — and false
76
+ * for a desktop probe without one, which still gets the page that lists every
77
+ * architecture.
78
+ */
79
+ direct: boolean;
49
80
  }
50
81
 
82
+ /** The answer a probe gives when it found nothing: a valid result, not an error. */
83
+ const EMPTY_RELEASE: ShellRelease = { url: null, latest: null, releaseUrl: null, direct: false };
84
+
51
85
  /**
52
86
  * The newest release that actually carries this shell's artifact.
53
87
  *
@@ -57,25 +91,32 @@ export interface ShellRelease {
57
91
  * `release.yml`), so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer
58
92
  * than the app release that carries the shells. `@semantic-release/github` runs
59
93
  * in both pipelines, so those are real releases — measured against a stub that
60
- * answered a package release: `/releases/latest` produced
61
- * `{url: null, latest: null}` and the sidebar's button disappeared even though
62
- * the APK sat on the release directly below.
94
+ * answered a package release: `/releases/latest` produced `{url: null, …}` and
95
+ * the sidebar's button disappeared even though the APK sat on the release
96
+ * directly below.
97
+ *
98
+ * `arch` is what a DESKTOP shell reports about its own machine, and it is the
99
+ * difference between "here is the page, pick your build" and "here is your
100
+ * installer". It is the THIRD parameter so every existing caller — including
101
+ * every test, which passes `fetchImpl` — keeps its shape.
63
102
  *
64
- * Cached for five minutes on `globalThis` for the same reason the database
65
- * handle is anchored there: `bun --hot` re-evaluates this module while the cache
66
- * it would otherwise hold lives on.
103
+ * Cached for five minutes on `globalThis` (see `shell-cache`) for the same
104
+ * reason the database handle is anchored there: `bun --hot` re-evaluates this
105
+ * module while the cache it would otherwise hold lives on.
67
106
  */
68
107
  export async function fetchShellRelease(
69
108
  kind: ShellKind,
70
109
  fetchImpl: typeof fetch = fetch,
110
+ arch: ShellArch | null = null,
71
111
  ): Promise<ShellRelease | null> {
72
- const cache = cacheSlot(kind);
112
+ const cache = cacheSlot<ShellRelease>(shellCacheKey(kind, arch));
73
113
  const now = Date.now();
74
114
  if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
75
115
 
76
116
  const headers = gitHubHeaders();
77
117
  const resolved =
78
- (await readFromApi(kind, fetchImpl, headers)) ?? (await readFromPages(kind, fetchImpl, headers));
118
+ (await readFromApi(kind, arch, fetchImpl, headers)) ??
119
+ (await readFromPages(kind, arch, fetchImpl, headers));
79
120
  if (!resolved) return cache.entry ?? null;
80
121
 
81
122
  cache.entry = resolved;
@@ -94,6 +135,7 @@ const RELEASE_SCAN = 20;
94
135
  */
95
136
  async function readFromApi(
96
137
  kind: ShellKind,
138
+ arch: ShellArch | null,
97
139
  fetchImpl: typeof fetch,
98
140
  headers: Record<string, string>,
99
141
  ): Promise<ShellRelease | null> {
@@ -105,8 +147,8 @@ async function readFromApi(
105
147
 
106
148
  const body = (await response.json()) as unknown;
107
149
  // A body that is not the release list is a failed probe, not an empty
108
- // repository: answering `{url: null, latest: null}` here would be
109
- // indistinguishable from "no release carries an artifact".
150
+ // repository: answering `{url: null, …}` here would be indistinguishable
151
+ // from "no release carries an artifact".
110
152
  if (!Array.isArray(body)) return null;
111
153
 
112
154
  // Drafts are not published and prereleases are not what a console offers an
@@ -117,12 +159,12 @@ async function readFromApi(
117
159
  );
118
160
 
119
161
  const release = published
120
- .map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
162
+ .map((entry) => ({ entry, asset: shellAsset(kind, arch, entry.assets) }))
121
163
  .find((candidate) => candidate.asset !== null);
122
164
 
123
- if (!release?.asset) return { url: null, latest: null, releaseUrl: null };
165
+ if (!release?.asset) return EMPTY_RELEASE;
124
166
 
125
- return resolvedRelease(kind, {
167
+ return resolvedRelease(kind, arch, {
126
168
  name: String(release.asset.name),
127
169
  downloadUrl:
128
170
  typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
@@ -136,14 +178,15 @@ async function readFromApi(
136
178
  /** The public-pages read, wrapped so a failure is the caller's `null`. */
137
179
  async function readFromPages(
138
180
  kind: ShellKind,
181
+ arch: ShellArch | null,
139
182
  fetchImpl: typeof fetch,
140
183
  headers: Record<string, string>,
141
184
  ): Promise<ShellRelease | null> {
142
185
  try {
143
- const found = await findAssetInPages(kind, fetchImpl, headers);
186
+ const found = await findAssetInPages(kind, fetchImpl, headers, arch);
144
187
  if (found.kind === 'unreachable') return null;
145
- if (found.kind === 'none') return { url: null, latest: null, releaseUrl: null };
146
- return resolvedRelease(kind, { ...found.asset, releaseUrl: found.releaseUrl });
188
+ if (found.kind === 'none') return EMPTY_RELEASE;
189
+ return resolvedRelease(kind, arch, { ...found.asset, releaseUrl: found.releaseUrl });
147
190
  } catch {
148
191
  return null;
149
192
  }
@@ -152,16 +195,26 @@ async function readFromPages(
152
195
  /**
153
196
  * The asset of a release that names this shell's artifact, or null.
154
197
  *
155
- * The CURRENT scheme wins over the legacy one, and that is not cosmetic. A
156
- * release that carries both — which happens the moment a rename lands and
157
- * `--clobber` has not yet replaced the old asset, or a hand-upload adds one
158
- * beside the other — would otherwise resolve to whichever the asset list
159
- * happened to order first. Measured: a release holding both
160
- * `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0`,
161
- * i.e. the console offered an update to a version it already had and hid the
162
- * newer one.
198
+ * Two questions, in this order:
199
+ *
200
+ * 1. **Does an architecture match?** When the caller named one, the only
201
+ * acceptable asset is the file that machine installs (`selfUpdateArtifact`).
202
+ * Everything else in the release — the other architecture, the `.dmg`, a
203
+ * `.deb` — is skipped, and the next release is tried.
204
+ * 2. **Which spelling?** The CURRENT scheme wins over the legacy one, and that
205
+ * is not cosmetic. A release that carries both — which happens the moment a
206
+ * rename lands and `--clobber` has not yet replaced the old asset, or a
207
+ * hand-upload adds one beside the other — would otherwise resolve to
208
+ * whichever the asset list happened to order first. Measured: a release
209
+ * holding both `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk`
210
+ * answered `0.4.0`, i.e. the console offered an update to a version it
211
+ * already had and hid the newer one.
163
212
  */
164
- function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
213
+ function shellAsset(
214
+ kind: ShellKind,
215
+ arch: ShellArch | null,
216
+ assets: unknown,
217
+ ): Record<string, unknown> | null {
165
218
  if (!Array.isArray(assets)) return null;
166
219
 
167
220
  const named = assets.filter(
@@ -172,95 +225,38 @@ function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> |
172
225
  versionFromAssetName(kind, String((entry as Record<string, unknown>).name)) !== null,
173
226
  );
174
227
 
228
+ if (arch) {
229
+ const exact = named.find(
230
+ (asset) => selfUpdateArtifact(kind, arch, String(asset.name)) !== null,
231
+ );
232
+ if (exact) return exact;
233
+ }
234
+
175
235
  return named.find((asset) => SHELL_ASSETS[kind].current.test(String(asset.name))) ?? named[0] ?? null;
176
236
  }
177
237
 
178
- /** Turn one resolved asset into the payload, applying the per-kind link rule. */
238
+ /**
239
+ * Turn one resolved asset into the payload.
240
+ *
241
+ * `direct` is derived from the SAME decision that chose the asset: an arch was
242
+ * named and the asset is that arch's self-update file. Deriving it separately —
243
+ * from `directDownload`, or from the platform — is how the client ends up
244
+ * offering an install for a release page.
245
+ */
179
246
  function resolvedRelease(
180
247
  kind: ShellKind,
248
+ arch: ShellArch | null,
181
249
  asset: { name: string; downloadUrl: string | null; releaseUrl: string | null },
182
250
  ): ShellRelease {
251
+ const installable = arch !== null && selfUpdateArtifact(kind, arch, asset.name) !== null;
252
+ const direct = SHELL_ASSETS[kind].directDownload || installable;
253
+
183
254
  return {
184
- // A per-arch desktop build cannot be named from here — see the module
185
- // comment — so the link is the page that lists every architecture.
186
- url: SHELL_ASSETS[kind].directDownload ? asset.downloadUrl : asset.releaseUrl,
255
+ // A per-arch desktop build cannot be named without an architecture — see
256
+ // the module comment — so the link is the page that lists every one.
257
+ url: direct ? asset.downloadUrl : asset.releaseUrl,
187
258
  latest: versionFromAssetName(kind, asset.name),
188
259
  releaseUrl: asset.releaseUrl,
260
+ direct: direct && asset.downloadUrl !== null,
189
261
  };
190
262
  }
191
-
192
- const CACHE_MS = 5 * 60_000;
193
-
194
- /**
195
- * Where the release read goes.
196
- *
197
- * Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
198
- * test and a rehearsal need to exercise the real parsing against a stub instead
199
- * of a live GitHub call, and hardcoding the host makes that impossible without
200
- * intercepting `fetch` globally.
201
- */
202
- function githubApiBase(): string {
203
- const configured = Bun.env.KABMO_GITHUB_API ?? '';
204
- return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
205
- }
206
-
207
- /**
208
- * A token, when the operator has one, which lifts the API quota from 60 an hour
209
- * to 5000 — the difference between the precise read working on a shared address
210
- * and falling through to the pages every time.
211
- *
212
- * Read from the environment rather than a setting: it is a deployment fact, it
213
- * must never reach the browser, and `kabmo serve` already passes its own
214
- * environment through.
215
- */
216
- function gitHubHeaders(): Record<string, string> {
217
- const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
218
- const headers: Record<string, string> = {
219
- // GitHub rejects a request with no User-Agent.
220
- 'user-agent': 'kabmo-console',
221
- };
222
- if (token) headers.authorization = `Bearer ${token}`;
223
- return headers;
224
- }
225
-
226
- interface CacheSlot {
227
- entry: ShellRelease | null;
228
- at: number;
229
- }
230
-
231
- type ShellCacheHost = typeof globalThis & {
232
- __kabmoShellRelease?: Partial<Record<ShellKind, CacheSlot>>;
233
- };
234
-
235
- /**
236
- * The cache slot for one shell.
237
- *
238
- * PER SHELL, not one shared slot: both probes read the same release list but
239
- * resolve different assets, so one slot would make the second shell answer with
240
- * the first one's artifact.
241
- */
242
- function cacheSlot(kind: ShellKind): CacheSlot {
243
- const host = globalThis as ShellCacheHost;
244
- host.__kabmoShellRelease ??= {};
245
- host.__kabmoShellRelease[kind] ??= { entry: null, at: 0 };
246
- return host.__kabmoShellRelease[kind] as CacheSlot;
247
- }
248
-
249
- /**
250
- * Drop the cached answer for one shell (or every shell, with no argument).
251
- *
252
- * The cache lives on `globalThis` so `bun --hot` cannot orphan it, which also
253
- * means it outlives a test file: without this seam the second case in a suite
254
- * reads the first case's answer and passes for the wrong reason (measured — the
255
- * "malformed body" case returned the previous test's release). Production has
256
- * no caller; it exists because the cache is process-wide by design.
257
- */
258
- export function resetShellReleaseCache(kind?: ShellKind): void {
259
- const host = globalThis as ShellCacheHost;
260
- if (!kind) {
261
- host.__kabmoShellRelease = {};
262
- return;
263
- }
264
- host.__kabmoShellRelease ??= {};
265
- host.__kabmoShellRelease[kind] = { entry: null, at: 0 };
266
- }
@@ -19,7 +19,7 @@
19
19
  * answers "no <op> in progress" and only `reset --merge HEAD` clears it.
20
20
  */
21
21
 
22
- import { afterAll, describe, expect, test } from 'bun:test';
22
+ import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { action } from '@/server/routes/fs/git';
@@ -36,6 +36,16 @@ import { removeDir } from '@/test-support/remove-dir';
36
36
  * action ran against THE Kabmo REPOSITORY.
37
37
  */
38
38
  const FIXTURE_ROOT = path.join(process.cwd(), 'tmp-test-git-merge');
39
+
40
+ /**
41
+ * Every case here builds a real repository and drives several `git` processes
42
+ * through it, so Bun's 5s per-test default is a budget for the RUNNER rather
43
+ * than for the code. Measured on a loaded CI runner: the cherry-pick case alone
44
+ * spent 5.0s and timed out, while the same commit passed the `Checks` job and
45
+ * failed the `Release` one minutes later — the same suite, two machines, one
46
+ * answer each. `git-actions-sync.test.ts` sets the same 30s for the same reason.
47
+ */
48
+ setDefaultTimeout(30_000);
39
49
  const repos: string[] = [];
40
50
 
41
51
  function git(cwd: string, ...args: string[]): string {
@@ -21,6 +21,7 @@ import fs from 'fs';
21
21
  import path from 'path';
22
22
  import { readFile } from '@/server/routes/fs/read';
23
23
  import { EDITOR_MAX_FILE_BYTES, PDF_MAX_FILE_BYTES } from '@/shared/lib/fs/file-kind';
24
+ import { formatBytes } from '@/shared/lib/format/number';
24
25
 
25
26
  const DIR = path.join(process.cwd(), 'tmp-read-gate');
26
27
 
@@ -88,16 +89,23 @@ describe('fs read gate', () => {
88
89
 
89
90
  expect(status).toBe(415);
90
91
  expect(body.kind).toBe('too-large');
91
- expect(body.error).toContain('5.00 MB');
92
+ // Derived from the constant, not spelled out: the sentence and the gate must
93
+ // name the same number, and a literal here would pass while they drifted.
94
+ expect(body.error).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
92
95
  });
93
96
 
94
97
  // A PDF is streamed to Chrome's own viewer through `/api/fs/raw`, so the text
95
- // ceiling does not apply to it — the route must still point at the raw route
96
- // for a PDF well past 5 MB rather than refusing it. Refusing it was the bug:
97
- // the size test ran before the document branch, so a 6 MB PDF was answered
98
- // "larger than the 5.00 MB the editor opens".
99
- test('a PDF past the text ceiling is still served by the raw route', async () => {
100
- write('report.pdf', new Uint8Array(EDITOR_MAX_FILE_BYTES + 1).fill(0x25));
98
+ // ceiling has no bearing on it — the route must point at the raw route rather
99
+ // than refuse it. Refusing it was the bug: the size test ran before the
100
+ // document branch, so a 6 MB PDF was answered "larger than the 5.00 MB the
101
+ // editor opens". 6 MB is the size that was over the old text ceiling and is
102
+ // still a perfectly ordinary PDF.
103
+ //
104
+ // It is written at 6 MB rather than at `EDITOR_MAX_FILE_BYTES + 1` because the
105
+ // two ceilings are independent: the text one is now ABOVE the PDF one, so
106
+ // "past text, under PDF" is no longer a size that exists.
107
+ test('a PDF past the old text ceiling is still served by the raw route', async () => {
108
+ write('report.pdf', new Uint8Array(6 * 1024 * 1024).fill(0x25));
101
109
  const { status, body } = await read('report.pdf');
102
110
 
103
111
  expect(status).toBe(400);
@@ -105,6 +113,15 @@ describe('fs read gate', () => {
105
113
  expect(body.kind).toBeUndefined();
106
114
  });
107
115
 
116
+ test('a PDF past its own ceiling is refused before the document branch', async () => {
117
+ write('enormous.pdf', new Uint8Array(PDF_MAX_FILE_BYTES + 1).fill(0x25));
118
+ const { status, body } = await read('enormous.pdf');
119
+
120
+ expect(status).toBe(415);
121
+ expect(body.kind).toBe('too-large');
122
+ expect(body.error).toContain(formatBytes(PDF_MAX_FILE_BYTES));
123
+ });
124
+
108
125
  test('a PDF past the PDF ceiling is refused for size, naming 30 MB', async () => {
109
126
  write('huge.pdf', new Uint8Array(PDF_MAX_FILE_BYTES + 1).fill(0x25));
110
127
  const { status, body } = await read('huge.pdf');
@@ -20,7 +20,8 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import { testPosix } from '@/test-support/platform';
22
22
  import { resolveFileScope } from '@/server/routes/fs/scope';
23
- import { DOCUMENT_MAX_FILE_BYTES, PDF_MAX_FILE_BYTES } from '@/shared/lib/fs/file-kind';
23
+ import { DOCUMENT_MAX_FILE_BYTES, EDITOR_MAX_FILE_BYTES, PDF_MAX_FILE_BYTES } from '@/shared/lib/fs/file-kind';
24
+ import { formatBytes } from '@/shared/lib/format/number';
24
25
 
25
26
  const APP = process.cwd();
26
27
  const DIR = path.join(APP, 'tmp-scope-probe');
@@ -158,14 +159,18 @@ describe('fs scope route', () => {
158
159
  expect(body.error).toContain('20.00 MB');
159
160
  });
160
161
 
161
- // Plain text keeps the text ceiling: a textarea holds that buffer for the
162
- // life of the tab, which is why its limit did not move with the documents'.
163
- test('a text file past the text ceiling is still refused', async () => {
164
- const target = write('big.log', new Uint8Array(6 * 1024 * 1024).fill(0x61));
165
- const { status, body } = await scope(target);
166
-
167
- expect(status).toBe(200);
168
- expect(body.kind).toBe('too-large');
169
- expect(body.error).toContain('5.00 MB');
162
+ // Plain text keeps the text ceiling, which is its own number: the buffer is
163
+ // held for the life of the tab and re-posted on every save, where a document
164
+ // is converted once. 6 MB used to be over it; it is a normal generated file
165
+ // and must open.
166
+ test('a text file over the text ceiling is refused, and one under it is not', async () => {
167
+ const ok = await scope(write('big.log', new Uint8Array(6 * 1024 * 1024).fill(0x61)));
168
+ expect(ok.status).toBe(200);
169
+ expect(ok.body.kind).toBe('text');
170
+
171
+ const over = await scope(write('huge.log', new Uint8Array(EDITOR_MAX_FILE_BYTES + 1).fill(0x61)));
172
+ expect(over.status).toBe(200);
173
+ expect(over.body.kind).toBe('too-large');
174
+ expect(over.body.error).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
170
175
  });
171
176
  });
@@ -26,25 +26,14 @@
26
26
  import { json, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
27
27
  import { resolveScopeForAbsolutePath } from '@/server/lib/fs/root';
28
28
  import { BINARY_SNIFF_BYTES, classifyEditorFile, unsupportedEditorMessage } from '@/shared/lib/fs/file-kind';
29
+ import { pathFromFileUrl } from '@/shared/lib/fs/file-url';
29
30
  import fs from 'fs';
30
31
  import path from 'path';
31
32
 
32
- /**
33
- * A `file://` URL to the path it names.
34
- *
35
- * The pathname is percent-encoded (a space is `%20`, and a Finder drop of
36
- * `My Notes.md` is exactly that), so decoding is not optional. `decodeURIComponent`
37
- * can throw on a malformed escape, which is answered as "not a path" rather
38
- * than surfacing as a 500.
39
- */
33
+ /** A `file://` URL to the path it names; see `pathFromFileUrl` for the rules. */
40
34
  function pathFromReference(reference: string): string | null {
41
35
  if (!/^file:\/\//i.test(reference)) return reference;
42
- try {
43
- const url = new URL(reference);
44
- return decodeURIComponent(url.pathname);
45
- } catch {
46
- return null;
47
- }
36
+ return pathFromFileUrl(reference);
48
37
  }
49
38
 
50
39
  export async function resolveFileScope({ request }: LoaderFunctionArgs) {
@@ -12,11 +12,16 @@ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell
12
12
  *
13
13
  * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
14
14
  * is to omit a button it cannot back with a link.
15
+ *
16
+ * PUBLIC, unlike its siblings: the Android shell asks this from native code
17
+ * (`AppUpdateCheck`), which has no session cookie, and its "Check for update"
18
+ * button is for a shell too stale to load the console. See `PUBLIC_EXACT` in
19
+ * `lib/auth/guard.ts`.
15
20
  */
16
21
  export async function loader() {
17
22
  const release: ShellRelease | null = await fetchShellRelease('android');
18
23
  return json(
19
- release ?? { url: null, latest: null, releaseUrl: null },
24
+ release ?? { url: null, latest: null, releaseUrl: null, direct: false },
20
25
  { headers: NO_STORE_HEADERS },
21
26
  );
22
27
  }
@@ -1,9 +1,10 @@
1
1
  import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
2
  import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
3
3
  import { isDesktopShellKind, type DesktopShellKind } from '@/shared/lib/updates/shell-ua';
4
+ import { parseShellArch, type ShellArch } from '@/shared/lib/updates/shell-arch';
4
5
 
5
6
  /**
6
- * GET /api/updates/desktop?kind=macos|windows|linux — the newest desktop shell.
7
+ * GET /api/updates/desktop?kind=macos|windows|linux[&arch=arm64|x64]
7
8
  *
8
9
  * ONE route with an explicit `kind`, unlike the mobile pair's two routes, and
9
10
  * the difference is deliberate. `?kind` here is REQUIRED and refused when it is
@@ -11,17 +12,29 @@ import { isDesktopShellKind, type DesktopShellKind } from '@/shared/lib/updates/
11
12
  * the mobile pair's split exists precisely because a defaulted parameter would
12
13
  * have made "the newest shell" mean Android without anyone saying so.
13
14
  *
15
+ * ## `arch` is what makes the offer installable
16
+ *
14
17
  * Only an INSTALLED desktop shell asks for this — the page reports its own
15
18
  * version over the shell bridge (`__kabmoDesktop.shellVersion`), because a
16
- * desktop app carries no User-Agent token. The `url` it answers with is the
17
- * RELEASE PAGE rather than a file: every desktop artifact is per-architecture,
18
- * and the server cannot know which architecture the window is running on.
19
+ * desktop app carries no User-Agent token. Without `arch` the answer is the
20
+ * RELEASE PAGE: every desktop artifact is per-architecture, and a console served
21
+ * to another machine cannot guess which one this window runs on.
22
+ *
23
+ * WITH `arch` the answer is that machine's own installer, and `direct: true`
24
+ * says so — which is what lets the sidebar offer an in-app update instead of a
25
+ * link. The shell still verifies the bytes before installing them; this route
26
+ * only decides WHICH file, never whether it is trusted.
27
+ *
28
+ * An `arch` that names nothing this product publishes is a REFUSAL rather than a
29
+ * silent fallback: a caller that asked for a build we do not make should be told,
30
+ * not handed the other architecture's installer.
19
31
  *
20
32
  * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
21
33
  * is to omit a button it cannot back with a link.
22
34
  */
23
35
  export async function loader({ request }: { request: Request }) {
24
- const kind = new URL(request.url).searchParams.get('kind');
36
+ const params = new URL(request.url).searchParams;
37
+ const kind = params.get('kind');
25
38
  if (!isDesktopShellKind(kind ?? '')) {
26
39
  return json(
27
40
  { error: 'kind must be one of macos, windows, linux' },
@@ -29,9 +42,18 @@ export async function loader({ request }: { request: Request }) {
29
42
  );
30
43
  }
31
44
 
32
- const release: ShellRelease | null = await fetchShellRelease(kind as DesktopShellKind);
45
+ const requested = params.get('arch');
46
+ const arch: ShellArch | null = requested ? parseShellArch(requested) : null;
47
+ if (requested && !arch) {
48
+ return json(
49
+ { error: 'arch must be one of arm64, x64' },
50
+ { status: 400, headers: NO_STORE_HEADERS },
51
+ );
52
+ }
53
+
54
+ const release: ShellRelease | null = await fetchShellRelease(kind as DesktopShellKind, fetch, arch);
33
55
  return json(
34
- release ?? { url: null, latest: null, releaseUrl: null },
56
+ release ?? { url: null, latest: null, releaseUrl: null, direct: false },
35
57
  { headers: NO_STORE_HEADERS },
36
58
  );
37
59
  }
@@ -16,11 +16,15 @@ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell
16
16
  *
17
17
  * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
18
18
  * is to omit a button it cannot back with a link.
19
+ *
20
+ * PUBLIC, for the same reason as `/api/updates/android`: the installed shell
21
+ * asks it from native code with no session cookie, and the check has to work
22
+ * before the console can load.
19
23
  */
20
24
  export async function loader() {
21
25
  const release: ShellRelease | null = await fetchShellRelease('ios');
22
26
  return json(
23
- release ?? { url: null, latest: null, releaseUrl: null },
27
+ release ?? { url: null, latest: null, releaseUrl: null, direct: false },
24
28
  { headers: NO_STORE_HEADERS },
25
29
  );
26
30
  }
@@ -26,7 +26,7 @@ import { scanSessionInfoCached } from '@/server/lib/omp/session/files';
26
26
  import { canonicalPath } from '@/server/lib/fs/canonical';
27
27
  import { worktreeIdentityFor, listWorktreeRows } from '@/server/lib/db/worktree-registry.server';
28
28
  import { dirtyCount, commitsAheadOf } from '@/server/lib/fs/worktree/remove';
29
- import { listWorktrees } from '@/server/lib/fs/worktree/list';
29
+ import { listWorktrees, isWorkTree } from '@/server/lib/fs/worktree/list';
30
30
  import {
31
31
  defaultWorktreeRoot,
32
32
  resolveConfiguredWorktreeRoot,
@@ -49,6 +49,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
49
49
  try {
50
50
  const db = await getDb();
51
51
  const primaryRoot = await resolveProjectRoot(root);
52
+ // "This folder is not a Git repository" is an ANSWER, not a failure. The
53
+ // payload has carried `isRepo` for exactly this, and a plain directory (a
54
+ // notes folder bound as a workspace) must read as "no worktrees here" —
55
+ // a 500 would make the composer report a broken feature for a folder that
56
+ // simply has nothing to offer. The probe runs before the identity read,
57
+ // which is what throws for a directory with no root commit.
58
+ if (!(await isWorkTree(primaryRoot))) {
59
+ const empty: WorktreeListPayload = { worktrees: [], base: '', isRepo: false };
60
+ return json(empty);
61
+ }
52
62
  const settings = await readWorktreeSettings();
53
63
  const projectId = await resolveProjectId(primaryRoot, (candidate) => worktreeIdentityFor(db, candidate));
54
64
  const base = resolveConfiguredWorktreeRoot(settings.directory, primaryRoot)
@@ -163,6 +163,23 @@ describe('GET /api/worktrees', () => {
163
163
  expect(primary.dirty).toBe(0);
164
164
  });
165
165
 
166
+ test('a folder that is not a repository answers isRepo:false, not a failure', async () => {
167
+ // A notes folder bound as a workspace: it can never hold a worktree, and
168
+ // the composer must read that as "no control here" rather than as a broken
169
+ // feature. The route used to 500 here, because the identity read throws for
170
+ // a directory with no root commit.
171
+ const plain = join(workRoot, 'plain-notes');
172
+ mkdirSync(plain, { recursive: true });
173
+ writeFileSync(join(plain, 'todo.md'), '- buy milk\n');
174
+ const db = await getDb();
175
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['plain', plain]);
176
+
177
+ const { status, body } = await call(listLoader, `http://localhost/api/worktrees?root=${encodeURIComponent(plain)}`);
178
+ expect(status).toBe(200);
179
+ expect(body.isRepo).toBe(false);
180
+ expect(worktreesOf(body)).toEqual([]);
181
+ });
182
+
166
183
  test('counts a session running in a worktree that lives OUTSIDE the repository', async () => {
167
184
  const created = await call(createAction, base(), {
168
185
  method: 'POST',