@kabmo-labs/kabmo 0.6.1 → 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-aq0e78gv.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 +74 -1
  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 +126 -50
  101. package/src/server/lib/updates/shell-release.ts +139 -88
  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
@@ -0,0 +1,145 @@
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, versionFromAssetName } from '@/server/lib/updates/shell-assets';
25
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
26
+
27
+ /** How many tags to walk before giving up. */
28
+ const PAGE_SCAN = 6;
29
+
30
+ /** A tag from the Atom feed, with the release page it names. */
31
+ export interface ReleaseTag {
32
+ name: string;
33
+ url: string | null;
34
+ }
35
+
36
+ /** One asset found in a release's fragment. */
37
+ export interface PageAsset {
38
+ name: string;
39
+ downloadUrl: string;
40
+ }
41
+
42
+ /**
43
+ * What the pages read concluded.
44
+ *
45
+ * THREE cases, not two, because "the feed could not be read" and "the feed was
46
+ * read and carries no artifact of this shell" are different facts: the first
47
+ * keeps the last good answer, the second is a cacheable empty result. Collapsing
48
+ * them makes a momentary outage look like "no update published", which is the
49
+ * same silence the API path's `null` exists to avoid.
50
+ */
51
+ export type PagesResult =
52
+ | { kind: 'found'; asset: PageAsset; releaseUrl: string | null }
53
+ | { kind: 'none' }
54
+ | { kind: 'unreachable' };
55
+
56
+ /**
57
+ * The newest tag that carries this shell's artifact.
58
+ *
59
+ * The FIRST tag with a matching asset wins, because the feed is newest-first and
60
+ * a release that carries no artifact of this shell (a `kabmo-sdk/v*` package
61
+ * release, which the pipeline publishes before the app) must not stop the walk.
62
+ */
63
+ export async function findAssetInPages(
64
+ kind: ShellKind,
65
+ fetchImpl: typeof fetch,
66
+ headers: Record<string, string>,
67
+ ): Promise<PagesResult> {
68
+ const feed = await fetchImpl(`https://github.com/${REPO}/releases.atom`, {
69
+ headers: { accept: 'application/atom+xml, text/xml', ...headers },
70
+ });
71
+ if (!feed.ok) return { kind: 'unreachable' };
72
+
73
+ const tags = releaseTags(await feed.text());
74
+ if (tags.length === 0) return { kind: 'none' };
75
+
76
+ for (const tag of tags.slice(0, PAGE_SCAN)) {
77
+ const page = await fetchImpl(
78
+ `https://github.com/${REPO}/releases/expanded_assets/${encodeURIComponent(tag.name)}`,
79
+ { headers: { accept: 'text/html', ...headers } },
80
+ );
81
+ if (!page.ok) continue;
82
+
83
+ const asset = shellAssetFromPage(kind, await page.text());
84
+ if (!asset) continue;
85
+
86
+ return { kind: 'found', asset, releaseUrl: tag.url };
87
+ }
88
+
89
+ return { kind: 'none' };
90
+ }
91
+
92
+ /**
93
+ * The feed's entries, in the order GitHub lists them (newest first).
94
+ *
95
+ * Parsed with two regexes rather than an XML parser: the feed is a fixed shape
96
+ * GitHub emits, and a dependency for two fields would be the larger risk. The
97
+ * entity decode covers the one escape that appears in practice.
98
+ */
99
+ export function releaseTags(xml: string): ReleaseTag[] {
100
+ const tags: ReleaseTag[] = [];
101
+ const entries = xml.split('<entry>').slice(1);
102
+
103
+ for (const entry of entries) {
104
+ const title = /<title>([^<]*)<\/title>/.exec(entry)?.[1];
105
+ if (!title) continue;
106
+ const href = /<link[^>]*rel="alternate"[^>]*href="([^"]+)"/.exec(entry)?.[1] ?? null;
107
+ tags.push({ name: decodeEntities(title.trim()), url: href ? decodeEntities(href) : null });
108
+ }
109
+
110
+ return tags;
111
+ }
112
+
113
+ /**
114
+ * The asset links inside a release's `expanded_assets` fragment.
115
+ *
116
+ * The name is read out of the DOWNLOAD URL rather than the link's text: the
117
+ * fragment wraps it in a `<span class="text-bold">` whose markup has changed
118
+ * before, while the URL is the release's own path and carries the same name.
119
+ *
120
+ * The CURRENT naming scheme wins over the legacy one, for the reason
121
+ * `shell-release`'s API path documents: a release carrying both spellings must
122
+ * not resolve to the older version.
123
+ */
124
+ export function shellAssetFromPage(kind: ShellKind, html: string): PageAsset | null {
125
+ const found: PageAsset[] = [];
126
+ const pattern = /href="(\/[^"]+\/releases\/download\/[^"]+)"/g;
127
+
128
+ for (const match of html.matchAll(pattern)) {
129
+ const path = decodeEntities(match[1] ?? '');
130
+ const name = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
131
+ if (versionFromAssetName(kind, name) !== null) found.push({ name, downloadUrl: `https://github.com${path}` });
132
+ }
133
+
134
+ return found.find((asset) => SHELL_ASSETS[kind].current.test(asset.name)) ?? found[0] ?? null;
135
+ }
136
+
137
+ /** The entities that appear in a feed title or a link. */
138
+ function decodeEntities(text: string): string {
139
+ return text
140
+ .replace(/&lt;/g, '<')
141
+ .replace(/&gt;/g, '>')
142
+ .replace(/&quot;/g, '"')
143
+ .replace(/&#39;/g, "'")
144
+ .replace(/&amp;/g, '&');
145
+ }
@@ -5,44 +5,16 @@
5
5
 
6
6
  import { beforeEach, describe, expect, test } from 'bun:test';
7
7
 
8
- import { fetchShellRelease, resetShellReleaseCache, versionFromAssetName } from '@/server/lib/updates/shell-release';
8
+ import { fetchShellRelease, resetShellReleaseCache } from '@/server/lib/updates/shell-release';
9
9
 
10
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.
11
+ * A fetch double that answers one body, and counts its calls.
14
12
  *
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.
13
+ * `text()` is provided as well as `json()`: the public-pages fallback reads Atom
14
+ * and HTML, and a stub that only answered `json()` would throw inside it and make
15
+ * a broken parse look like a clean "no release" — the exact confusion these
16
+ * tests exist to prevent.
17
17
  */
18
- describe('versionFromAssetName', () => {
19
- test('reads the version the release workflows write', () => {
20
- expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk')).toBe('1.2.3');
21
- expect(versionFromAssetName('ios', 'kabmo-ios-v1.2.3.ipa')).toBe('1.2.3');
22
- });
23
-
24
- test('accepts a hand-uploaded name without the v', () => {
25
- expect(versionFromAssetName('android', 'kabmo-android-1.2.3.apk')).toBe('1.2.3');
26
- expect(versionFromAssetName('ios', 'kabmo-ios-1.2.3.ipa')).toBe('1.2.3');
27
- });
28
-
29
- test('ignores anything that is not that shell’s artifact', () => {
30
- expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk.zip')).toBeNull();
31
- expect(versionFromAssetName('android', 'source.tar.gz')).toBeNull();
32
- expect(versionFromAssetName('android', 'kabmo-android-latest.apk')).toBeNull();
33
- expect(versionFromAssetName('android', '')).toBeNull();
34
- });
35
-
36
- test('does not accept one shell’s asset for the other', () => {
37
- // The two probes read the SAME release list, so a parser that ignored the
38
- // kind would let an iOS probe answer with the APK (and offer an `.apk` to an
39
- // iPhone).
40
- expect(versionFromAssetName('ios', 'kabmo-android-v1.2.3.apk')).toBeNull();
41
- expect(versionFromAssetName('android', 'kabmo-ios-v1.2.3.ipa')).toBeNull();
42
- });
43
- });
44
-
45
- /** A fetch double that answers one body, and counts its calls. */
46
18
  function stubFetch(body: unknown, ok = true) {
47
19
  const calls: string[] = [];
48
20
  const impl = (async (input: string | URL | Request) => {
@@ -50,11 +22,45 @@ function stubFetch(body: unknown, ok = true) {
50
22
  return {
51
23
  ok,
52
24
  json: async () => body,
25
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
53
26
  } as Response;
54
27
  }) as unknown as typeof fetch;
55
28
  return { impl, calls };
56
29
  }
57
30
 
31
+ /**
32
+ * A double that routes by URL, for the two-path probe.
33
+ *
34
+ * `api` answers the REST read (or fails, which is what a spent quota does) and
35
+ * `pages` answers the Atom feed and each release's asset fragment. Returning
36
+ * `null` for a route means "this request fails", which is how the fallback is
37
+ * entered at all.
38
+ */
39
+ function routedFetch(routes: {
40
+ api?: unknown;
41
+ atom?: string;
42
+ assets?: Record<string, string>;
43
+ }) {
44
+ const calls: string[] = [];
45
+ const impl = (async (input: string | URL | Request) => {
46
+ const url = String(input);
47
+ calls.push(url);
48
+
49
+ if (url.includes('/repos/')) {
50
+ if (routes.api === undefined) return { ok: false, json: async () => ({}), text: async () => '' } as Response;
51
+ return { ok: true, json: async () => routes.api, text: async () => JSON.stringify(routes.api) } as Response;
52
+ }
53
+ if (url.endsWith('/releases.atom')) {
54
+ const body = routes.atom ?? '';
55
+ return { ok: Boolean(routes.atom), json: async () => ({}), text: async () => body } as Response;
56
+ }
57
+ const tag = url.slice(url.lastIndexOf('/') + 1);
58
+ const body = routes.assets?.[decodeURIComponent(tag)];
59
+ return { ok: body !== undefined, json: async () => ({}), text: async () => body ?? '' } as Response;
60
+ }) as unknown as typeof fetch;
61
+ return { impl, calls };
62
+ }
63
+
58
64
  describe('fetchShellRelease', () => {
59
65
  // The cache is process-wide (see `resetShellReleaseCache`), so every case
60
66
  // starts from a cold one or it reads the previous case's answer.
@@ -67,15 +73,15 @@ describe('fetchShellRelease', () => {
67
73
  assets: [
68
74
  { name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
69
75
  {
70
- name: 'kabmo-android-v1.2.3.apk',
71
- browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
76
+ name: 'Kabmo-1.2.3-android.apk',
77
+ browser_download_url: 'https://x/Kabmo-1.2.3-android.apk',
72
78
  },
73
79
  ],
74
80
  },
75
81
  ]);
76
82
 
77
83
  expect(await fetchShellRelease('android', impl)).toEqual({
78
- url: 'https://x/kabmo-android-v1.2.3.apk',
84
+ url: 'https://x/Kabmo-1.2.3-android.apk',
79
85
  latest: '1.2.3',
80
86
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
81
87
  });
@@ -86,8 +92,8 @@ describe('fetchShellRelease', () => {
86
92
  {
87
93
  html_url: 'https://x/app',
88
94
  assets: [
89
- { name: 'kabmo-android-v0.9.0.apk', browser_download_url: 'https://x/a.apk' },
90
- { name: 'kabmo-ios-v0.4.0.ipa', browser_download_url: 'https://x/i.ipa' },
95
+ { name: 'Kabmo-0.9.0-android.apk', browser_download_url: 'https://x/a.apk' },
96
+ { name: 'Kabmo-0.4.0-ios.ipa', browser_download_url: 'https://x/i.ipa' },
91
97
  ],
92
98
  },
93
99
  ]);
@@ -99,14 +105,54 @@ describe('fetchShellRelease', () => {
99
105
  });
100
106
  });
101
107
 
108
+ /**
109
+ * A release that carries BOTH spellings must answer with the current one.
110
+ *
111
+ * That state is real: a rename lands, and the previous release's asset stays
112
+ * beside the new one until `--clobber` replaces it (or a hand-upload adds
113
+ * one). Measured before the fix: a release holding both
114
+ * `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0` —
115
+ * the console offered an update to the version the user already had, and hid
116
+ * the newer one.
117
+ */
118
+ test('prefers the current scheme when a release carries both', async () => {
119
+ const { impl } = stubFetch([
120
+ {
121
+ html_url: 'https://x/r',
122
+ assets: [
123
+ { name: 'kabmo-android-v0.4.0.apk', browser_download_url: 'https://x/old.apk' },
124
+ { name: 'Kabmo-0.6.0-android.apk', browser_download_url: 'https://x/new.apk' },
125
+ ],
126
+ },
127
+ ]);
128
+
129
+ expect(await fetchShellRelease('android', impl)).toEqual({
130
+ url: 'https://x/new.apk',
131
+ latest: '0.6.0',
132
+ releaseUrl: 'https://x/r',
133
+ });
134
+ });
135
+
136
+ /** A release carrying ONLY the old spelling must keep answering. */
137
+ test('still resolves a release that only has the legacy spelling', async () => {
138
+ const { impl } = stubFetch([
139
+ {
140
+ html_url: 'https://x/r2',
141
+ assets: [{ name: 'kabmo-android-v0.4.0.apk', browser_download_url: 'https://x/old.apk' }],
142
+ },
143
+ ]);
144
+
145
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('0.4.0');
146
+ });
147
+
102
148
  test('the two shells cache separately', async () => {
103
149
  // One shared slot would make the second probe answer with the first one's
104
150
  // artifact — the release list is the same read, the asset is not.
105
151
  const release = {
106
152
  html_url: 'https://x/app',
107
153
  assets: [
108
- { name: 'kabmo-android-v1.0.0.apk', browser_download_url: 'https://x/a.apk' },
109
- { name: 'kabmo-ios-v2.0.0.ipa', browser_download_url: 'https://x/i.ipa' },
154
+ { name: 'Kabmo-1.0.0-android.apk', browser_download_url: 'https://x/a.apk' },
155
+ { name: 'Kabmo-2.0.0-ios.ipa', browser_download_url: 'https://x/i.ipa' },
110
156
  ],
111
157
  };
112
158
  expect((await fetchShellRelease('android', stubFetch([release]).impl))?.latest).toBe('1.0.0');
@@ -129,7 +175,7 @@ describe('fetchShellRelease', () => {
129
175
  { html_url: 'https://x/kabmo-ui', assets: [] },
130
176
  {
131
177
  html_url: 'https://x/app',
132
- assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
178
+ assets: [{ name: 'Kabmo-0.3.0-android.apk', browser_download_url: 'https://x/a.apk' }],
133
179
  },
134
180
  ]);
135
181
 
@@ -145,16 +191,16 @@ describe('fetchShellRelease', () => {
145
191
  {
146
192
  draft: true,
147
193
  html_url: 'https://x/draft',
148
- assets: [{ name: 'kabmo-android-v9.9.9.apk', browser_download_url: 'https://x/draft.apk' }],
194
+ assets: [{ name: 'Kabmo-9.9.9-android.apk', browser_download_url: 'https://x/draft.apk' }],
149
195
  },
150
196
  {
151
197
  prerelease: true,
152
198
  html_url: 'https://x/pre',
153
- assets: [{ name: 'kabmo-android-v8.0.0.apk', browser_download_url: 'https://x/pre.apk' }],
199
+ assets: [{ name: 'Kabmo-8.0.0-android.apk', browser_download_url: 'https://x/pre.apk' }],
154
200
  },
155
201
  {
156
202
  html_url: 'https://x/app',
157
- assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
203
+ assets: [{ name: 'Kabmo-0.3.0-android.apk', browser_download_url: 'https://x/a.apk' }],
158
204
  },
159
205
  ]);
160
206
 
@@ -170,15 +216,45 @@ describe('fetchShellRelease', () => {
170
216
  });
171
217
 
172
218
  test('answers null when the probe fails', async () => {
173
- // An offline box or a rate-limited GitHub must not take the sidebar down,
174
- // and a failure must not be read as "no newer version" either — it is
175
- // simply no offer.
176
- const { impl } = stubFetch({}, false);
219
+ // An offline box must not take the sidebar down, and a failure must not be
220
+ // read as "no newer version" either — it is simply no offer. BOTH paths are
221
+ // down here: the API refuses and the feed is unreachable, which is the only
222
+ // shape that means "cannot ask" rather than "nothing published".
223
+ const { impl } = routedFetch({});
177
224
  expect(await fetchShellRelease('android', impl)).toBeNull();
178
225
  });
179
226
 
180
227
  test('answers null when the body is malformed', async () => {
181
- const { impl } = stubFetch({ assets: 'nope' });
228
+ // The API answers 200 with something that is not a release list, and the
229
+ // feed is unreachable — the API's answer is a failed probe, not an empty
230
+ // repository.
231
+ const { impl } = routedFetch({ api: { assets: 'nope' } });
182
232
  expect(await fetchShellRelease('android', impl)).toBeNull();
183
233
  });
234
+
235
+ /**
236
+ * The desktop trio reads the same release list and answers the RELEASE PAGE.
237
+ *
238
+ * Every desktop artifact is per-architecture, and this server cannot know which
239
+ * architecture the window asking is running on — it may be a remote console on
240
+ * another machine. Naming one arch's file would hand half the readers a build
241
+ * that will not launch, so the link is the page that lists all of them.
242
+ */
243
+ test('a desktop probe answers the release page, never one architecture', async () => {
244
+ const { impl } = stubFetch([
245
+ {
246
+ html_url: 'https://x/app',
247
+ assets: [
248
+ { name: 'Kabmo-0.2.0-mac-arm64.zip', browser_download_url: 'https://x/mac-arm.zip' },
249
+ { name: 'Kabmo-0.2.0-mac-x64.dmg', browser_download_url: 'https://x/mac-x64.dmg' },
250
+ ],
251
+ },
252
+ ]);
253
+
254
+ expect(await fetchShellRelease('macos', impl)).toEqual({
255
+ url: 'https://x/app',
256
+ latest: '0.2.0',
257
+ releaseUrl: 'https://x/app',
258
+ });
259
+ });
184
260
  });