@kabmo-labs/kabmo 0.5.2 → 0.6.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 (124) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +10 -15
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
  44. package/src/client/hooks/desktop/servers.test.ts +73 -0
  45. package/src/client/hooks/desktop/servers.ts +126 -0
  46. package/src/client/hooks/settings/model-roles.ts +156 -0
  47. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  48. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  49. package/src/client/hooks/workspace/keybindings.ts +44 -0
  50. package/src/client/hooks/workspace/open-panel.ts +42 -0
  51. package/src/client/lib/desktop/bridge.ts +158 -0
  52. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  53. package/src/client/lib/desktop/titlebar.ts +97 -0
  54. package/src/client/lib/plugins/slots.test.ts +54 -0
  55. package/src/client/lib/plugins/slots.ts +37 -1
  56. package/src/server/db.server.test.ts +44 -3
  57. package/src/server/db.server.ts +26 -2
  58. package/src/server/lib/bundler/build-errors.test.ts +0 -6
  59. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  60. package/src/server/lib/db/schema.ts +3 -0
  61. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  62. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  63. package/src/server/lib/model-roles/mock.server.ts +40 -0
  64. package/src/server/lib/model-roles/schema.server.ts +36 -0
  65. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  66. package/src/server/lib/model-roles/store.server.ts +188 -0
  67. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  68. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  69. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  70. package/src/server/lib/omp/rpc/manager.ts +18 -13
  71. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  72. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  73. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  74. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  75. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  76. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  77. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  78. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  79. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  80. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  81. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  82. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  83. package/src/server/lib/panels/compat.server.test.ts +4 -4
  84. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  85. package/src/server/lib/panels/registry.server.test.ts +4 -4
  86. package/src/server/lib/panels/state.server.test.ts +4 -4
  87. package/src/server/lib/panels/sync.server.test.ts +4 -4
  88. package/src/server/lib/plugins/background.test.ts +2 -2
  89. package/src/server/lib/plugins/e2e.test.ts +2 -2
  90. package/src/server/lib/plugins/platform.test.ts +2 -2
  91. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  92. package/src/server/lib/queue/delivery.server.ts +11 -2
  93. package/src/server/lib/updates/shell-release.test.ts +184 -0
  94. package/src/server/lib/updates/shell-release.ts +215 -0
  95. package/src/server/lib/waiting/store.server.test.ts +2 -2
  96. package/src/server/plugins/ssr.ts +13 -10
  97. package/src/server/routes/chat/stream.test.ts +4 -4
  98. package/src/server/routes/fs/git-merge.test.ts +0 -11
  99. package/src/server/routes/models/roles.ts +73 -14
  100. package/src/server/routes/panels/css.test.ts +4 -4
  101. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  102. package/src/server/routes/panels/routes.test.ts +0 -7
  103. package/src/server/routes/plugins/routes.test.ts +4 -4
  104. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  105. package/src/server/routes/updates/android.ts +2 -2
  106. package/src/server/routes/updates/index.ts +2 -0
  107. package/src/server/routes/updates/ios.ts +26 -0
  108. package/src/server/routes/waiting/routes.test.ts +2 -2
  109. package/src/shared/lib/models/server-cache.ts +11 -0
  110. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  111. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  112. package/src/shared/lib/updates/shell-ua.ts +41 -20
  113. package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
  114. package/src/shared/lib/workspace/mobile-dock.ts +15 -6
  115. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  116. package/src/shared/lib/workspace/panel-open.ts +76 -0
  117. package/src/shared/types/model-roles.ts +54 -0
  118. package/src/test-support/isolated-db.ts +17 -5
  119. package/dist/client/static/js/index-3qamd33d.css +0 -1
  120. package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
  121. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  122. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  123. package/src/server/lib/updates/android-release.test.ts +0 -138
  124. package/src/server/lib/updates/android-release.ts +0 -183
@@ -0,0 +1,184 @@
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, versionFromAssetName } from '@/server/lib/updates/shell-release';
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-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
+ function stubFetch(body: unknown, ok = true) {
47
+ const calls: string[] = [];
48
+ const impl = (async (input: string | URL | Request) => {
49
+ calls.push(String(input));
50
+ return {
51
+ ok,
52
+ json: async () => body,
53
+ } as Response;
54
+ }) as unknown as typeof fetch;
55
+ return { impl, calls };
56
+ }
57
+
58
+ describe('fetchShellRelease', () => {
59
+ // The cache is process-wide (see `resetShellReleaseCache`), so every case
60
+ // starts from a cold one or it reads the previous case's answer.
61
+ beforeEach(() => resetShellReleaseCache());
62
+
63
+ test('picks the APK asset and its version', async () => {
64
+ const { impl } = stubFetch([
65
+ {
66
+ html_url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
67
+ assets: [
68
+ { name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
69
+ {
70
+ name: 'kabmo-android-v1.2.3.apk',
71
+ browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
72
+ },
73
+ ],
74
+ },
75
+ ]);
76
+
77
+ expect(await fetchShellRelease('android', impl)).toEqual({
78
+ url: 'https://x/kabmo-android-v1.2.3.apk',
79
+ latest: '1.2.3',
80
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
81
+ });
82
+ });
83
+
84
+ test('picks the IPA asset for the iOS probe', async () => {
85
+ const { impl } = stubFetch([
86
+ {
87
+ html_url: 'https://x/app',
88
+ 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' },
91
+ ],
92
+ },
93
+ ]);
94
+
95
+ expect(await fetchShellRelease('ios', impl)).toEqual({
96
+ url: 'https://x/i.ipa',
97
+ latest: '0.4.0',
98
+ releaseUrl: 'https://x/app',
99
+ });
100
+ });
101
+
102
+ test('the two shells cache separately', async () => {
103
+ // One shared slot would make the second probe answer with the first one's
104
+ // artifact — the release list is the same read, the asset is not.
105
+ const release = {
106
+ html_url: 'https://x/app',
107
+ 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' },
110
+ ],
111
+ };
112
+ expect((await fetchShellRelease('android', stubFetch([release]).impl))?.latest).toBe('1.0.0');
113
+ expect((await fetchShellRelease('ios', stubFetch([release]).impl))?.latest).toBe('2.0.0');
114
+
115
+ // …and a reset of one leaves the other's answer in place.
116
+ resetShellReleaseCache('android');
117
+ const { impl, calls } = stubFetch([release]);
118
+ expect((await fetchShellRelease('ios', impl))?.latest).toBe('2.0.0');
119
+ expect(calls, 'a cached answer must not re-read GitHub').toHaveLength(0);
120
+ });
121
+
122
+ test('skips a newer release that carries no artifact', async () => {
123
+ // This is the shape that broke the button: `release.yml` releases the npm
124
+ // packages FIRST, so `kabmo-sdk/v0.0.2` is newer than the app release that
125
+ // holds the shell. Asking `/releases/latest` answered the package release and
126
+ // the button vanished while the artifact sat one entry below.
127
+ const { impl } = stubFetch([
128
+ { html_url: 'https://x/kabmo-sdk', assets: [{ name: 'source.tar.gz' }] },
129
+ { html_url: 'https://x/kabmo-ui', assets: [] },
130
+ {
131
+ html_url: 'https://x/app',
132
+ assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
133
+ },
134
+ ]);
135
+
136
+ expect(await fetchShellRelease('android', impl)).toEqual({
137
+ url: 'https://x/a.apk',
138
+ latest: '0.3.0',
139
+ releaseUrl: 'https://x/app',
140
+ });
141
+ });
142
+
143
+ test('skips drafts and prereleases', async () => {
144
+ const { impl } = stubFetch([
145
+ {
146
+ draft: true,
147
+ html_url: 'https://x/draft',
148
+ assets: [{ name: 'kabmo-android-v9.9.9.apk', browser_download_url: 'https://x/draft.apk' }],
149
+ },
150
+ {
151
+ prerelease: true,
152
+ html_url: 'https://x/pre',
153
+ assets: [{ name: 'kabmo-android-v8.0.0.apk', browser_download_url: 'https://x/pre.apk' }],
154
+ },
155
+ {
156
+ html_url: 'https://x/app',
157
+ assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
158
+ },
159
+ ]);
160
+
161
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('0.3.0');
162
+ });
163
+
164
+ test('answers an empty result for a list with no artifact anywhere', async () => {
165
+ const { impl } = stubFetch([
166
+ { html_url: 'https://x', assets: [{ name: 'notes.md' }] },
167
+ { html_url: 'https://y', assets: [] },
168
+ ]);
169
+ expect(await fetchShellRelease('android', impl)).toEqual({ url: null, latest: null, releaseUrl: null });
170
+ });
171
+
172
+ 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);
177
+ expect(await fetchShellRelease('android', impl)).toBeNull();
178
+ });
179
+
180
+ test('answers null when the body is malformed', async () => {
181
+ const { impl } = stubFetch({ assets: 'nope' });
182
+ expect(await fetchShellRelease('android', impl)).toBeNull();
183
+ });
184
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Which published native-shell artifact a browser or an installed shell should
3
+ * download, and whether the installed one is behind.
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`).
11
+ *
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.
15
+ *
16
+ * Two facts travel back, and they answer different questions:
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 tag
21
+ * (`kabmo-android-v1.2.3.apk`) — a name built here would be a second copy of
22
+ * the workflow's naming rule, and the first release that renames it 404s.
23
+ * - `latest` is the version that asset ships, read from the asset name. The
24
+ * release TAG cannot be used: the artifact is attached to the app's release,
25
+ * whose tag names the SERVER version, and the two move independently.
26
+ *
27
+ * Failure is a normal answer here (`null`), never a throw: an offline box or a
28
+ * rate-limited GitHub must not take the sidebar down, and a button that appears
29
+ * because a probe failed would offer a download that does not exist.
30
+ */
31
+
32
+ import { REPO } from '@/server/lib/updates/release-url';
33
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
34
+
35
+ /** One published shell artifact, as the console needs it. */
36
+ export interface ShellRelease {
37
+ /** Asset download URL, or null when the release carries no artifact. */
38
+ url: string | null;
39
+ /** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
40
+ latest: string | null;
41
+ /** The release page, for a "what changed" link. */
42
+ releaseUrl: string | null;
43
+ }
44
+
45
+ /** How each shell's asset is named by its publish workflow. */
46
+ export const SHELL_ASSETS: Record<ShellKind, { prefix: string; extension: string }> = {
47
+ android: { prefix: 'kabmo-android-', extension: '.apk' },
48
+ ios: { prefix: 'kabmo-ios-', extension: '.ipa' },
49
+ };
50
+
51
+ /**
52
+ * `kabmo-android-v1.2.3.apk` → `1.2.3` (and the `.ipa` twin).
53
+ *
54
+ * The `v` is optional because a hand-uploaded asset may omit it, and a version
55
+ * that cannot be read is not evidence of an update — the caller treats null as
56
+ * "no new version".
57
+ */
58
+ export function versionFromAssetName(kind: ShellKind, name: string): string | null {
59
+ const { prefix, extension } = SHELL_ASSETS[kind];
60
+ if (!name.startsWith(prefix) || !name.endsWith(extension)) return null;
61
+ const middle = name.slice(prefix.length, -extension.length);
62
+ const version = middle.startsWith('v') ? middle.slice(1) : middle;
63
+ return /^\d+\.\d+\.\d+/.test(version) ? version : null;
64
+ }
65
+
66
+ /**
67
+ * The newest release that actually carries this shell's artifact.
68
+ *
69
+ * NOT `/releases/latest`, which answers the newest release of ANY kind. This
70
+ * repository publishes three artifacts from one push, and the packages are
71
+ * released FIRST (`packages → marketplace → android → apk-build → app` in
72
+ * `release.yml`), so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer
73
+ * than the app release that carries the shell. `@semantic-release/github` runs
74
+ * in both pipelines, so those are real releases — measured against a stub that
75
+ * answered a package release: `/releases/latest` produced
76
+ * `{url: null, latest: null}` and the sidebar's button disappeared even though
77
+ * the APK sat on the release directly below.
78
+ *
79
+ * One request per shell, cached for five minutes on `globalThis` for the same
80
+ * reason the database handle is anchored there: `bun --hot` re-evaluates this
81
+ * module while the cache it would otherwise hold lives on. GitHub allows 60
82
+ * unauthenticated reads an hour per address, and the sidebar asks on every
83
+ * mount.
84
+ */
85
+ export async function fetchShellRelease(
86
+ kind: ShellKind,
87
+ fetchImpl: typeof fetch = fetch,
88
+ ): Promise<ShellRelease | null> {
89
+ const cache = cacheSlot(kind);
90
+ const now = Date.now();
91
+ if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
92
+
93
+ try {
94
+ const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
95
+ headers: {
96
+ accept: 'application/vnd.github+json',
97
+ // GitHub rejects a request with no User-Agent.
98
+ 'user-agent': 'kabmo-console',
99
+ },
100
+ });
101
+ if (!response.ok) return cache.entry ?? null;
102
+
103
+ const body = (await response.json()) as unknown;
104
+ // A body that is not the release list is a failed probe, not an empty
105
+ // repository: answering `{url: null, latest: null}` here would be
106
+ // indistinguishable from "no release carries an artifact", and the caller
107
+ // would cache that mistake for five minutes.
108
+ if (!Array.isArray(body)) return cache.entry ?? null;
109
+
110
+ // Drafts are not published and prereleases are not what a console offers an
111
+ // installed shell — the same two the `latest` endpoint excluded.
112
+ const published = body.filter(
113
+ (entry): entry is Record<string, unknown> =>
114
+ entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
115
+ );
116
+
117
+ const release = published
118
+ .map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
119
+ .find((candidate) => candidate.asset !== null);
120
+
121
+ if (!release?.asset) {
122
+ const fallback: ShellRelease = { url: null, latest: null, releaseUrl: null };
123
+ cache.entry = fallback;
124
+ cache.at = now;
125
+ return fallback;
126
+ }
127
+
128
+ const resolved: ShellRelease = {
129
+ url: typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
130
+ latest: versionFromAssetName(kind, String(release.asset.name)),
131
+ releaseUrl: typeof release.entry.html_url === 'string' ? release.entry.html_url : null,
132
+ };
133
+
134
+ cache.entry = resolved;
135
+ cache.at = now;
136
+ return resolved;
137
+ } catch {
138
+ // Keep the last good answer rather than replacing it with a failure: a
139
+ // momentary outage must not remove a download button that was valid.
140
+ return cache.entry ?? null;
141
+ }
142
+ }
143
+
144
+ /** How many releases to look back through for the artifact. */
145
+ const RELEASE_SCAN = 20;
146
+
147
+ /** The first asset of a release that names this shell's artifact, or null. */
148
+ function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
149
+ if (!Array.isArray(assets)) return null;
150
+
151
+ for (const entry of assets) {
152
+ if (entry === null || typeof entry !== 'object') continue;
153
+ const asset = entry as Record<string, unknown>;
154
+ if (typeof asset.name === 'string' && versionFromAssetName(kind, asset.name) !== null) return asset;
155
+ }
156
+
157
+ return null;
158
+ }
159
+
160
+ const CACHE_MS = 5 * 60_000;
161
+
162
+ /**
163
+ * Where the release read goes.
164
+ *
165
+ * Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
166
+ * test and a rehearsal need to exercise the real parsing against a stub instead
167
+ * of a live GitHub call, and hardcoding the host makes that impossible without
168
+ * intercepting `fetch` globally.
169
+ */
170
+ function githubApiBase(): string {
171
+ const configured = Bun.env.KABMO_GITHUB_API ?? '';
172
+ return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
173
+ }
174
+
175
+ interface CacheSlot {
176
+ entry: ShellRelease | null;
177
+ at: number;
178
+ }
179
+
180
+ type ShellCacheHost = typeof globalThis & {
181
+ __kabmoShellRelease?: Partial<Record<ShellKind, CacheSlot>>;
182
+ };
183
+
184
+ /**
185
+ * The cache slot for one shell.
186
+ *
187
+ * PER SHELL, not one shared slot: both probes read the same release list but
188
+ * resolve different assets, so one slot would make the second shell answer with
189
+ * the first one's artifact.
190
+ */
191
+ function cacheSlot(kind: ShellKind): CacheSlot {
192
+ const host = globalThis as ShellCacheHost;
193
+ host.__kabmoShellRelease ??= {};
194
+ host.__kabmoShellRelease[kind] ??= { entry: null, at: 0 };
195
+ return host.__kabmoShellRelease[kind] as CacheSlot;
196
+ }
197
+
198
+ /**
199
+ * Drop the cached answer for one shell (or every shell, with no argument).
200
+ *
201
+ * The cache lives on `globalThis` so `bun --hot` cannot orphan it, which also
202
+ * means it outlives a test file: without this seam the second case in a suite
203
+ * reads the first case's answer and passes for the wrong reason (measured — the
204
+ * "malformed body" case returned the previous test's release). Production has
205
+ * no caller; it exists because the cache is process-wide by design.
206
+ */
207
+ export function resetShellReleaseCache(kind?: ShellKind): void {
208
+ const host = globalThis as ShellCacheHost;
209
+ if (!kind) {
210
+ host.__kabmoShellRelease = {};
211
+ return;
212
+ }
213
+ host.__kabmoShellRelease ??= {};
214
+ host.__kabmoShellRelease[kind] = { entry: null, at: 0 };
215
+ }
@@ -38,11 +38,11 @@ const ROOT = path.join(os.tmpdir(), `kabmo-waiting-store-${process.pid}`);
38
38
 
39
39
  beforeAll(async () => {
40
40
  fs.mkdirSync(ROOT, { recursive: true });
41
- isolateDb();
41
+ await isolateDb();
42
42
  });
43
43
 
44
44
  afterAll(async () => {
45
- releaseDb();
45
+ await releaseDb();
46
46
  fs.rmSync(ROOT, { recursive: true, force: true });
47
47
  });
48
48
 
@@ -16,7 +16,7 @@ import { readSettingsBlob } from '@/shared/lib/settings/keys';
16
16
  import { resolveTheme } from '@/shared/lib/theme/catalog';
17
17
  import { windowChromeColor } from '@/shared/lib/theme/chrome';
18
18
  import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
19
- import { androidShellVersion, isAndroidDevice } from '@/shared/lib/updates/shell-ua';
19
+ import { isAndroidDevice, shellVersion } from '@/shared/lib/updates/shell-ua';
20
20
 
21
21
  /** HTML-escape a string for interpolation into the failure page's `<pre>`. */
22
22
  function escapeHtmlText(value: string): string {
@@ -157,15 +157,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
157
157
  const theme = resolveTheme(settingsBlob.theme);
158
158
  const userAgent = request.headers.get('user-agent') ?? '';
159
159
  const initialIsMobile = MOBILE_UA.test(userAgent);
160
- // Which APK is asking, when the request came from the Android shell. Read
161
- // HERE because the shell is rendered per request and the client's download
162
- // button needs the answer before its first paint: a fetch after hydration
163
- // would draw the button late, or draw it and then remove it.
160
+ // Which shell is asking, and which build of it. Read HERE because the shell
161
+ // is rendered per request and the client's download control needs the answer
162
+ // before its first paint: a fetch after hydration would draw the button late,
163
+ // or draw it and then remove it.
164
164
  //
165
- // `isAndroid` is the wider fact and it exists for the INSTALL offer: a reader
166
- // on Chrome or Brave for Android carries no shell token, and "not the app"
167
- // alone cannot tell them from a desktop where an APK is useless.
168
- const shellVersion = androidShellVersion(userAgent);
165
+ // `isAndroid` is the wider fact and it exists for the Android INSTALL offer: a
166
+ // reader on Chrome or Brave for Android carries no shell token, and "not the
167
+ // app" alone cannot tell them from a desktop where an APK is useless. There is
168
+ // no `isIos`: an IPA cannot be installed from a browser, so only an installed
169
+ // iOS shell is ever offered an update.
170
+ const androidVersion = shellVersion(userAgent, 'android');
171
+ const iosVersion = shellVersion(userAgent, 'ios');
169
172
  const isAndroid = isAndroidDevice(userAgent);
170
173
 
171
174
  const rendered = await renderShell();
@@ -178,7 +181,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
178
181
  // questions: "this browser has a session" versus "there is a session to have".
179
182
  // A Kabmo running open answers `authenticated: true` for every visitor, so
180
183
  // the navbar cannot use it to decide whether a sign-out control applies.
181
- const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, isAndroid, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
184
+ const bootstrap = JSON.stringify({ initialIsMobile, shellVersion: androidVersion, iosShellVersion: iosVersion, isAndroid, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
182
185
  // The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
183
186
  // a second tag: with both present Chrome reads the first one, so an appended
184
187
  // tag would leave a dark theme painting light browser chrome.
@@ -33,16 +33,16 @@ import { action } from '@/server/routes/chat/stream';
33
33
 
34
34
  const savedMock = Bun.env.MOCK;
35
35
 
36
- beforeEach(() => {
37
- isolateDb();
36
+ beforeEach(async () => {
37
+ await isolateDb();
38
38
  localRuns().clear();
39
39
  Bun.env.MOCK = 'true';
40
40
  });
41
41
 
42
- afterEach(() => {
42
+ afterEach(async () => {
43
43
  Bun.env.MOCK = savedMock;
44
44
  localRuns().clear();
45
- releaseDb();
45
+ await releaseDb();
46
46
  });
47
47
 
48
48
  /** Resolve on the next `stream-status` write — the write's own signal. */
@@ -254,17 +254,6 @@ describe('continue_operation', () => {
254
254
  });
255
255
 
256
256
  describe('abort_operation', () => {
257
- test('restores the pre-merge state', async () => {
258
- const repo = makeConflictingRepo();
259
- await post({ actionType: 'merge_branch', repo: '.', root: repo, branch: 'feature' });
260
-
261
- const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
262
- expect(res.body.success).toBe(true);
263
- expect((await readGitState(repo)).operation).toBeNull();
264
- expect(fs.readFileSync(path.join(repo, 'f.txt'), 'utf8')).toBe('a\nMAIN\n');
265
- expect(git(repo, 'status', '--porcelain')).toBe('');
266
- });
267
-
268
257
  test('refuses when nothing is in progress', async () => {
269
258
  const repo = makeConflictingRepo();
270
259
  const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
@@ -2,30 +2,89 @@ import { json } from '@/server/lib/remix-compat';
2
2
  import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
3
3
  import { methodNotAllowed } from '@/server/lib/route-adapter';
4
4
  import { readModelRoles, writeModelRoles } from '@/server/lib/omp/config/roles';
5
+ import { readModelRoleCandidates } from '@/server/lib/model-roles/catalog.server';
6
+ import {
7
+ deleteModelRolePreset,
8
+ listModelRolePresets,
9
+ saveModelRolePreset,
10
+ seedPresetFromCurrentRoles,
11
+ } from '@/server/lib/model-roles/store.server';
12
+ import { normalizeRoleMap } from '@/shared/lib/omp/config/model-roles';
13
+ import type { ModelRolesPayload } from '@/shared/types/model-roles';
5
14
 
15
+ /**
16
+ * Model roles: the live `modelRoles` map from config.yml, the presets Kabmo
17
+ * stores for it, and the catalog the role dropdowns offer.
18
+ *
19
+ * Three verbs, each with one job:
20
+ * - `GET` → everything the panel renders (roles, presets, candidates), and
21
+ * the ONE-SHOT seed that snapshots an install's existing roles
22
+ * into a preset the first time the panel is opened.
23
+ * - `PUT` → replace the whole `modelRoles` map. This is what applying a
24
+ * preset does, and what the per-row editors do with one entry
25
+ * changed; there is no per-role verb, because config.yml's map is
26
+ * written whole either way.
27
+ * - `POST` → save a preset (`{ preset: { id?, name, roles } }`).
28
+ * - `DELETE` → remove a preset by `?id=`.
29
+ */
6
30
  export async function loader(_args: LoaderFunctionArgs) {
7
31
  try {
8
- return json(await readModelRoles());
32
+ // Roles first: the seed snapshots them, and the presets list is read after
33
+ // it so the seeded row is in the very first payload the panel renders.
34
+ const [{ path, roles }, catalog] = await Promise.all([
35
+ readModelRoles(),
36
+ readModelRoleCandidates(),
37
+ ]);
38
+ await seedPresetFromCurrentRoles(roles);
39
+ const presets = await listModelRolePresets();
40
+ const payload: ModelRolesPayload = {
41
+ path,
42
+ roles,
43
+ presets,
44
+ candidates: catalog.candidates,
45
+ candidatesError: catalog.error,
46
+ };
47
+ return json(payload);
9
48
  } catch (error) {
10
49
  return json({ error: String(error) }, { status: 400 });
11
50
  }
12
51
  }
13
52
 
14
53
  export async function action({ request, params }: ActionFunctionArgs) {
15
- if (request.method !== 'PUT') {
16
- return methodNotAllowed({ request, params });
17
- }
18
54
  try {
19
- const body = await request.json() as { roles?: unknown };
20
- if (!body.roles || typeof body.roles !== 'object' || Array.isArray(body.roles)) {
21
- return json({ error: 'roles must be an object' }, { status: 400 });
22
- }
23
- const roles = Object.fromEntries(Object.entries(body.roles).filter((entry): entry is [string, string] =>
24
- typeof entry[1] === 'string' && entry[0].trim().length > 0 && entry[1].trim().length > 0,
25
- ));
26
- await writeModelRoles(roles);
27
- return json({ success: true, roles });
55
+ if (request.method === 'PUT') return await saveRoles(request);
56
+ if (request.method === 'POST') return await savePreset(request);
57
+ if (request.method === 'DELETE') return await removePreset(request);
58
+ return methodNotAllowed({ request, params });
28
59
  } catch (error) {
29
- return json({ error: String(error) }, { status: 400 });
60
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
30
61
  }
31
62
  }
63
+
64
+ async function saveRoles(request: Request): Promise<Response> {
65
+ const body = await request.json() as { roles?: unknown };
66
+ const roles = normalizeRoleMap(body.roles);
67
+ await writeModelRoles(roles);
68
+ return json({ success: true, roles });
69
+ }
70
+
71
+ async function savePreset(request: Request): Promise<Response> {
72
+ const body = await request.json() as { preset?: unknown };
73
+ const preset = typeof body.preset === 'object' && body.preset !== null ? body.preset : null;
74
+ if (!preset) return json({ error: 'preset is required' }, { status: 400 });
75
+ const draft = preset as Record<string, unknown>;
76
+ const stored = await saveModelRolePreset({
77
+ id: typeof draft.id === 'string' && draft.id ? draft.id : null,
78
+ name: draft.name,
79
+ roles: draft.roles,
80
+ });
81
+ return json({ success: true, preset: stored, presets: await listModelRolePresets() });
82
+ }
83
+
84
+ async function removePreset(request: Request): Promise<Response> {
85
+ const id = new URL(request.url).searchParams.get('id');
86
+ if (!id) return json({ error: 'id is required' }, { status: 400 });
87
+ const removed = await deleteModelRolePreset(id);
88
+ if (!removed) return json({ error: 'Preset not found' }, { status: 404 });
89
+ return json({ success: true, presets: await listModelRolePresets() });
90
+ }
@@ -60,19 +60,19 @@ function call(loader: (args: never) => unknown, params: Record<string, string>)
60
60
  return loader({ params, request: new Request('http://local/') } as never) as Promise<Response>;
61
61
  }
62
62
 
63
- beforeEach(() => {
63
+ beforeEach(async () => {
64
64
  root = fs.mkdtempSync(join(tmpdir(), 'omc-panel-css-'));
65
65
  bundled = fs.mkdtempSync(join(tmpdir(), 'omc-panel-store-'));
66
66
  process.env.KABMO_MARKETPLACE_DIR = root;
67
67
  process.env.KABMO_BUNDLED_MARKETPLACE_DIR = bundled;
68
68
  // A database of this suite's own, BEFORE the first scan: the scan reads the
69
69
  // disabled set, and a path set while a cached handle exists is never read.
70
- isolateDb();
70
+ await isolateDb();
71
71
  invalidatePanelScan();
72
72
  });
73
73
 
74
- afterEach(() => {
75
- releaseDb();
74
+ afterEach(async () => {
75
+ await releaseDb();
76
76
  delete process.env.KABMO_MARKETPLACE_DIR;
77
77
  delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
78
78
  invalidatePanelScan();
@@ -104,19 +104,19 @@ export { getMarketplacePluginsDir };
104
104
  * entry makes a later "no errors" assertion pass or fail for the wrong reason.
105
105
  */
106
106
  export function usePanelRouteFixtures(): void {
107
- beforeEach(() => {
107
+ beforeEach(async () => {
108
108
  dirs.root = fs.mkdtempSync(join(tmpdir(), 'omc-panel-routes-'));
109
109
  dirs.bundled = fs.mkdtempSync(join(tmpdir(), 'omc-panel-store-'));
110
110
  process.env.KABMO_MARKETPLACE_DIR = dirs.root;
111
111
  process.env.KABMO_BUNDLED_MARKETPLACE_DIR = dirs.bundled;
112
- isolateDb();
112
+ await isolateDb();
113
113
  invalidatePanelScan();
114
114
  });
115
115
 
116
116
  afterEach(async () => {
117
117
  await disposeAllPlugins();
118
118
  globalThis.__kabmoPluginLoadErrors = undefined;
119
- releaseDb();
119
+ await releaseDb();
120
120
  delete process.env.KABMO_MARKETPLACE_DIR;
121
121
  delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
122
122
  invalidatePanelScan();
@@ -132,13 +132,6 @@ describe('the README route', () => {
132
132
 
133
133
  expect((await call(readmeLoader, { plugin: 'demo', '*': '../outside.md' })).status).toBe(403);
134
134
  });
135
-
136
- test('404s a plugin that ships no README', async () => {
137
- const dir = writePlugin('demo', getMarketplacePluginsDir());
138
- fs.rmSync(join(dir, 'README.md'));
139
- invalidatePanelScan();
140
- expect((await call(readmeLoader, { plugin: 'demo', '*': 'README.md' })).status).toBe(404);
141
- });
142
135
  });
143
136
 
144
137
  describe('the install route', () => {