@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
@@ -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,75 +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-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 two 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
- /** A fetch double that answers one body, and counts its calls. */
77
18
  function stubFetch(body: unknown, ok = true) {
78
19
  const calls: string[] = [];
79
20
  const impl = (async (input: string | URL | Request) => {
@@ -81,11 +22,45 @@ function stubFetch(body: unknown, ok = true) {
81
22
  return {
82
23
  ok,
83
24
  json: async () => body,
25
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
84
26
  } as Response;
85
27
  }) as unknown as typeof fetch;
86
28
  return { impl, calls };
87
29
  }
88
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
+
89
64
  describe('fetchShellRelease', () => {
90
65
  // The cache is process-wide (see `resetShellReleaseCache`), so every case
91
66
  // starts from a cold one or it reads the previous case's answer.
@@ -241,15 +216,45 @@ describe('fetchShellRelease', () => {
241
216
  });
242
217
 
243
218
  test('answers null when the probe fails', async () => {
244
- // An offline box or a rate-limited GitHub must not take the sidebar down,
245
- // and a failure must not be read as "no newer version" either — it is
246
- // simply no offer.
247
- 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({});
248
224
  expect(await fetchShellRelease('android', impl)).toBeNull();
249
225
  });
250
226
 
251
227
  test('answers null when the body is malformed', async () => {
252
- 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' } });
253
232
  expect(await fetchShellRelease('android', impl)).toBeNull();
254
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
+ });
255
260
  });
@@ -2,128 +2,68 @@
2
2
  * Which published native-shell artifact a browser or an installed shell should
3
3
  * download, and whether the installed one is behind.
4
4
  *
5
- * Neither shell is an npm package (see `.github/workflows/publish-android.yml`
6
- * and `publish-ios.yml`), so the npm probe that answers for the app and for
7
- * `omp` cannot answer here. The only record of a published shell is a GitHub
8
- * release asset, which means one unauthenticated REST read of the release LIST —
9
- * not `/releases/latest`, which answers the newest release of ANY kind (see
10
- * `fetchShellRelease`).
5
+ * No shell is an npm package (see `.github/workflows/publish-{android,ios,macos,
6
+ * windows,linux}.yml`), so the npm probe that answers for the app and for `omp`
7
+ * cannot answer here. The only record of a published shell is a GitHub release
8
+ * asset.
11
9
  *
12
- * ONE module for both shells, because the question is identical and only the
13
- * asset naming differs. Two copies is how one of them starts reading the release
14
- * tag, or caching under a slot the other overwrites.
10
+ * ## Two reads, because one of them has a quota
15
11
  *
16
- * Two facts travel back, and they answer different questions:
12
+ * The REST API is the precise read and the FIRST one tried. It is also
13
+ * unauthenticated at 60 requests an hour **per source IP**, and a phone on mobile
14
+ * data shares that IP with an entire carrier NAT — so `shell-pages` is the second
15
+ * path, and it is why the button appears at all on a bad day. See that module's
16
+ * own comment for the measured failure.
17
17
  *
18
- * - `url` is the asset to download. It is resolved from the release's OWN asset
19
- * list rather than built as `releases/latest/download/<name>`, because that
20
- * path needs the asset name to match exactly and the name carries the shell's
21
- * version (`Kabmo-0.5.0-android.apk`) — a name built here would be a second
22
- * copy of the workflow's naming rule, and the first release that renames it
23
- * 404s.
24
- * - `latest` is the version that asset ships, read from the asset name. The
25
- * release TAG cannot be used: the artifact is attached to the app's release,
26
- * whose tag names the SERVER version, and the two move independently.
18
+ * ## What travels back
27
19
  *
28
- * Failure is a normal answer here (`null`), never a throw: an offline box or a
29
- * rate-limited GitHub must not take the sidebar down, and a button that appears
30
- * because a probe failed would offer a download that does not exist.
20
+ * - `url` is what to download. For the mobile pair it is the artifact itself (an
21
+ * 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
+ * - `latest` is the version that asset ships, read from the asset NAME (see
28
+ * `shell-assets`). The release TAG cannot be used: the artifact is attached to
29
+ * the app's release, whose tag names the SERVER version.
30
+ *
31
+ * Failure is a normal answer here (`null`), never a throw: an offline box must
32
+ * not take the sidebar down, and a button that appears because a probe failed
33
+ * would offer a download that does not exist.
31
34
  */
32
35
 
33
36
  import { REPO } from '@/server/lib/updates/release-url';
37
+ import { SHELL_ASSETS, versionFromAssetName } from '@/server/lib/updates/shell-assets';
38
+ import { findAssetInPages } from '@/server/lib/updates/shell-pages';
34
39
  import type { ShellKind } from '@/shared/lib/updates/shell-ua';
35
40
 
36
41
  /** One published shell artifact, as the console needs it. */
37
42
  export interface ShellRelease {
38
- /** Asset download URL, or null when the release carries no artifact. */
43
+ /** Asset download URL, or the release page for a per-arch desktop build. */
39
44
  url: string | null;
40
- /** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
45
+ /** Version the asset name declares (`Kabmo-1.2.3-android.apk` → `1.2.3`). */
41
46
  latest: string | null;
42
47
  /** The release page, for a "what changed" link. */
43
48
  releaseUrl: string | null;
44
49
  }
45
50
 
46
- /**
47
- * How each shell's asset is named by its publish workflow, plus the prefix it
48
- * carried BEFORE the rename.
49
- *
50
- * ONE scheme for every shell in the project — `<App>-<version>-<platform>.<ext>`:
51
- * `Kabmo-0.5.0-android.apk`, `Kabmo-0.1.0-ios.ipa`, and the desktop trio's
52
- * `Kabmo-0.1.2-mac-arm64.dmg` / `-win-x64-setup.exe` / `-linux-x86_64.tar.gz`.
53
- * The mobile pair carries no architecture because neither artifact is per-arch:
54
- * the APK is one universal build (the shell is pure Kotlin with no native
55
- * libraries, so there is nothing to split), and the IPA contains only the device
56
- * slice (`arm64-apple-ios`). A `-arm64` in those names would claim a distinction
57
- * that does not exist and would make the parser look for a second artifact.
58
- *
59
- * **`legacyPrefix` is not dead code.** These names are PARSED to answer "is a
60
- * newer shell published", and every release already on GitHub carries the old
61
- * spelling. Dropping it would make the console answer "no update" for an
62
- * installed shell the moment this ships — silently, because a name that does not
63
- * match reads exactly like a release with no artifact. The probe tries the
64
- * current prefix first, then the old one; new releases only ever write the
65
- * current one.
66
- */
67
- export const SHELL_ASSETS: Record<
68
- ShellKind,
69
- { prefix: string; extension: string; legacyPrefix: string; legacyExtension: string }
70
- > = {
71
- android: {
72
- prefix: 'Kabmo-',
73
- extension: '-android.apk',
74
- legacyPrefix: 'kabmo-android-v',
75
- legacyExtension: '.apk',
76
- },
77
- ios: { prefix: 'Kabmo-', extension: '-ios.ipa', legacyPrefix: 'kabmo-ios-v', legacyExtension: '.ipa' },
78
- };
79
-
80
- /**
81
- * `Kabmo-0.5.0-android.apk` → `0.5.0` (and the `.ipa` twin), plus the legacy
82
- * `kabmo-android-v0.5.0.apk` spelling every published release still carries.
83
- *
84
- * The version is the MIDDLE of the name in the current scheme and the TAIL of the
85
- * legacy one, so the two are parsed by different rules rather than by one that
86
- * tries to cover both.
87
- */
88
- export function versionFromAssetName(kind: ShellKind, name: string): string | null {
89
- const { prefix, extension, legacyPrefix, legacyExtension } = SHELL_ASSETS[kind];
90
-
91
- // Current: `<prefix><version><extension>`, where the prefix is the app's name
92
- // and the extension names the platform.
93
- if (name.startsWith(prefix) && name.endsWith(extension)) {
94
- const middle = name.slice(prefix.length, -extension.length);
95
- if (/^\d+\.\d+\.\d+$/.test(middle)) return middle;
96
- }
97
-
98
- // Legacy: `kabmo-android-v<version>.apk`. Kept for the releases already
99
- // published; no new release writes it.
100
- if (name.startsWith(legacyPrefix) && name.endsWith(legacyExtension)) {
101
- const middle = name.slice(legacyPrefix.length, -legacyExtension.length);
102
- const version = middle.startsWith('v') ? middle.slice(1) : middle;
103
- return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
104
- }
105
-
106
- return null;
107
- }
108
-
109
51
  /**
110
52
  * The newest release that actually carries this shell's artifact.
111
53
  *
112
54
  * NOT `/releases/latest`, which answers the newest release of ANY kind. This
113
- * repository publishes three artifacts from one push, and the packages are
114
- * released FIRST (`packages → marketplace → android → apk-build → app` in
55
+ * repository publishes several artifacts from one push, and the packages are
56
+ * released FIRST (`packages → marketplace → android → … → app` in
115
57
  * `release.yml`), so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer
116
- * than the app release that carries the shell. `@semantic-release/github` runs
58
+ * than the app release that carries the shells. `@semantic-release/github` runs
117
59
  * in both pipelines, so those are real releases — measured against a stub that
118
60
  * answered a package release: `/releases/latest` produced
119
61
  * `{url: null, latest: null}` and the sidebar's button disappeared even though
120
62
  * the APK sat on the release directly below.
121
63
  *
122
- * One request per shell, cached for five minutes on `globalThis` for the same
123
- * reason the database handle is anchored there: `bun --hot` re-evaluates this
124
- * module while the cache it would otherwise hold lives on. GitHub allows 60
125
- * unauthenticated reads an hour per address, and the sidebar asks on every
126
- * mount.
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.
127
67
  */
128
68
  export async function fetchShellRelease(
129
69
  kind: ShellKind,
@@ -133,25 +73,44 @@ export async function fetchShellRelease(
133
73
  const now = Date.now();
134
74
  if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
135
75
 
76
+ const headers = gitHubHeaders();
77
+ const resolved =
78
+ (await readFromApi(kind, fetchImpl, headers)) ?? (await readFromPages(kind, fetchImpl, headers));
79
+ if (!resolved) return cache.entry ?? null;
80
+
81
+ cache.entry = resolved;
82
+ cache.at = now;
83
+ return resolved;
84
+ }
85
+
86
+ /** How many releases to look back through for the artifact. */
87
+ const RELEASE_SCAN = 20;
88
+
89
+ /**
90
+ * The REST read, or null when it did not answer with a usable release list.
91
+ *
92
+ * A non-2xx is not an error to report — it is the quota, and the caller has a
93
+ * second path.
94
+ */
95
+ async function readFromApi(
96
+ kind: ShellKind,
97
+ fetchImpl: typeof fetch,
98
+ headers: Record<string, string>,
99
+ ): Promise<ShellRelease | null> {
136
100
  try {
137
101
  const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
138
- headers: {
139
- accept: 'application/vnd.github+json',
140
- // GitHub rejects a request with no User-Agent.
141
- 'user-agent': 'kabmo-console',
142
- },
102
+ headers: { accept: 'application/vnd.github+json', ...headers },
143
103
  });
144
- if (!response.ok) return cache.entry ?? null;
104
+ if (!response.ok) return null;
145
105
 
146
106
  const body = (await response.json()) as unknown;
147
107
  // A body that is not the release list is a failed probe, not an empty
148
108
  // repository: answering `{url: null, latest: null}` here would be
149
- // indistinguishable from "no release carries an artifact", and the caller
150
- // would cache that mistake for five minutes.
151
- if (!Array.isArray(body)) return cache.entry ?? null;
109
+ // indistinguishable from "no release carries an artifact".
110
+ if (!Array.isArray(body)) return null;
152
111
 
153
112
  // Drafts are not published and prereleases are not what a console offers an
154
- // installed shell — the same two the `latest` endpoint excluded.
113
+ // installed shell.
155
114
  const published = body.filter(
156
115
  (entry): entry is Record<string, unknown> =>
157
116
  entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
@@ -161,31 +120,34 @@ export async function fetchShellRelease(
161
120
  .map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
162
121
  .find((candidate) => candidate.asset !== null);
163
122
 
164
- if (!release?.asset) {
165
- const fallback: ShellRelease = { url: null, latest: null, releaseUrl: null };
166
- cache.entry = fallback;
167
- cache.at = now;
168
- return fallback;
169
- }
123
+ if (!release?.asset) return { url: null, latest: null, releaseUrl: null };
170
124
 
171
- const resolved: ShellRelease = {
172
- url: typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
173
- latest: versionFromAssetName(kind, String(release.asset.name)),
125
+ return resolvedRelease(kind, {
126
+ name: String(release.asset.name),
127
+ downloadUrl:
128
+ typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
174
129
  releaseUrl: typeof release.entry.html_url === 'string' ? release.entry.html_url : null,
175
- };
176
-
177
- cache.entry = resolved;
178
- cache.at = now;
179
- return resolved;
130
+ });
180
131
  } catch {
181
- // Keep the last good answer rather than replacing it with a failure: a
182
- // momentary outage must not remove a download button that was valid.
183
- return cache.entry ?? null;
132
+ return null;
184
133
  }
185
134
  }
186
135
 
187
- /** How many releases to look back through for the artifact. */
188
- const RELEASE_SCAN = 20;
136
+ /** The public-pages read, wrapped so a failure is the caller's `null`. */
137
+ async function readFromPages(
138
+ kind: ShellKind,
139
+ fetchImpl: typeof fetch,
140
+ headers: Record<string, string>,
141
+ ): Promise<ShellRelease | null> {
142
+ try {
143
+ const found = await findAssetInPages(kind, fetchImpl, headers);
144
+ 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 });
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
189
151
 
190
152
  /**
191
153
  * The asset of a release that names this shell's artifact, or null.
@@ -198,9 +160,6 @@ const RELEASE_SCAN = 20;
198
160
  * `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0`,
199
161
  * i.e. the console offered an update to a version it already had and hid the
200
162
  * newer one.
201
- *
202
- * Both schemes are still ACCEPTED (a release with only the old spelling must
203
- * keep working), so the preference is a second pass rather than a filter.
204
163
  */
205
164
  function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
206
165
  if (!Array.isArray(assets)) return null;
@@ -213,15 +172,21 @@ function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> |
213
172
  versionFromAssetName(kind, String((entry as Record<string, unknown>).name)) !== null,
214
173
  );
215
174
 
216
- const { prefix, extension } = SHELL_ASSETS[kind];
217
- return (
218
- named.find((asset) => {
219
- const name = String(asset.name);
220
- return name.startsWith(prefix) && name.endsWith(extension);
221
- }) ??
222
- named[0] ??
223
- null
224
- );
175
+ return named.find((asset) => SHELL_ASSETS[kind].current.test(String(asset.name))) ?? named[0] ?? null;
176
+ }
177
+
178
+ /** Turn one resolved asset into the payload, applying the per-kind link rule. */
179
+ function resolvedRelease(
180
+ kind: ShellKind,
181
+ asset: { name: string; downloadUrl: string | null; releaseUrl: string | null },
182
+ ): ShellRelease {
183
+ 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,
187
+ latest: versionFromAssetName(kind, asset.name),
188
+ releaseUrl: asset.releaseUrl,
189
+ };
225
190
  }
226
191
 
227
192
  const CACHE_MS = 5 * 60_000;
@@ -239,6 +204,25 @@ function githubApiBase(): string {
239
204
  return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
240
205
  }
241
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
+
242
226
  interface CacheSlot {
243
227
  entry: ShellRelease | null;
244
228
  at: number;