@kabmo-labs/kabmo 0.7.0 → 0.8.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 (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -12,14 +12,22 @@
12
12
  * on the old copy. Nothing else would ever notice: the install is a directory of
13
13
  * ordinary files, and the scan has no notion of a store version.
14
14
  *
15
- * The sync runs at boot, after the listener is up, and it is VERSION-GATED — the
16
- * store copy is written over the install only when its manifest version is
17
- * strictly newer. That is what makes the pass safe to run unconditionally: a
18
- * user's plugin that happens to share an id with a store entry but was installed
19
- * from a git URL carries no `.git` ONLY if it was copied by hand, and a
20
- * hand-copied directory keeps working because its version is compared rather
21
- * than its provenance. A plugin installed from a git URL is skipped outright,
22
- * because the store's copy is not its source of truth.
15
+ * The sync runs at boot, after the listener is up. For an install KABMO WROTE —
16
+ * one carrying a baseline — the store's TREE is the gate, not its version: the
17
+ * copy is written whenever the two tracked trees differ, in either direction.
18
+ * A version gate cannot be the whole answer, and believing it was is what left
19
+ * every bundled plugin un-updatable after the product rename: that release
20
+ * rewrote the store's files and reset every bundled version to `0.0.1`, so the
21
+ * store was never "newer" than the install and the pass skipped forever —
22
+ * measured on a real install, where the only way to get the new code was to
23
+ * remove the plugin and install it again. Content is also the honest test for a
24
+ * shipped fix that forgot its bump.
25
+ *
26
+ * A directory with NO baseline is of unknown provenance (hand-copied, or
27
+ * installed by a build that predates the baseline), so it keeps the conservative
28
+ * version gate: a copy the user made themselves must not be replaced by a store
29
+ * entry that merely shares its id. A git clone is skipped outright, because the
30
+ * store's copy is not its source of truth.
23
31
  *
24
32
  * The install's `node_modules` and `dist` are left alone until the copy lands:
25
33
  * the sync copies the store's SOURCES, then rebuilds, exactly as an install
@@ -36,14 +44,21 @@ import { readPluginManifest, subdirectories } from '@/server/lib/panels/files.se
36
44
  import { toManifest } from '@/server/lib/panels/manifest';
37
45
  import { buildPanelPlugin } from '@/server/lib/panels/build.server';
38
46
  import { readGitOrigin } from '@/server/lib/panels/git-clone.server';
39
- import { changedPaths, hashTrackedFiles } from '@/server/lib/panels/local-edits.server';
40
- import { readBaseline, clearBaseline, writeBaseline, writeBlockedSync } from '@/server/lib/panels/baseline.server';
47
+ import { baselineDescribesInstall, changedPaths, hashTrackedFiles, type TrackedFileHashes } from '@/server/lib/panels/local-edits.server';
48
+ import { readBaseline, writeBaseline, writeBlockedSync } from '@/server/lib/panels/baseline.server';
41
49
  import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
42
50
 
43
51
  export interface BundledSyncOutcome {
44
52
  pluginId: string;
45
53
  from: string;
46
54
  to: string;
55
+ /**
56
+ * True when the store's files changed but its manifest version did not.
57
+ *
58
+ * The common case after a shipped fix whose bump was missed, and the reason
59
+ * the gate is content: reporting it as `1.0.0 → 1.0.0` reads as a no-op.
60
+ */
61
+ contentOnly?: boolean;
47
62
  /** A rebuild that failed after the files were replaced. */
48
63
  warning?: string;
49
64
  }
@@ -81,9 +96,13 @@ async function manifestVersion(dir: string): Promise<string | null> {
81
96
  * that says otherwise. When the store has no `dist`, nothing is removed and the
82
97
  * rebuild regenerates it from the new sources.
83
98
  */
84
- async function replaceFromStore(source: string, destination: string): Promise<void> {
99
+ async function replaceFromStore(
100
+ source: string,
101
+ destination: string,
102
+ knownInstallFiles?: TrackedFileHashes,
103
+ ): Promise<void> {
85
104
  const storeFiles = await hashTrackedFiles(source);
86
- const installFiles = await hashTrackedFiles(destination);
105
+ const installFiles = knownInstallFiles ?? (await hashTrackedFiles(destination));
87
106
  for (const path of Object.keys(installFiles)) {
88
107
  if (path in storeFiles) continue;
89
108
  await rm(join(destination, path), { force: true });
@@ -100,7 +119,49 @@ async function replaceFromStore(source: string, destination: string): Promise<vo
100
119
  }
101
120
 
102
121
  /**
103
- * Bring every installed bundled plugin up to the store's version.
122
+ * Write the store's copy over an install and rebuild it.
123
+ *
124
+ * Shared by the boot pass and the override, because everything after the gate is
125
+ * identical and the parts that matter are the ones a second copy gets wrong: the
126
+ * replacement is a MIRROR, the manifest is re-read from what landed, and the
127
+ * baseline is re-taken AFTER the build so the next pass compares against the
128
+ * tree this one produced.
129
+ */
130
+ async function applyStoreCopy(
131
+ pluginId: string,
132
+ storeDir: string,
133
+ destination: string,
134
+ from: string,
135
+ to: string,
136
+ installFiles: TrackedFileHashes,
137
+ ): Promise<{ outcome: BundledSyncOutcome } | { error: string }> {
138
+ await replaceFromStore(storeDir, destination, installFiles);
139
+
140
+ const read = await readPluginManifest(destination);
141
+ const manifest = read.kind === 'manifest' ? toManifest(read.value, destination) : null;
142
+ if (!manifest || 'error' in manifest) return { error: 'the store copy has no valid manifest' };
143
+
144
+ const build = await buildPanelPlugin(destination, manifest);
145
+ invalidatePanelScan();
146
+ // The new files are the baseline now — taken AFTER the build so the tree the
147
+ // next pass compares against is the one this pass produced.
148
+ await writeBaseline(pluginId, await hashTrackedFiles(destination));
149
+ await writeBlockedSync(pluginId, null);
150
+ return {
151
+ outcome: {
152
+ pluginId,
153
+ from,
154
+ to,
155
+ // The store changed but its manifest version did not. Naming it keeps a
156
+ // `1.0.0 → 1.0.0` line from reading as a no-op when a real fix landed.
157
+ ...(from === to ? { contentOnly: true } : {}),
158
+ ...(build.status === 'failed' ? { warning: build.reason ?? 'the plugin did not build' } : {}),
159
+ },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Bring every installed bundled plugin up to the store's copy.
104
165
  *
105
166
  * Never throws: one plugin that cannot be replaced must not stop the others, and
106
167
  * a failure is reported per plugin. Returns what actually moved.
@@ -132,9 +193,10 @@ export async function syncBundledPlugins(): Promise<{
132
193
  try {
133
194
  const storeVersion = await manifestVersion(join(storeRoot, pluginId));
134
195
  const installedVersion = await manifestVersion(destination);
135
- if (!storeVersion || !installedVersion || !isNewer(storeVersion, installedVersion)) {
136
- // Nothing to apply, so any earlier refusal no longer describes anything
137
- // the user has to act on.
196
+ // A manifest that cannot be read on either side means there is no
197
+ // comparison to make at all — the scan reports it, and the sync leaves the
198
+ // directory alone rather than guessing which side is wrong.
199
+ if (!storeVersion || !installedVersion) {
138
200
  await writeBlockedSync(pluginId, null);
139
201
  continue;
140
202
  }
@@ -143,9 +205,48 @@ export async function syncBundledPlugins(): Promise<{
143
205
  // differs from it is the user's. A plugin with no baseline predates this
144
206
  // check (or was installed by an older build), and running is the only
145
207
  // useful answer: refusing would freeze it forever with no way to say why.
146
- const baseline = await readBaseline(pluginId);
147
- if (baseline) {
148
- const changed = changedPaths(baseline, await hashTrackedFiles(destination));
208
+ //
209
+ // PROVENANCE and EVIDENCE are separate answers. A recorded baseline is what
210
+ // proves Kabmo wrote this directory, so the content gate below applies
211
+ // either way; only a baseline that still DESCRIBES the install may say a
212
+ // file is the user's — see `baselineDescribesInstall`. Obeying a stale one
213
+ // is what made the update unrecoverable: the rename rewrote every bundled
214
+ // file while an older baseline was recorded, so every path read as the
215
+ // user's edit and the only way out was removing the plugin.
216
+ const recorded = await readBaseline(pluginId);
217
+ const installFiles = await hashTrackedFiles(destination);
218
+ const storeFiles = await hashTrackedFiles(join(storeRoot, pluginId));
219
+ const trusted = recorded !== null && baselineDescribesInstall(recorded, installFiles, storeFiles);
220
+
221
+ // WHICH GATE APPLIES IS DECIDED BY PROVENANCE, and the two answer
222
+ // different questions:
223
+ //
224
+ // - A directory Kabmo wrote is compared by CONTENT. The store's tree is
225
+ // the source of truth for it, so any difference is an update — including
226
+ // a fix shipped without a version bump, and including the case that made
227
+ // this a bug: the product rename rewrote every bundled file AND reset the
228
+ // versions to 0.0.1, so a version gate could never see the store as
229
+ // newer and the plugin was stuck until it was removed and reinstalled
230
+ // (measured on a real install).
231
+ // - A directory with no recorded baseline is of unknown provenance, so the
232
+ // conservative version gate stays: a hand-copied directory that happens
233
+ // to share a store id must not be overwritten by a store entry that is
234
+ // merely newer than it.
235
+ const contentDiffers = changedPaths(installFiles, storeFiles).length > 0;
236
+ const versionIsNewer = isNewer(storeVersion, installedVersion);
237
+ const shouldApply = recorded ? contentDiffers : versionIsNewer;
238
+ if (!shouldApply) {
239
+ // Nothing to apply, so any earlier refusal no longer describes anything
240
+ // the user has to act on.
241
+ await writeBlockedSync(pluginId, null);
242
+ // A stale baseline is replaced by what is on disk, so the next release's
243
+ // update compares against reality rather than against history.
244
+ if (recorded && !trusted) await writeBaseline(pluginId, installFiles);
245
+ continue;
246
+ }
247
+
248
+ if (trusted) {
249
+ const changed = changedPaths(recorded, installFiles);
149
250
  if (changed.length > 0) {
150
251
  blocked.push({ pluginId, from: installedVersion, to: storeVersion, changed });
151
252
  await writeBlockedSync(pluginId, { at: Date.now(), changed });
@@ -153,26 +254,19 @@ export async function syncBundledPlugins(): Promise<{
153
254
  }
154
255
  }
155
256
 
156
- await replaceFromStore(join(storeRoot, pluginId), destination);
157
-
158
- const read = await readPluginManifest(destination);
159
- const manifest = read.kind === 'manifest' ? toManifest(read.value, destination) : null;
160
- if (!manifest || 'error' in manifest) {
161
- failed.push({ pluginId, error: 'the store copy has no valid manifest' });
257
+ const applied = await applyStoreCopy(
258
+ pluginId,
259
+ join(storeRoot, pluginId),
260
+ destination,
261
+ installedVersion,
262
+ storeVersion,
263
+ installFiles,
264
+ );
265
+ if ('error' in applied) {
266
+ failed.push({ pluginId, error: applied.error });
162
267
  continue;
163
268
  }
164
- const build = await buildPanelPlugin(destination, manifest);
165
- invalidatePanelScan();
166
- // The new files are the baseline now — taken AFTER the build so the tree
167
- // the next pass compares against is the one this pass produced.
168
- await writeBaseline(pluginId, await hashTrackedFiles(destination));
169
- await writeBlockedSync(pluginId, null);
170
- updated.push({
171
- pluginId,
172
- from: installedVersion,
173
- to: storeVersion,
174
- ...(build.status === 'failed' ? { warning: build.reason ?? 'the plugin did not build' } : {}),
175
- });
269
+ updated.push(applied.outcome);
176
270
  } catch (error) {
177
271
  failed.push({ pluginId, error: error instanceof Error ? error.message : String(error) });
178
272
  }
@@ -187,9 +281,11 @@ export async function syncBundledPlugins(): Promise<{
187
281
  *
188
282
  * The escape hatch for a refusal: the user has seen which files differ and asked
189
283
  * for the store's copy anyway, which is the only way past a baseline Kabmo
190
- * cannot reconcile. The baseline is dropped first, so the pass that follows takes
191
- * the store's files and re-records what it wrote — the same end state a clean
192
- * install reaches.
284
+ * cannot reconcile. It applies the copy DIRECTLY rather than clearing the
285
+ * baseline and re-running the pass: a cleared baseline makes the plugin read as
286
+ * "unknown provenance", which is exactly the state the conservative version gate
287
+ * governs — so the override silently became a no-op whenever the store's version
288
+ * was not newer, which is the case it most often exists for.
193
289
  *
194
290
  * Deliberately addressed by id and refusing anything that is not a bundled
195
291
  * install: this must never be a way to overwrite a git clone.
@@ -201,12 +297,21 @@ export async function forceSyncBundledPlugin(pluginId: string): Promise<{ ok: bo
201
297
  if (!(await pathExists(destination))) return { ok: false, error: `${pluginId} is not installed.` };
202
298
  if (await readGitOrigin(destination)) return { ok: false, error: `${pluginId} is a git clone; its own Update is the way to change it.` };
203
299
 
204
- await clearBaseline(pluginId);
205
- await writeBlockedSync(pluginId, null);
206
- const result = await syncBundledPlugins();
207
- const failed = result.failed.find((entry) => entry.pluginId === pluginId);
208
- if (failed) return { ok: false, error: failed.error };
209
- const outcome = result.updated.find((entry) => entry.pluginId === pluginId);
210
- if (!outcome) return { ok: false, error: `${pluginId} is already at the store's version.` };
211
- return { ok: true, ...(outcome.warning ? { warning: outcome.warning } : {}) };
300
+ const from = (await manifestVersion(destination)) ?? 'unknown';
301
+ const to = (await manifestVersion(storeDir)) ?? 'unknown';
302
+ try {
303
+ const applied = await applyStoreCopy(
304
+ pluginId,
305
+ storeDir,
306
+ destination,
307
+ from,
308
+ to,
309
+ await hashTrackedFiles(destination),
310
+ );
311
+ if ('error' in applied) return { ok: false, error: applied.error };
312
+ emitRealtimeSignal('panels-changed');
313
+ return { ok: true, ...(applied.outcome.warning ? { warning: applied.outcome.warning } : {}) };
314
+ } catch (error) {
315
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
316
+ }
212
317
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { versionFromAssetName } from '@/server/lib/updates/shell-assets';
9
+
10
+ /**
11
+ * The asset name is the only place a shell artifact's version exists: the
12
+ * release TAG names the SERVER version, and the two move independently. A parser
13
+ * that read the tag would compare a server version against a shell version.
14
+ *
15
+ * Both shells are pinned here, because they share the parser and differ only by
16
+ * prefix/extension — the shape that silently accepts the other shell's asset.
17
+ */
18
+ describe('versionFromAssetName', () => {
19
+ test('reads the version the release workflows write', () => {
20
+ expect(versionFromAssetName('android', 'Kabmo-0.5.0-android.apk')).toBe('0.5.0');
21
+ expect(versionFromAssetName('ios', 'Kabmo-0.1.0-ios.ipa')).toBe('0.1.0');
22
+ });
23
+
24
+ /**
25
+ * The old spelling is still READ, and that is not sentiment.
26
+ *
27
+ * These names are the only record of a published shell, and every release
28
+ * already on GitHub carries `kabmo-android-v<v>.apk`. A parser that only knew
29
+ * the new scheme would answer "no update" for an installed shell the moment
30
+ * this shipped — silently, because a name that does not match reads exactly
31
+ * like a release with no artifact.
32
+ */
33
+ test('still reads the legacy spelling every published release carries', () => {
34
+ expect(versionFromAssetName('android', 'kabmo-android-v0.5.0.apk')).toBe('0.5.0');
35
+ expect(versionFromAssetName('ios', 'kabmo-ios-v0.1.0.ipa')).toBe('0.1.0');
36
+ });
37
+
38
+ test('ignores anything that is not that shell’s artifact', () => {
39
+ expect(versionFromAssetName('android', 'Kabmo-1.2.3-android.apk.zip')).toBeNull();
40
+ expect(versionFromAssetName('android', 'source.tar.gz')).toBeNull();
41
+ expect(versionFromAssetName('android', 'Kabmo-latest-android.apk')).toBeNull();
42
+ expect(versionFromAssetName('android', 'Kabmo--android.apk')).toBeNull();
43
+ expect(versionFromAssetName('android', '')).toBeNull();
44
+ });
45
+
46
+ /**
47
+ * The desktop assets share the `Kabmo-` prefix, so the EXTENSION is what keeps
48
+ * them out. A probe that matched on the prefix alone would offer a Mac `.dmg`
49
+ * to an Android phone.
50
+ */
51
+ test('does not read a desktop asset as a mobile one', () => {
52
+ for (const name of [
53
+ 'Kabmo-0.1.2-mac-arm64.zip',
54
+ 'Kabmo-0.1.2-mac-arm64.dmg',
55
+ 'Kabmo-0.1.0-win-x64-setup.exe',
56
+ 'Kabmo-0.1.0-linux-x86_64.tar.gz',
57
+ 'Kabmo-0.1.0-linux-amd64.deb',
58
+ 'Kabmo-0.1.0-linux-x86_64.AppImage',
59
+ ]) {
60
+ expect(versionFromAssetName('android', name), name).toBeNull();
61
+ expect(versionFromAssetName('ios', name), name).toBeNull();
62
+ }
63
+ });
64
+
65
+ test('does not accept one shell’s asset for the other', () => {
66
+ // The probes read the SAME release list, so a parser that ignored the
67
+ // kind would let an iOS probe answer with the APK (and offer an `.apk` to an
68
+ // iPhone).
69
+ expect(versionFromAssetName('ios', 'Kabmo-0.5.0-android.apk')).toBeNull();
70
+ expect(versionFromAssetName('android', 'Kabmo-0.1.0-ios.ipa')).toBeNull();
71
+ expect(versionFromAssetName('ios', 'kabmo-android-v1.2.3.apk')).toBeNull();
72
+ expect(versionFromAssetName('android', 'kabmo-ios-v1.2.3.ipa')).toBeNull();
73
+ });
74
+
75
+ /**
76
+ * The desktop trio, one entry each.
77
+ *
78
+ * A desktop release carries SEVERAL files per platform (two arches × two
79
+ * macOS shapes, four Linux formats), so the match is on the segment after the
80
+ * version rather than the whole name: any one of them answers the same
81
+ * version, and the download the console offers is the release page.
82
+ */
83
+ test('reads every desktop spelling, and only its own platform', () => {
84
+ for (const name of ['Kabmo-0.2.0-mac-arm64.zip', 'Kabmo-0.2.0-mac-x64.dmg']) {
85
+ expect(versionFromAssetName('macos', name), name).toBe('0.2.0');
86
+ expect(versionFromAssetName('windows', name), name).toBeNull();
87
+ expect(versionFromAssetName('linux', name), name).toBeNull();
88
+ }
89
+ for (const name of ['Kabmo-0.2.0-win-x64-setup.exe', 'Kabmo-0.2.0-win-arm64-setup.exe']) {
90
+ expect(versionFromAssetName('windows', name), name).toBe('0.2.0');
91
+ expect(versionFromAssetName('macos', name), name).toBeNull();
92
+ }
93
+ for (const name of [
94
+ 'Kabmo-0.2.0-linux-x86_64.tar.gz',
95
+ 'Kabmo-0.2.0-linux-arm64.tar.gz',
96
+ 'Kabmo-0.2.0-linux-amd64.deb',
97
+ 'Kabmo-0.2.0-linux-arm64.rpm',
98
+ 'Kabmo-0.2.0-linux-x86_64.AppImage',
99
+ ]) {
100
+ expect(versionFromAssetName('linux', name), name).toBe('0.2.0');
101
+ expect(versionFromAssetName('macos', name), name).toBeNull();
102
+ }
103
+ });
104
+
105
+ test('a desktop shell reads no mobile asset, and vice versa', () => {
106
+ // The `Kabmo-` prefix is shared by all five, so a scheme that matched on the
107
+ // prefix alone would offer an APK to a Mac.
108
+ expect(versionFromAssetName('macos', 'Kabmo-0.6.0-android.apk')).toBeNull();
109
+ expect(versionFromAssetName('windows', 'Kabmo-0.2.0-ios.ipa')).toBeNull();
110
+ expect(versionFromAssetName('linux', 'Kabmo-0.6.0-android.apk')).toBeNull();
111
+ expect(versionFromAssetName('android', 'Kabmo-0.2.0-linux-x86_64.tar.gz')).toBeNull();
112
+ expect(versionFromAssetName('ios', 'Kabmo-0.2.0-win-x64-setup.exe')).toBeNull();
113
+ });
114
+ });
@@ -0,0 +1,109 @@
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 { ShellKind } from '@/shared/lib/updates/shell-ua';
16
+
17
+ /**
18
+ * One shell's asset scheme, plus the spelling it carried BEFORE the rename.
19
+ *
20
+ * ONE scheme for every shell in the project — `<App>-<version>-<platform>…`:
21
+ * `Kabmo-0.5.0-android.apk`, `Kabmo-0.1.0-ios.ipa`, and the desktop trio's
22
+ * `Kabmo-0.2.0-mac-arm64.dmg` / `-win-x64-setup.exe` / `-linux-x86_64.tar.gz`.
23
+ * The mobile pair carries no architecture because neither artifact is per-arch:
24
+ * the APK is one universal build (the shell is pure Kotlin with no native
25
+ * libraries, so there is nothing to split), and the IPA contains only the device
26
+ * slice (`arm64-apple-ios`).
27
+ *
28
+ * The desktop entries match on the segment AFTER the version rather than on the
29
+ * whole name, because one release carries several files per platform
30
+ * (`-mac-arm64.zip` and `-mac-arm64.dmg`, four Linux formats, two arches). Any
31
+ * one of them answers the same version, and the download is the release page.
32
+ *
33
+ * **`legacy` is not dead code.** These names are PARSED to answer "is a newer
34
+ * shell published", and every release already on GitHub carries the old mobile
35
+ * spelling. Dropping it would make the console answer "no update" for an
36
+ * installed shell the moment this ships — silently, because a name that does not
37
+ * match reads exactly like a release with no artifact. The desktop trio has no
38
+ * legacy spelling: they were born under the current scheme.
39
+ */
40
+ export interface AssetScheme {
41
+ /** The whole-name pattern for the mobile pair, the segment for the desktops. */
42
+ pattern: RegExp;
43
+ /** The prefix that marks the CURRENT scheme, for "prefer the new spelling". */
44
+ current: RegExp;
45
+ /** The old mobile spelling, or null for a shell that never had one. */
46
+ legacy: { prefix: string; extension: string } | null;
47
+ /**
48
+ * Whether the payload's `url` should be the artifact itself. False for a
49
+ * per-arch desktop build, where the release page is the only honest link.
50
+ */
51
+ directDownload: boolean;
52
+ }
53
+
54
+ const VERSION = String.raw`(\d+\.\d+\.\d+)`;
55
+
56
+ export const SHELL_ASSETS: Record<ShellKind, AssetScheme> = {
57
+ android: {
58
+ pattern: new RegExp(`^Kabmo-${VERSION}-android\\.apk$`),
59
+ current: /^Kabmo-\d/,
60
+ legacy: { prefix: 'kabmo-android-v', extension: '.apk' },
61
+ directDownload: true,
62
+ },
63
+ ios: {
64
+ pattern: new RegExp(`^Kabmo-${VERSION}-ios\\.ipa$`),
65
+ current: /^Kabmo-\d/,
66
+ legacy: { prefix: 'kabmo-ios-v', extension: '.ipa' },
67
+ directDownload: true,
68
+ },
69
+ macos: {
70
+ pattern: new RegExp(`^Kabmo-${VERSION}-mac-`),
71
+ current: /^Kabmo-\d/,
72
+ legacy: null,
73
+ directDownload: false,
74
+ },
75
+ windows: {
76
+ pattern: new RegExp(`^Kabmo-${VERSION}-win-`),
77
+ current: /^Kabmo-\d/,
78
+ legacy: null,
79
+ directDownload: false,
80
+ },
81
+ linux: {
82
+ pattern: new RegExp(`^Kabmo-${VERSION}-linux-`),
83
+ current: /^Kabmo-\d/,
84
+ legacy: null,
85
+ directDownload: false,
86
+ },
87
+ };
88
+
89
+ /**
90
+ * `Kabmo-0.5.0-android.apk` → `0.5.0`, the `.ipa` twin, every desktop spelling,
91
+ * plus the legacy `kabmo-android-v0.5.0.apk` the older releases still carry.
92
+ */
93
+ export function versionFromAssetName(kind: ShellKind, name: string): string | null {
94
+ const scheme = SHELL_ASSETS[kind];
95
+
96
+ const current = scheme.pattern.exec(name);
97
+ if (current) return current[1] ?? null;
98
+
99
+ // Legacy: `kabmo-android-v<version>.apk`. Kept for the releases already
100
+ // published; no new release writes it.
101
+ const legacy = scheme.legacy;
102
+ if (legacy && name.startsWith(legacy.prefix) && name.endsWith(legacy.extension)) {
103
+ const middle = name.slice(legacy.prefix.length, -legacy.extension.length);
104
+ const version = middle.startsWith('v') ? middle.slice(1) : middle;
105
+ return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
106
+ }
107
+
108
+ return null;
109
+ }
@@ -0,0 +1,156 @@
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
+ });
93
+ expect(calls.some((url) => url.includes('/repos/'))).toBe(true);
94
+ expect(calls.some((url) => url.endsWith('/releases.atom'))).toBe(true);
95
+ });
96
+
97
+ test('walks past a release that carries no artifact of this shell', async () => {
98
+ // The packages are released FIRST, so the newest tag is often a
99
+ // `kabmo-sdk/v*` release with no shell in it — the same shape that made
100
+ // `/releases/latest` wrong.
101
+ const { impl } = routedFetch({
102
+ atom: ATOM,
103
+ assets: { 'kabmo-sdk/v0.1.0': '<div>no assets</div>', 'v0.7.0': ASSETS_V070 },
104
+ });
105
+
106
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('0.6.0');
107
+ });
108
+
109
+ test('answers the release page for a desktop shell', async () => {
110
+ const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
111
+
112
+ expect(await fetchShellRelease('macos', impl)).toEqual({
113
+ url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
114
+ latest: '0.2.0',
115
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
116
+ });
117
+ });
118
+
119
+ test('a release with no artifact anywhere answers empty, not null', async () => {
120
+ // "The read worked and there is nothing" and "the read failed" must stay
121
+ // distinguishable: the first is cacheable and the second keeps the last good
122
+ // answer.
123
+ const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': '<div></div>' } });
124
+ expect(await fetchShellRelease('android', impl)).toEqual({ url: null, latest: null, releaseUrl: null });
125
+ });
126
+
127
+ test('an unreachable feed answers null rather than an empty result', async () => {
128
+ const { impl } = routedFetch({});
129
+ expect(await fetchShellRelease('android', impl)).toBeNull();
130
+ });
131
+
132
+ test('the API answer wins when it works, so the fallback costs nothing', async () => {
133
+ const { impl, calls } = routedFetch({
134
+ api: [
135
+ {
136
+ html_url: 'https://x/api',
137
+ assets: [{ name: 'Kabmo-1.0.0-android.apk', browser_download_url: 'https://x/a.apk' }],
138
+ },
139
+ ],
140
+ atom: ATOM,
141
+ assets: { 'v0.7.0': ASSETS_V070 },
142
+ });
143
+
144
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('1.0.0');
145
+ expect(calls.some((url) => url.endsWith('/releases.atom'))).toBe(false);
146
+ });
147
+
148
+ test('releaseTags reads the feed in order, with each page', () => {
149
+ expect(releaseTags(ATOM)).toEqual([
150
+ { name: 'v0.7.0', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0' },
151
+ { name: 'kabmo-sdk/v0.1.0', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/kabmo-sdk/v0.1.0' },
152
+ { name: 'v0.6.1', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.6.1' },
153
+ ]);
154
+ expect(releaseTags('')).toEqual([]);
155
+ });
156
+ });