@kabmo-labs/kabmo 0.6.1 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -16,7 +16,7 @@ import {
16
16
 
17
17
  /** The published release every case compares against. */
18
18
  const RELEASE = {
19
- url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
19
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/Kabmo-1.2.3-android.apk',
20
20
  latest: '1.2.3',
21
21
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
22
22
  };
@@ -17,26 +17,57 @@ import { fetchShellRelease, resetShellReleaseCache, versionFromAssetName } from
17
17
  */
18
18
  describe('versionFromAssetName', () => {
19
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');
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
22
  });
23
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');
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');
27
36
  });
28
37
 
29
38
  test('ignores anything that is not that shell’s artifact', () => {
30
- expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk.zip')).toBeNull();
39
+ expect(versionFromAssetName('android', 'Kabmo-1.2.3-android.apk.zip')).toBeNull();
31
40
  expect(versionFromAssetName('android', 'source.tar.gz')).toBeNull();
32
- expect(versionFromAssetName('android', 'kabmo-android-latest.apk')).toBeNull();
41
+ expect(versionFromAssetName('android', 'Kabmo-latest-android.apk')).toBeNull();
42
+ expect(versionFromAssetName('android', 'Kabmo--android.apk')).toBeNull();
33
43
  expect(versionFromAssetName('android', '')).toBeNull();
34
44
  });
35
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
+
36
65
  test('does not accept one shell’s asset for the other', () => {
37
66
  // The two probes read the SAME release list, so a parser that ignored the
38
67
  // kind would let an iOS probe answer with the APK (and offer an `.apk` to an
39
68
  // iPhone).
69
+ expect(versionFromAssetName('ios', 'Kabmo-0.5.0-android.apk')).toBeNull();
70
+ expect(versionFromAssetName('android', 'Kabmo-0.1.0-ios.ipa')).toBeNull();
40
71
  expect(versionFromAssetName('ios', 'kabmo-android-v1.2.3.apk')).toBeNull();
41
72
  expect(versionFromAssetName('android', 'kabmo-ios-v1.2.3.ipa')).toBeNull();
42
73
  });
@@ -67,15 +98,15 @@ describe('fetchShellRelease', () => {
67
98
  assets: [
68
99
  { name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
69
100
  {
70
- name: 'kabmo-android-v1.2.3.apk',
71
- browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
101
+ name: 'Kabmo-1.2.3-android.apk',
102
+ browser_download_url: 'https://x/Kabmo-1.2.3-android.apk',
72
103
  },
73
104
  ],
74
105
  },
75
106
  ]);
76
107
 
77
108
  expect(await fetchShellRelease('android', impl)).toEqual({
78
- url: 'https://x/kabmo-android-v1.2.3.apk',
109
+ url: 'https://x/Kabmo-1.2.3-android.apk',
79
110
  latest: '1.2.3',
80
111
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
81
112
  });
@@ -86,8 +117,8 @@ describe('fetchShellRelease', () => {
86
117
  {
87
118
  html_url: 'https://x/app',
88
119
  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' },
120
+ { name: 'Kabmo-0.9.0-android.apk', browser_download_url: 'https://x/a.apk' },
121
+ { name: 'Kabmo-0.4.0-ios.ipa', browser_download_url: 'https://x/i.ipa' },
91
122
  ],
92
123
  },
93
124
  ]);
@@ -99,14 +130,54 @@ describe('fetchShellRelease', () => {
99
130
  });
100
131
  });
101
132
 
133
+ /**
134
+ * A release that carries BOTH spellings must answer with the current one.
135
+ *
136
+ * That state is real: a rename lands, and the previous release's asset stays
137
+ * beside the new one until `--clobber` replaces it (or a hand-upload adds
138
+ * one). Measured before the fix: a release holding both
139
+ * `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0` —
140
+ * the console offered an update to the version the user already had, and hid
141
+ * the newer one.
142
+ */
143
+ test('prefers the current scheme when a release carries both', async () => {
144
+ const { impl } = stubFetch([
145
+ {
146
+ html_url: 'https://x/r',
147
+ assets: [
148
+ { name: 'kabmo-android-v0.4.0.apk', browser_download_url: 'https://x/old.apk' },
149
+ { name: 'Kabmo-0.6.0-android.apk', browser_download_url: 'https://x/new.apk' },
150
+ ],
151
+ },
152
+ ]);
153
+
154
+ expect(await fetchShellRelease('android', impl)).toEqual({
155
+ url: 'https://x/new.apk',
156
+ latest: '0.6.0',
157
+ releaseUrl: 'https://x/r',
158
+ });
159
+ });
160
+
161
+ /** A release carrying ONLY the old spelling must keep answering. */
162
+ test('still resolves a release that only has the legacy spelling', async () => {
163
+ const { impl } = stubFetch([
164
+ {
165
+ html_url: 'https://x/r2',
166
+ assets: [{ name: 'kabmo-android-v0.4.0.apk', browser_download_url: 'https://x/old.apk' }],
167
+ },
168
+ ]);
169
+
170
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('0.4.0');
171
+ });
172
+
102
173
  test('the two shells cache separately', async () => {
103
174
  // One shared slot would make the second probe answer with the first one's
104
175
  // artifact — the release list is the same read, the asset is not.
105
176
  const release = {
106
177
  html_url: 'https://x/app',
107
178
  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' },
179
+ { name: 'Kabmo-1.0.0-android.apk', browser_download_url: 'https://x/a.apk' },
180
+ { name: 'Kabmo-2.0.0-ios.ipa', browser_download_url: 'https://x/i.ipa' },
110
181
  ],
111
182
  };
112
183
  expect((await fetchShellRelease('android', stubFetch([release]).impl))?.latest).toBe('1.0.0');
@@ -129,7 +200,7 @@ describe('fetchShellRelease', () => {
129
200
  { html_url: 'https://x/kabmo-ui', assets: [] },
130
201
  {
131
202
  html_url: 'https://x/app',
132
- 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' }],
133
204
  },
134
205
  ]);
135
206
 
@@ -145,16 +216,16 @@ describe('fetchShellRelease', () => {
145
216
  {
146
217
  draft: true,
147
218
  html_url: 'https://x/draft',
148
- assets: [{ name: 'kabmo-android-v9.9.9.apk', browser_download_url: 'https://x/draft.apk' }],
219
+ assets: [{ name: 'Kabmo-9.9.9-android.apk', browser_download_url: 'https://x/draft.apk' }],
149
220
  },
150
221
  {
151
222
  prerelease: true,
152
223
  html_url: 'https://x/pre',
153
- assets: [{ name: 'kabmo-android-v8.0.0.apk', browser_download_url: 'https://x/pre.apk' }],
224
+ assets: [{ name: 'Kabmo-8.0.0-android.apk', browser_download_url: 'https://x/pre.apk' }],
154
225
  },
155
226
  {
156
227
  html_url: 'https://x/app',
157
- assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
228
+ assets: [{ name: 'Kabmo-0.3.0-android.apk', browser_download_url: 'https://x/a.apk' }],
158
229
  },
159
230
  ]);
160
231
 
@@ -17,9 +17,10 @@
17
17
  *
18
18
  * - `url` is the asset to download. It is resolved from the release's OWN asset
19
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.
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.
23
24
  * - `latest` is the version that asset ships, read from the asset name. The
24
25
  * release TAG cannot be used: the artifact is attached to the app's release,
25
26
  * whose tag names the SERVER version, and the two move independently.
@@ -42,25 +43,67 @@ export interface ShellRelease {
42
43
  releaseUrl: string | null;
43
44
  }
44
45
 
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' },
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' },
49
78
  };
50
79
 
51
80
  /**
52
- * `kabmo-android-v1.2.3.apk` → `1.2.3` (and the `.ipa` twin).
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.
53
83
  *
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".
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.
57
87
  */
58
88
  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;
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;
64
107
  }
65
108
 
66
109
  /**
@@ -144,17 +187,41 @@ export async function fetchShellRelease(
144
187
  /** How many releases to look back through for the artifact. */
145
188
  const RELEASE_SCAN = 20;
146
189
 
147
- /** The first asset of a release that names this shell's artifact, or null. */
190
+ /**
191
+ * The asset of a release that names this shell's artifact, or null.
192
+ *
193
+ * The CURRENT scheme wins over the legacy one, and that is not cosmetic. A
194
+ * release that carries both — which happens the moment a rename lands and
195
+ * `--clobber` has not yet replaced the old asset, or a hand-upload adds one
196
+ * beside the other — would otherwise resolve to whichever the asset list
197
+ * happened to order first. Measured: a release holding both
198
+ * `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0`,
199
+ * i.e. the console offered an update to a version it already had and hid the
200
+ * 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
+ */
148
205
  function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
149
206
  if (!Array.isArray(assets)) return null;
150
207
 
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
- }
208
+ const named = assets.filter(
209
+ (entry): entry is Record<string, unknown> =>
210
+ entry !== null &&
211
+ typeof entry === 'object' &&
212
+ typeof (entry as Record<string, unknown>).name === 'string' &&
213
+ versionFromAssetName(kind, String((entry as Record<string, unknown>).name)) !== null,
214
+ );
156
215
 
157
- return null;
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
+ );
158
225
  }
159
226
 
160
227
  const CACHE_MS = 5 * 60_000;