@kabmo-labs/kabmo 0.0.1 → 0.2.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 (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. package/dist/client/static/js/index-x9cehh1a.css +0 -1
@@ -17,9 +17,6 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { collectIgnoredPaths } from '@/server/lib/fs/git-ignore';
19
19
 
20
- /** Directory names never listed: build output and VCS metadata. */
21
- const NOISE_DIRS: Record<string, true> = { node_modules: true, '.git': true, dist: true, build: true };
22
-
23
20
  export interface DirectoryEntry {
24
21
  id: string;
25
22
  name: string;
@@ -30,13 +27,30 @@ export interface DirectoryEntry {
30
27
  ignored: boolean;
31
28
  }
32
29
 
33
- /** Read one directory, folders first then files, each marked ignored or not. */
30
+ /**
31
+ * Read one directory, folders first then files, each marked ignored or not.
32
+ *
33
+ * EVERY entry is listed — `node_modules`, `.git`, `dist`, `build` and dot-files
34
+ * included. There is no name table: filtering by name cannot tell a dependency
35
+ * tree from a folder of release artifacts (`packages/android/dist` holds the
36
+ * built APKs), and it hid directories the user could not reach any other way.
37
+ * The panel's own signal for "this is untracked noise" is the `ignored` flag,
38
+ * which dims the row via `git check-ignore` instead of deleting it — and a tree
39
+ * that is expensive to expand costs nothing until someone expands it, because
40
+ * children are lazy.
41
+ */
34
42
  export async function listDirectoryEntries(dirPath: string, rootPath: string): Promise<DirectoryEntry[]> {
35
43
  const listed = (await fs.promises.readdir(dirPath))
36
- .filter((child) => !NOISE_DIRS[child])
37
44
  .map((child) => {
38
45
  const full = path.join(dirPath, child);
39
- return { child, full, rel: path.relative(rootPath, full) };
46
+ // POSIX spelling, because `rel` is not only a display value: it is the
47
+ // tree's id/key, the `fs:` topic's path, and the path handed to
48
+ // `git check-ignore` — which rejects a backslash outright, so on Windows
49
+ // every row came back un-dimmed and keyed `packages\android\dist`. The
50
+ // rest of the server states the same convention (`lib/fs/root.ts`,
51
+ // `lib/fs/transfer.ts`).
52
+ const rel = path.relative(rootPath, full).split(path.sep).join('/');
53
+ return { child, full, rel };
40
54
  });
41
55
 
42
56
  const ignored = await collectIgnoredPaths(rootPath, listed.map((entry) => entry.rel));
@@ -20,9 +20,6 @@
20
20
  * launcher is what brings the new files up.
21
21
  */
22
22
 
23
- import { existsSync } from 'node:fs';
24
- import { join as joinPath } from 'node:path';
25
-
26
23
  import { resolveBunBin } from '@/server/lib/lifecycle/bun';
27
24
  import { packageDir } from '@/server/lib/fs/package-root';
28
25
  import { listInstanceRecords } from '@/server/lib/lifecycle/instance';
@@ -30,6 +27,7 @@ import { isCliManaged, resolveLaunchMode, unmanagedReason } from '@/server/lib/l
30
27
  import { capitalizeFirstLetter } from '@/shared/lib/chat/capitalize';
31
28
  import { loadAuthConfig } from '@/server/lib/auth/guard';
32
29
  import { PASSWORD_HANDOVER_ENV, PASSWORD_HANDOVER_STDIN, serializeHandover } from '@/shared/lib/auth/handover';
30
+ import { copyAt, type InstalledCopy } from '@/server/lib/updates/installed-cli';
33
31
  import type { UpdateApplyResult } from '@/shared/types/updates';
34
32
 
35
33
  /**
@@ -87,11 +85,46 @@ function ownInstance(): { port: number; launchMode: string } | null {
87
85
  return { port, launchMode: resolveLaunchMode() };
88
86
  }
89
87
 
90
- /** `kabmo restart --port <port>` for the install this server runs from. */
91
- function restartCommand(port: number): string[] | null {
92
- const cli = joinPath(PKG_ROOT, 'src', 'cli', 'kabmo.js');
93
- if (!existsSync(cli)) return null;
94
- return [resolveBunBin(), cli, 'restart', '--port', String(port)];
88
+ /**
89
+ * The marker the TARGET copy reads its credential through.
90
+ *
91
+ * The rename renamed the variable too (`OMPCHAMBER_PASSWORD_HANDOVER` →
92
+ * `KABMO_PASSWORD_HANDOVER`), so a restart that set only one spelling would
93
+ * hand a payload to a CLI that never looks for it — and the replacement would
94
+ * come back with authentication silently disabled. Keyed by package name
95
+ * because that is the only fact about the target this process can read.
96
+ */
97
+ const HANDOVER_ENV_BY_PACKAGE: Record<string, string> = {
98
+ 'ompchamber': 'OMPCHAMBER_PASSWORD_HANDOVER',
99
+ '@kabmo-labs/kabmo': PASSWORD_HANDOVER_ENV,
100
+ };
101
+
102
+ export interface RestartInvocation {
103
+ /** argv for the detached helper: `<bun> <cli> restart --port <port>`. */
104
+ cmd: string[];
105
+ /** The variable that CLI reads its credential marker from. */
106
+ handoverEnv: string;
107
+ /** Package root the helper runs in. */
108
+ root: string;
109
+ }
110
+
111
+ /**
112
+ * How to restart the copy rooted at `root`.
113
+ *
114
+ * The CLI is that copy's OWN registered bin, resolved from its `package.json`,
115
+ * because the file name is not a constant across a rename: a renamed package
116
+ * ships only the bin it declares, so a hard-coded `kabmo.js` finds nothing in
117
+ * a copy that names it something else. Null when the root holds no runnable
118
+ * copy.
119
+ */
120
+ export function restartInvocation(port: number, root: string): RestartInvocation | null {
121
+ const copy = copyAt(root);
122
+ if (!copy) return null;
123
+ return {
124
+ cmd: [resolveBunBin(), copy.cli, 'restart', '--port', String(port)],
125
+ handoverEnv: HANDOVER_ENV_BY_PACKAGE[copy.packageName] ?? PASSWORD_HANDOVER_ENV,
126
+ root: copy.root,
127
+ };
95
128
  }
96
129
 
97
130
  /**
@@ -127,7 +160,7 @@ export function withRestartPlan(result: UpdateApplyResult, plan: SelfRestartPlan
127
160
  * helper is detached so this process exiting does not take it down, and it
128
161
  * removes this port's record itself (`stopInstance`) before serving.
129
162
  */
130
- export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): SelfRestartPlan {
163
+ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update', installed: InstalledCopy | null = null): SelfRestartPlan {
131
164
  const self = ownInstance();
132
165
  if (!self) {
133
166
  const nextStep = purpose === 'restart' ? 'restart it yourself' : 'restart it yourself to apply the update';
@@ -138,11 +171,16 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
138
171
  return { restarting: false, message: skipRestartNote(self.launchMode, purpose) };
139
172
  }
140
173
 
141
- const cmd = restartCommand(self.port);
142
- if (!cmd) {
174
+ // The copy that must come back is the one the update INSTALLED. Falling back
175
+ // to this process's own root keeps `Restart` (which installs nothing) and a
176
+ // caller that could not read the install working as before.
177
+ const root = installed?.root ?? PKG_ROOT;
178
+ const invocation = restartInvocation(self.port, root);
179
+ if (!invocation) {
143
180
  const nextStep = purpose === 'restart' ? 'restart it yourself' : 'restart it yourself to apply the update';
144
- return { restarting: false, message: `The kabmo CLI is missing from ${PKG_ROOT} — ${nextStep}.` };
181
+ return { restarting: false, message: `The CLI is missing from ${root} — ${nextStep}.` };
145
182
  }
183
+ const { cmd, handoverEnv } = invocation;
146
184
 
147
185
  // The replacement must come up with the SAME credential this process is
148
186
  // serving under. Without it, a restart silently disables authentication —
@@ -160,7 +198,7 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
160
198
  try {
161
199
  const child = Bun.spawn({
162
200
  cmd,
163
- cwd: PKG_ROOT,
201
+ cwd: root,
164
202
  stdin: credential ? 'pipe' : 'ignore',
165
203
  stdout: 'inherit',
166
204
  stderr: 'inherit',
@@ -169,7 +207,7 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
169
207
  // CLI forwards it to the server it spawns, which is the only place it
170
208
  // is ever read.
171
209
  env: credential
172
- ? { ...Bun.env, [PASSWORD_HANDOVER_ENV]: PASSWORD_HANDOVER_STDIN }
210
+ ? { ...Bun.env, [handoverEnv]: PASSWORD_HANDOVER_STDIN }
173
211
  : Bun.env,
174
212
  });
175
213
  if (credential && child.stdin) {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The one session-removal sequence, shared by the single DELETE route and the
8
+ * batch route.
9
+ *
10
+ * The ORDER is the contract and it is enforced here, not by the callers:
11
+ * 1. refuse a pending `new-…` chat and an id that is not one path segment —
12
+ * both name something other than the session they appear to;
13
+ * 2. refuse a session whose omp child is mid-turn (a run, a live subagent, or
14
+ * a dialog the agent is blocked on) — the delete would kill work the user
15
+ * can still see, and omp would flush the file back on shutdown anyway;
16
+ * 3. destroy the child and AWAIT its exit — omp writes session state on
17
+ * shutdown and would otherwise recreate the transcript just removed;
18
+ * 4. purge side questions, then the transcript, artifacts and Kabmo rows.
19
+ *
20
+ * A batch that re-implemented any of this would drift from the single delete,
21
+ * and the drift would be silent: the route answers per-id verdicts either way.
22
+ */
23
+
24
+ import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
25
+ import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
26
+ import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
27
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
28
+ import { isMockMode } from '@/server/mock.server';
29
+
30
+ export interface SessionRemoval {
31
+ /** True when the session was removed (or would have been, in mock mode). */
32
+ ok: boolean;
33
+ /** HTTP status the single-session route answers with; batch callers use `error`. */
34
+ status: number;
35
+ code?: 'session_pending' | 'invalid_id' | 'session_busy' | 'not_found';
36
+ error?: string;
37
+ fileRemoved?: boolean;
38
+ artifactsRemoved?: boolean;
39
+ btwTopicsRemoved?: number;
40
+ }
41
+
42
+ /**
43
+ * Remove one session, in the documented order. Never throws: every failure is
44
+ * a verdict, because a batch has to keep going after one refused id.
45
+ */
46
+ export async function removeSession(sessionId: string): Promise<SessionRemoval> {
47
+ if (sessionId.startsWith('new-')) {
48
+ return {
49
+ ok: false,
50
+ status: 400,
51
+ code: 'session_pending',
52
+ error: 'This chat has not been created yet.',
53
+ };
54
+ }
55
+ // The id names a directory under the sessions root and another under the btw
56
+ // root, so it must be exactly one path segment — see `isSinglePathSegment`
57
+ // for the `.` case that made this load-bearing.
58
+ if (!isSinglePathSegment(sessionId)) {
59
+ return { ok: false, status: 400, code: 'invalid_id', error: 'Invalid session id' };
60
+ }
61
+
62
+ try {
63
+ // A live child is the only other writer of the transcript. Busy means a
64
+ // turn, a live subagent, or a dialog omp is parked on.
65
+ const live = getRpcSession(sessionId);
66
+ if (live?.isAlive()) {
67
+ if (live.isBusy()) {
68
+ return {
69
+ ok: false,
70
+ status: 409,
71
+ code: 'session_busy',
72
+ error: 'This session is working. Stop the run before deleting it.',
73
+ };
74
+ }
75
+ await live.destroyAndWait();
76
+ }
77
+
78
+ // Side questions live outside the session file and are removed first: their
79
+ // children must be gone before the parent transcript they were snapshotted
80
+ // from disappears.
81
+ const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
82
+ const purged = await purgeSessionData(sessionId);
83
+ if (!purged.found) {
84
+ return { ok: false, status: 404, code: 'not_found', error: 'Session not found' };
85
+ }
86
+
87
+ return {
88
+ ok: true,
89
+ status: 200,
90
+ fileRemoved: purged.fileRemoved,
91
+ artifactsRemoved: purged.artifactsRemoved,
92
+ btwTopicsRemoved,
93
+ };
94
+ } catch (error) {
95
+ return {
96
+ ok: false,
97
+ status: 500,
98
+ error: error instanceof Error ? error.message : String(error),
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { beforeEach, describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ fetchAndroidRelease,
10
+ resetAndroidReleaseCache,
11
+ versionFromAssetName,
12
+ } from '@/server/lib/updates/android-release';
13
+
14
+ /**
15
+ * The asset name is the only place the APK's version exists: the release TAG
16
+ * names the SERVER version, and the two move independently. A parser that read
17
+ * the tag would compare a server version against an APK version.
18
+ */
19
+ describe('versionFromAssetName', () => {
20
+ test('reads the version the release workflow writes', () => {
21
+ expect(versionFromAssetName('kabmo-android-v1.2.3.apk')).toBe('1.2.3');
22
+ });
23
+
24
+ test('accepts a hand-uploaded name without the v', () => {
25
+ expect(versionFromAssetName('kabmo-android-1.2.3.apk')).toBe('1.2.3');
26
+ });
27
+
28
+ test('ignores anything that is not the APK', () => {
29
+ expect(versionFromAssetName('kabmo-android-v1.2.3.apk.zip')).toBeNull();
30
+ expect(versionFromAssetName('source.tar.gz')).toBeNull();
31
+ expect(versionFromAssetName('kabmo-android-latest.apk')).toBeNull();
32
+ expect(versionFromAssetName('')).toBeNull();
33
+ });
34
+ });
35
+
36
+ /** A fetch double that answers one body, and counts its calls. */
37
+ function stubFetch(body: unknown, ok = true) {
38
+ const calls: string[] = [];
39
+ const impl = (async (input: string | URL | Request) => {
40
+ calls.push(String(input));
41
+ return {
42
+ ok,
43
+ json: async () => body,
44
+ } as Response;
45
+ }) as unknown as typeof fetch;
46
+ return { impl, calls };
47
+ }
48
+
49
+ describe('fetchAndroidRelease', () => {
50
+ // The cache is process-wide (see `resetAndroidReleaseCache`), so every case
51
+ // starts from a cold one or it reads the previous case's answer.
52
+ beforeEach(() => resetAndroidReleaseCache());
53
+
54
+ test('picks the APK asset and its version', async () => {
55
+ const { impl } = stubFetch({
56
+ html_url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
57
+ assets: [
58
+ { name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
59
+ {
60
+ name: 'kabmo-android-v1.2.3.apk',
61
+ browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
62
+ },
63
+ ],
64
+ });
65
+
66
+ const release = await fetchAndroidRelease(impl);
67
+ expect(release).toEqual({
68
+ url: 'https://x/kabmo-android-v1.2.3.apk',
69
+ latest: '1.2.3',
70
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
71
+ });
72
+ });
73
+
74
+ test('answers null for a release with no APK', async () => {
75
+ const { impl } = stubFetch({ html_url: 'https://x', assets: [{ name: 'notes.md' }] });
76
+ const release = await fetchAndroidRelease(impl);
77
+ expect(release).toEqual({ url: null, latest: null, releaseUrl: 'https://x' });
78
+ });
79
+
80
+ test('answers null when the probe fails', async () => {
81
+ // An offline box or a rate-limited GitHub must not take the sidebar down,
82
+ // and a failure must not be read as "no newer version" either — it is
83
+ // simply no offer.
84
+ const { impl } = stubFetch({}, false);
85
+ expect(await fetchAndroidRelease(impl)).toBeNull();
86
+ });
87
+
88
+ test('answers null when the body is malformed', async () => {
89
+ const { impl } = stubFetch({ assets: 'nope' });
90
+ expect(await fetchAndroidRelease(impl)).toBeNull();
91
+ });
92
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Which Android APK a browser should download, and whether this one is behind.
3
+ *
4
+ * The APK is NOT an npm package (see `.github/workflows/publish-android.yml`), so
5
+ * the npm probe that answers for the app and for `omp` cannot answer here. The
6
+ * only record of a published APK is the GitHub release asset, which means one
7
+ * unauthenticated REST read of the newest release.
8
+ *
9
+ * Two facts travel back, and they answer different questions:
10
+ *
11
+ * - `url` is the asset to download. It is resolved from the release's OWN asset
12
+ * list rather than built as `releases/latest/download/<name>`, because that
13
+ * path needs the asset name to match exactly and the name carries the tag
14
+ * (`kabmo-android-v1.2.3.apk`) — a name built here would be a second copy of
15
+ * the workflow's naming rule, and the first release that renames it 404s.
16
+ * - `latest` is the version that asset ships, read from the asset name. The
17
+ * release TAG cannot be used: the APK is attached to the app's release, whose
18
+ * tag names the SERVER version, and the two move independently.
19
+ *
20
+ * Failure is a normal answer here (`null`), never a throw: an offline box or a
21
+ * rate-limited GitHub must not take the sidebar down, and a button that appears
22
+ * because a probe failed would offer a download that does not exist.
23
+ */
24
+
25
+ import { REPO } from '@/server/lib/updates/release-url';
26
+
27
+ /** One published APK, as the console needs it. */
28
+ export interface AndroidRelease {
29
+ /** Asset download URL, or null when the release carries no APK. */
30
+ url: string | null;
31
+ /** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
32
+ latest: string | null;
33
+ /** The release page, for a "what changed" link. */
34
+ releaseUrl: string | null;
35
+ }
36
+
37
+ /** The asset-name prefix the release workflow writes. */
38
+ const ASSET_PREFIX = 'kabmo-android-';
39
+
40
+ /**
41
+ * `kabmo-android-v1.2.3.apk` → `1.2.3`.
42
+ *
43
+ * The `v` is optional because a hand-uploaded asset may omit it, and a version
44
+ * that cannot be read is not evidence of an update — the caller treats null as
45
+ * "no new version".
46
+ */
47
+ export function versionFromAssetName(name: string): string | null {
48
+ if (!name.startsWith(ASSET_PREFIX) || !name.endsWith('.apk')) return null;
49
+ const middle = name.slice(ASSET_PREFIX.length, -'.apk'.length);
50
+ const version = middle.startsWith('v') ? middle.slice(1) : middle;
51
+ return /^\d+\.\d+\.\d+/.test(version) ? version : null;
52
+ }
53
+
54
+ /**
55
+ * The newest release's APK.
56
+ *
57
+ * One request, cached for five minutes on `globalThis` for the same reason the
58
+ * database handle is anchored there: `bun --hot` re-evaluates this module while
59
+ * the cache it would otherwise hold lives on. GitHub allows 60 unauthenticated
60
+ * reads an hour per address, and the sidebar asks on every mount.
61
+ */
62
+ export async function fetchAndroidRelease(fetchImpl: typeof fetch = fetch): Promise<AndroidRelease | null> {
63
+ const cache = cacheSlot();
64
+ const now = Date.now();
65
+ if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
66
+
67
+ try {
68
+ const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases/latest`, {
69
+ headers: {
70
+ accept: 'application/vnd.github+json',
71
+ // GitHub rejects a request with no User-Agent.
72
+ 'user-agent': 'kabmo-console',
73
+ },
74
+ });
75
+ if (!response.ok) return cache.entry ?? null;
76
+
77
+ const body = (await response.json()) as {
78
+ html_url?: unknown;
79
+ assets?: unknown;
80
+ };
81
+ // A body that is not the release document is a failed probe, not an empty
82
+ // release: answering `{url: null, latest: null}` here would be
83
+ // indistinguishable from "a release with no APK", and the caller would cache
84
+ // that mistake for five minutes.
85
+ if (!Array.isArray(body.assets)) return cache.entry ?? null;
86
+
87
+ const assets = body.assets;
88
+ const asset = assets
89
+ .map((entry) => (entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : null))
90
+ .find((entry) => entry && typeof entry.name === 'string' && versionFromAssetName(entry.name) !== null);
91
+
92
+ const name = typeof asset?.name === 'string' ? asset.name : '';
93
+ const url = typeof asset?.browser_download_url === 'string' ? asset.browser_download_url : null;
94
+ const release: AndroidRelease = {
95
+ url,
96
+ latest: versionFromAssetName(name),
97
+ releaseUrl: typeof body.html_url === 'string' ? body.html_url : null,
98
+ };
99
+
100
+ cache.entry = release;
101
+ cache.at = now;
102
+ return release;
103
+ } catch {
104
+ // Keep the last good answer rather than replacing it with a failure: a
105
+ // momentary outage must not remove a download button that was valid.
106
+ return cache.entry ?? null;
107
+ }
108
+ }
109
+
110
+ const CACHE_MS = 5 * 60_000;
111
+
112
+ /**
113
+ * Where the release read goes.
114
+ *
115
+ * Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
116
+ * test and a rehearsal need to exercise the real parsing against a stub instead
117
+ * of a live GitHub call, and hardcoding the host makes that impossible without
118
+ * intercepting `fetch` globally.
119
+ */
120
+ function githubApiBase(): string {
121
+ const configured = Bun.env.KABMO_GITHUB_API ?? '';
122
+ return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
123
+ }
124
+
125
+ interface CacheSlot {
126
+ entry: AndroidRelease | null;
127
+ at: number;
128
+ }
129
+
130
+ function cacheSlot(): CacheSlot {
131
+ const host = globalThis as typeof globalThis & { __kabmoAndroidRelease?: CacheSlot };
132
+ host.__kabmoAndroidRelease ??= { entry: null, at: 0 };
133
+ return host.__kabmoAndroidRelease;
134
+ }
135
+
136
+ /**
137
+ * Drop the cached answer.
138
+ *
139
+ * The cache lives on `globalThis` so `bun --hot` cannot orphan it, which also
140
+ * means it outlives a test file: without this seam the second case in a suite
141
+ * reads the first case's answer and passes for the wrong reason (measured — the
142
+ * "malformed body" case returned the previous test's release). Production has no
143
+ * caller; it exists because the cache is process-wide by design.
144
+ */
145
+ export function resetAndroidReleaseCache(): void {
146
+ cacheSlot().entry = null;
147
+ cacheSlot().at = 0;
148
+ }
@@ -12,6 +12,7 @@ import { updateKabmo } from '@/server/lib/updates/install';
12
12
  import { applyOmpUpdate } from '@/server/lib/updates/omp';
13
13
  import { withUpdateSlot } from '@/server/lib/updates/single-flight';
14
14
  import { recycleStandbyProcesses } from '@/server/lib/omp/session/standby.server';
15
+ import type { InstalledCopy } from '@/server/lib/updates/installed-cli';
15
16
  import type { UpdateApplyResult, UpdateTarget } from '@/shared/types/updates';
16
17
 
17
18
  /** A stage label or a line of command output, as the update produces it. */
@@ -27,6 +28,12 @@ export interface UpdateProgressHooks {
27
28
  export interface AppliedUpdate {
28
29
  result: UpdateApplyResult;
29
30
  updated: boolean;
31
+ /**
32
+ * The copy this run left on disk, when it knows. The restart that follows
33
+ * launches THIS one — the running process's own directory is the build the
34
+ * update just replaced, which is how a rename came back as the old product.
35
+ */
36
+ installed?: InstalledCopy | null;
30
37
  }
31
38
 
32
39
  /**
@@ -74,6 +81,7 @@ export async function applyUpdate(target: UpdateTarget, hooks: UpdateProgressHoo
74
81
  output: result.output || undefined,
75
82
  },
76
83
  updated: result.updated,
84
+ installed: result.installed,
77
85
  };
78
86
  }
79
87
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Running one update step, and reading a child's output as it arrives.
8
+ *
9
+ * Split out of `./install` because the parent file sits at the repository's
10
+ * per-file ceiling and this half is a self-contained child-process concern: a
11
+ * synchronous probe used for git facts, one step runner, and the streaming
12
+ * drain both the Kabmo and the oh-my-pi update paths share.
13
+ */
14
+
15
+ /** A client rebuild is slow, but a hung child is worse. */
16
+ export const STEP_TIMEOUT_MS = 600_000;
17
+ /** Cap on the captured output returned to the console. */
18
+ export const OUTPUT_LIMIT = 20_000;
19
+
20
+ export interface UpdateStep {
21
+ label: string;
22
+ cmd: string[];
23
+ cwd: string;
24
+ }
25
+
26
+ export function runSync(cmd: string[], cwd?: string): { ok: boolean; out: string } {
27
+ try {
28
+ const proc = Bun.spawnSync({
29
+ cmd,
30
+ cwd,
31
+ stdin: 'ignore',
32
+ stdout: 'pipe',
33
+ stderr: 'pipe',
34
+ timeout: 15_000,
35
+ });
36
+ return { ok: proc.exitCode === 0, out: proc.stdout.toString().trim() };
37
+ } catch {
38
+ return { ok: false, out: '' };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Drain a child's stdout/stderr, forwarding each chunk to `sink` as it arrives
44
+ * and returning the whole decoded text. Exported because the omp update path
45
+ * needs the same streaming read: buffering with `new Response(stream).text()`
46
+ * emits nothing until the process exits, which is exactly what makes an update
47
+ * look hung.
48
+ */
49
+ export async function pumpStream(stream: ReadableStream<Uint8Array>, sink?: (chunk: string) => void): Promise<string> {
50
+ const reader = stream.getReader();
51
+ const decoder = new TextDecoder();
52
+ let text = '';
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ const chunk = decoder.decode(value, { stream: true });
57
+ if (chunk.length === 0) continue;
58
+ text += chunk;
59
+ sink?.(chunk);
60
+ }
61
+ return text;
62
+ }
63
+
64
+ export async function runStep(step: UpdateStep, onLine?: (chunk: string) => void): Promise<{ ok: boolean; exitCode: number | null; output: string }> {
65
+ const proc = Bun.spawn({
66
+ cmd: step.cmd,
67
+ cwd: step.cwd,
68
+ stdin: 'ignore',
69
+ stdout: 'pipe',
70
+ stderr: 'pipe',
71
+ timeout: STEP_TIMEOUT_MS,
72
+ env: { ...Bun.env, PAGER: 'cat', FORCE_COLOR: '0' },
73
+ });
74
+ const [stdout, stderr] = await Promise.all([pumpStream(proc.stdout, onLine), pumpStream(proc.stderr, onLine)]);
75
+ const exitCode = await proc.exited;
76
+ return { ok: exitCode === 0, exitCode, output: `${stdout}${stderr}`.trim() };
77
+ }
78
+
79
+ export function tail(text: string): string {
80
+ return text.length > OUTPUT_LIMIT ? `…\n${text.slice(-OUTPUT_LIMIT)}` : text;
81
+ }