@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.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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:
|
|
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, [
|
|
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
|
+
}
|