@kabmo-labs/kabmo 0.0.1 → 0.1.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 +323 -323
- package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
- package/dist/client/static/js/index-yn17t04b.css +1 -0
- 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/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/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/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 +28 -1
- package/src/client/components/layout/session-sidebar/index.tsx +87 -0
- package/src/client/components/mobile/SessionSidebar.tsx +78 -0
- 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 +2 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
- 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/tailwind.css +6 -0
- 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/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/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/sessions/batch-delete.test.ts +139 -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/apply.ts +1 -1
- 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/sidebar-toolbar.ts +6 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -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
|
+
}
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
type InstallMethodInfo,
|
|
28
28
|
} from '@/server/lib/updates/install-method';
|
|
29
29
|
import { isNewer } from '@/shared/lib/updates/semver';
|
|
30
|
+
import { copyAt, resolveInstalledCopy, type InstalledCopy } from '@/server/lib/updates/installed-cli';
|
|
31
|
+
import { runStep, runSync, tail, type UpdateStep } from '@/server/lib/updates/install-steps';
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Package root of the running copy — the directory holding its `package.json`.
|
|
@@ -37,10 +39,6 @@ import { isNewer } from '@/shared/lib/updates/semver';
|
|
|
37
39
|
* checkout. `packageDir()` finds the real one in both.
|
|
38
40
|
*/
|
|
39
41
|
const DEFAULT_PKG_ROOT = packageDir();
|
|
40
|
-
/** A client rebuild is slow, but a hung child is worse. */
|
|
41
|
-
const STEP_TIMEOUT_MS = 600_000;
|
|
42
|
-
/** Cap on the captured output returned to the console. */
|
|
43
|
-
const OUTPUT_LIMIT = 20_000;
|
|
44
42
|
|
|
45
43
|
export interface KabmoVersionInfo {
|
|
46
44
|
/** Version on disk right now (`package.json`), null when unreadable. */
|
|
@@ -62,12 +60,6 @@ export interface InstallContext extends InstallMethodInfo {
|
|
|
62
60
|
bunBin: string;
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
export interface UpdateStep {
|
|
66
|
-
label: string;
|
|
67
|
-
cmd: string[];
|
|
68
|
-
cwd: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
63
|
export interface UpdateOptions {
|
|
72
64
|
/** Reinstall even when the installed version is already the latest. */
|
|
73
65
|
force?: boolean;
|
|
@@ -97,22 +89,14 @@ export interface KabmoUpdateResult {
|
|
|
97
89
|
releaseUrl: string | null;
|
|
98
90
|
/** Captured command output (tail), for non-streaming callers. */
|
|
99
91
|
output: string;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
stdout: 'pipe',
|
|
109
|
-
stderr: 'pipe',
|
|
110
|
-
timeout: 15_000,
|
|
111
|
-
});
|
|
112
|
-
return { ok: proc.exitCode === 0, out: proc.stdout.toString().trim() };
|
|
113
|
-
} catch {
|
|
114
|
-
return { ok: false, out: '' };
|
|
115
|
-
}
|
|
92
|
+
/**
|
|
93
|
+
* The copy that now sits on disk — what a restart must launch.
|
|
94
|
+
*
|
|
95
|
+
* Not the copy that is RUNNING: a rename installs a different package
|
|
96
|
+
* beside it, so respawning from the running directory relaunches the build
|
|
97
|
+
* the update just replaced (measured on the 4.23.1 → 0.0.1 move).
|
|
98
|
+
*/
|
|
99
|
+
installed: InstalledCopy | null;
|
|
116
100
|
}
|
|
117
101
|
|
|
118
102
|
/** Version installed on disk — not the one the running process imported. */
|
|
@@ -199,47 +183,6 @@ export function planUpdateSteps({ method, version, pkgRoot, bunBin, packageName
|
|
|
199
183
|
];
|
|
200
184
|
}
|
|
201
185
|
|
|
202
|
-
/**
|
|
203
|
-
* Drain a child's stdout/stderr, forwarding each chunk to `sink` as it arrives
|
|
204
|
-
* and returning the whole decoded text. Exported because the omp update path
|
|
205
|
-
* needs the same streaming read: buffering with `new Response(stream).text()`
|
|
206
|
-
* emits nothing until the process exits, which is exactly what makes an update
|
|
207
|
-
* look hung.
|
|
208
|
-
*/
|
|
209
|
-
export async function pumpStream(stream: ReadableStream<Uint8Array>, sink?: (chunk: string) => void): Promise<string> {
|
|
210
|
-
const reader = stream.getReader();
|
|
211
|
-
const decoder = new TextDecoder();
|
|
212
|
-
let text = '';
|
|
213
|
-
for (;;) {
|
|
214
|
-
const { done, value } = await reader.read();
|
|
215
|
-
if (done) break;
|
|
216
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
217
|
-
if (chunk.length === 0) continue;
|
|
218
|
-
text += chunk;
|
|
219
|
-
sink?.(chunk);
|
|
220
|
-
}
|
|
221
|
-
return text;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function runStep(step: UpdateStep, onLine?: (chunk: string) => void): Promise<{ ok: boolean; exitCode: number | null; output: string }> {
|
|
225
|
-
const proc = Bun.spawn({
|
|
226
|
-
cmd: step.cmd,
|
|
227
|
-
cwd: step.cwd,
|
|
228
|
-
stdin: 'ignore',
|
|
229
|
-
stdout: 'pipe',
|
|
230
|
-
stderr: 'pipe',
|
|
231
|
-
timeout: STEP_TIMEOUT_MS,
|
|
232
|
-
env: { ...Bun.env, PAGER: 'cat', FORCE_COLOR: '0' },
|
|
233
|
-
});
|
|
234
|
-
const [stdout, stderr] = await Promise.all([pumpStream(proc.stdout, onLine), pumpStream(proc.stderr, onLine)]);
|
|
235
|
-
const exitCode = await proc.exited;
|
|
236
|
-
return { ok: exitCode === 0, exitCode, output: `${stdout}${stderr}`.trim() };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function tail(text: string): string {
|
|
240
|
-
return text.length > OUTPUT_LIMIT ? `…\n${text.slice(-OUTPUT_LIMIT)}` : text;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
186
|
/**
|
|
244
187
|
* Bring this install to the newest release. Never throws: every failure mode
|
|
245
188
|
* (no release, unsupported install, dirty work tree, failing step) comes back
|
|
@@ -260,6 +203,7 @@ export async function updateKabmo({ force = false, pkgRoot = DEFAULT_PKG_ROOT, o
|
|
|
260
203
|
version: null,
|
|
261
204
|
releaseUrl: null,
|
|
262
205
|
output: '',
|
|
206
|
+
installed: null,
|
|
263
207
|
} satisfies Omit<KabmoUpdateResult, 'success' | 'message'>;
|
|
264
208
|
const fail = (message: string, patch: Partial<KabmoUpdateResult> = {}): KabmoUpdateResult => ({
|
|
265
209
|
success: false,
|
|
@@ -336,10 +280,18 @@ export async function updateKabmo({ force = false, pkgRoot = DEFAULT_PKG_ROOT, o
|
|
|
336
280
|
}
|
|
337
281
|
}
|
|
338
282
|
|
|
283
|
+
// Read back AFTER the steps: a global install puts the package where the
|
|
284
|
+
// installer decided, and a checkout IS the package. A null here leaves the
|
|
285
|
+
// restart on this process's own copy, which is the honest fallback.
|
|
286
|
+
const installed = context.method === 'git'
|
|
287
|
+
? copyAt(root)
|
|
288
|
+
: resolveInstalledCopy(info.package ?? KABMO_PACKAGE, context.bunBin) ?? copyAt(root);
|
|
289
|
+
|
|
339
290
|
return {
|
|
340
291
|
success: true,
|
|
341
292
|
...base,
|
|
342
293
|
...resolved,
|
|
294
|
+
installed,
|
|
343
295
|
updated: true,
|
|
344
296
|
version: info.latest,
|
|
345
297
|
message: info.renamed
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The copy an update INSTALLED, and how a restart reaches it.
|
|
8
|
+
*
|
|
9
|
+
* The bug these pin is a rename, not a version bump: `bun add -g
|
|
10
|
+
* @kabmo-labs/kabmo` installs a second package beside `ompchamber`, so a
|
|
11
|
+
* restart resolved from the RUNNING copy relaunched the build the update just
|
|
12
|
+
* replaced — measured on the 4.23.1 → 0.0.1 move, where the log showed
|
|
13
|
+
* `.../node_modules/ompchamber/src/cli/ompchamber.js restart --port 3001` and
|
|
14
|
+
* the console came back as the old version.
|
|
15
|
+
*
|
|
16
|
+
* Three facts are pinned here, each of which failed independently:
|
|
17
|
+
*
|
|
18
|
+
* 1. The CLI file comes from the package's own `bin` map, so a copy that names
|
|
19
|
+
* its bin something other than `kabmo.js` still resolves.
|
|
20
|
+
* 2. A copy is found through Bun's global layout, asked of the installer.
|
|
21
|
+
* 3. The credential marker follows the TARGET package's spelling, because the
|
|
22
|
+
* rename renamed the variable too.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, expect, test } from 'bun:test';
|
|
26
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { copyAt, globalNodeModules, nodeModulesForBinDir, resolveInstalledCopy } from '@/server/lib/updates/installed-cli';
|
|
31
|
+
import { restartInvocation, scheduleSelfRestart } from '@/server/lib/lifecycle/restart';
|
|
32
|
+
import { writeInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
33
|
+
import { executableStub } from '@/test-support/executable-stub';
|
|
34
|
+
|
|
35
|
+
function fixture(manifest: Record<string, unknown>, files: string[]): string {
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), 'installed-cli-'));
|
|
37
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify(manifest));
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
mkdirSync(join(root, file, '..'), { recursive: true });
|
|
40
|
+
writeFileSync(join(root, file), '#!/usr/bin/env bun\n');
|
|
41
|
+
}
|
|
42
|
+
return root;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('copyAt', () => {
|
|
46
|
+
test('resolves the current package through its own bin', () => {
|
|
47
|
+
const root = fixture(
|
|
48
|
+
{ name: '@kabmo-labs/kabmo', bin: { kabmo: './src/cli/kabmo.js' } },
|
|
49
|
+
['src/cli/kabmo.js'],
|
|
50
|
+
);
|
|
51
|
+
try {
|
|
52
|
+
const copy = copyAt(root);
|
|
53
|
+
expect(copy?.packageName).toBe('@kabmo-labs/kabmo');
|
|
54
|
+
expect(copy?.bin).toBe('kabmo');
|
|
55
|
+
// The file name is the package's own, never a guess.
|
|
56
|
+
expect(copy?.cli.endsWith(join('src', 'cli', 'kabmo.js'))).toBe(true);
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the bridge release registers both names, and its own one wins', () => {
|
|
63
|
+
// `ompchamber@4.23.x` ships both bins; resolving to whichever key came
|
|
64
|
+
// first would restart a renamed install through the legacy spelling.
|
|
65
|
+
const root = fixture(
|
|
66
|
+
{ name: 'ompchamber', bin: { kabmo: './src/cli/kabmo.js', ompchamber: './src/cli/ompchamber.js' } },
|
|
67
|
+
['src/cli/kabmo.js', 'src/cli/ompchamber.js'],
|
|
68
|
+
);
|
|
69
|
+
try {
|
|
70
|
+
expect(copyAt(root)?.bin).toBe('ompchamber');
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(root, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a string bin is registered under the unscoped package name', () => {
|
|
77
|
+
const root = fixture({ name: '@kabmo-labs/kabmo', bin: './bin/kabmo.js' }, ['bin/kabmo.js']);
|
|
78
|
+
try {
|
|
79
|
+
expect(copyAt(root)).toMatchObject({ bin: 'kabmo', packageName: '@kabmo-labs/kabmo' });
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a declared bin that is not on disk is not a copy', () => {
|
|
86
|
+
const root = fixture({ name: '@kabmo-labs/kabmo', bin: { kabmo: './src/cli/kabmo.js' } }, []);
|
|
87
|
+
try {
|
|
88
|
+
expect(copyAt(root)).toBeNull();
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(root, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a directory with no manifest is not a copy', () => {
|
|
95
|
+
const root = mkdtempSync(join(tmpdir(), 'installed-cli-empty-'));
|
|
96
|
+
try {
|
|
97
|
+
expect(copyAt(root)).toBeNull();
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('nodeModulesForBinDir', () => {
|
|
105
|
+
test('the packages sit one layout step from the bin directory', () => {
|
|
106
|
+
// `<BUN_INSTALL>/bin` → `<BUN_INSTALL>/install/global/node_modules`, the
|
|
107
|
+
// shape `detectInstallMethod` recognizes as a Bun global install. Built
|
|
108
|
+
// with `join`, not written POSIX: `node:path` emits the PLATFORM separator,
|
|
109
|
+
// so a literal expectation fails on Windows with the same path spelled
|
|
110
|
+
// `\home\u\...` (measured on the Windows CI leg).
|
|
111
|
+
const installRoot = join(tmpdir(), 'kabmo-bun-install');
|
|
112
|
+
expect(nodeModulesForBinDir(join(installRoot, 'bin')))
|
|
113
|
+
.toBe(join(installRoot, 'install', 'global', 'node_modules'));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('globalNodeModules', () => {
|
|
118
|
+
// A COMPILED stub, not a shell script: `Bun.spawn` cannot start a script on
|
|
119
|
+
// Windows (only `.com`/`.exe` are tried), and this suite runs there. The path
|
|
120
|
+
// is baked into the source rather than passed through the environment,
|
|
121
|
+
// because a child does not inherit a `Bun.env` assignment made in-process —
|
|
122
|
+
// and baking it keeps the source constant, so the compiled fixture is cached.
|
|
123
|
+
const binDir = join(tmpdir(), 'kabmo-fake-bun-bin');
|
|
124
|
+
const stubSource = `console.log(${JSON.stringify(binDir)});\n`;
|
|
125
|
+
|
|
126
|
+
test('asks the installer and maps its answer', async () => {
|
|
127
|
+
mkdirSync(binDir, { recursive: true });
|
|
128
|
+
const bun = await executableStub(stubSource, 'fake-bun-pm-bin');
|
|
129
|
+
try {
|
|
130
|
+
expect(globalNodeModules(bun)).toBe(nodeModulesForBinDir(binDir));
|
|
131
|
+
} finally {
|
|
132
|
+
rmSync(binDir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a package that is not installed resolves to null', async () => {
|
|
137
|
+
const bun = await executableStub('process.exit(1);\n', 'fake-bun-no-answer');
|
|
138
|
+
expect(resolveInstalledCopy('@kabmo-labs/definitely-absent', bun)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('restartInvocation', () => {
|
|
143
|
+
test('runs the installed copy and names its credential marker', () => {
|
|
144
|
+
const root = fixture(
|
|
145
|
+
{ name: '@kabmo-labs/kabmo', bin: { kabmo: './src/cli/kabmo.js' } },
|
|
146
|
+
['src/cli/kabmo.js'],
|
|
147
|
+
);
|
|
148
|
+
try {
|
|
149
|
+
const invocation = restartInvocation(3001, root);
|
|
150
|
+
expect(invocation?.cmd.slice(-3)).toEqual(['restart', '--port', '3001']);
|
|
151
|
+
expect(invocation?.cmd[1].endsWith(join('src', 'cli', 'kabmo.js'))).toBe(true);
|
|
152
|
+
// The renamed build reads KABMO_PASSWORD_HANDOVER; setting only the old
|
|
153
|
+
// spelling would hand it a payload it never looks for.
|
|
154
|
+
expect(invocation?.handoverEnv).toBe('KABMO_PASSWORD_HANDOVER');
|
|
155
|
+
expect(invocation?.root).toBe(root);
|
|
156
|
+
} finally {
|
|
157
|
+
rmSync(root, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a pre-rename copy keeps the old marker', () => {
|
|
162
|
+
const root = fixture(
|
|
163
|
+
{ name: 'ompchamber', bin: { ompchamber: './src/cli/ompchamber.js' } },
|
|
164
|
+
['src/cli/ompchamber.js'],
|
|
165
|
+
);
|
|
166
|
+
try {
|
|
167
|
+
expect(restartInvocation(3001, root)?.handoverEnv).toBe('OMPCHAMBER_PASSWORD_HANDOVER');
|
|
168
|
+
} finally {
|
|
169
|
+
rmSync(root, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a root with no runnable copy yields no invocation', () => {
|
|
174
|
+
const root = mkdtempSync(join(tmpdir(), 'installed-cli-empty-'));
|
|
175
|
+
try {
|
|
176
|
+
expect(restartInvocation(3001, root)).toBeNull();
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('scheduleSelfRestart target', () => {
|
|
184
|
+
test('consults the INSTALLED copy, not the one this process runs from', () => {
|
|
185
|
+
// A rename installs a second package beside the running one, so restarting
|
|
186
|
+
// from this process's own root relaunches the build the update replaced.
|
|
187
|
+
// The refusal names the root it consulted, which is how this is observable
|
|
188
|
+
// without arming a spawn.
|
|
189
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'restart-target-'));
|
|
190
|
+
const previous = Bun.env.KABMO_DATA_DIR;
|
|
191
|
+
Bun.env.KABMO_DATA_DIR = dataDir;
|
|
192
|
+
writeInstanceRecord({
|
|
193
|
+
pid: process.pid,
|
|
194
|
+
port: 4204,
|
|
195
|
+
host: 'localhost',
|
|
196
|
+
mode: 'prod',
|
|
197
|
+
launchMode: 'daemon',
|
|
198
|
+
tray: false,
|
|
199
|
+
tls: false,
|
|
200
|
+
auth: false,
|
|
201
|
+
startedAt: '2026-10-01T10:00:00.000Z',
|
|
202
|
+
version: '0.0.1',
|
|
203
|
+
});
|
|
204
|
+
// A root with no runnable copy: the plan refuses and says WHICH root.
|
|
205
|
+
const installedRoot = mkdtempSync(join(tmpdir(), 'installed-copy-'));
|
|
206
|
+
try {
|
|
207
|
+
const plan = scheduleSelfRestart('update', {
|
|
208
|
+
root: installedRoot,
|
|
209
|
+
packageName: '@kabmo-labs/kabmo',
|
|
210
|
+
bin: 'kabmo',
|
|
211
|
+
cli: join(installedRoot, 'src', 'cli', 'kabmo.js'),
|
|
212
|
+
});
|
|
213
|
+
expect(plan.restarting).toBe(false);
|
|
214
|
+
expect(plan.message).toContain(installedRoot);
|
|
215
|
+
} finally {
|
|
216
|
+
if (previous === undefined) delete Bun.env.KABMO_DATA_DIR;
|
|
217
|
+
else Bun.env.KABMO_DATA_DIR = previous;
|
|
218
|
+
rmSync(installedRoot, { recursive: true, force: true });
|
|
219
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where an installed copy of the product lives on disk, and which CLI file
|
|
8
|
+
* restarts it.
|
|
9
|
+
*
|
|
10
|
+
* The restart after an update must launch the copy the update INSTALLED, not
|
|
11
|
+
* the one that is running. That distinction did not exist until the rename:
|
|
12
|
+
* `bun add -g @kabmo-labs/kabmo` installs a DIFFERENT package beside
|
|
13
|
+
* `ompchamber`, so a server that resolved the restart from its own directory
|
|
14
|
+
* kept respawning the pre-rename build — measured on the 4.23.1 → 0.0.1 move,
|
|
15
|
+
* where the log read `.../node_modules/ompchamber/src/cli/ompchamber.js restart
|
|
16
|
+
* --port 3001` and the console came back as 4.23.1 while the update reported
|
|
17
|
+
* success. The same mistake returns in the other direction for a later rename,
|
|
18
|
+
* which is why the rule lives here rather than in a comment.
|
|
19
|
+
*
|
|
20
|
+
* The CLI file comes from the package's own `bin` map, never from a guessed
|
|
21
|
+
* filename: a package ships whatever it registers, so a hard-coded
|
|
22
|
+
* `kabmo.js` finds nothing in a copy that names its bin something else.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { resolveBunBin } from '@/server/lib/lifecycle/bun';
|
|
30
|
+
|
|
31
|
+
export interface InstalledCopy {
|
|
32
|
+
/** Package root: the directory holding this copy's `package.json` and `src/`. */
|
|
33
|
+
root: string;
|
|
34
|
+
/** `name` from that `package.json` (`ompchamber` before the rename, `@kabmo-labs/kabmo` after). */
|
|
35
|
+
packageName: string;
|
|
36
|
+
/** The bin key this copy registers — the name a user types. */
|
|
37
|
+
bin: string;
|
|
38
|
+
/** Absolute path to that bin's script. */
|
|
39
|
+
cli: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Manifest {
|
|
43
|
+
name?: unknown;
|
|
44
|
+
bin?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readManifest(root: string): Manifest | null {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as Manifest;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `@kabmo-labs/kabmo` → `kabmo`: the name a package's own bin is registered under. */
|
|
56
|
+
function unscoped(name: string): string {
|
|
57
|
+
return name.slice(name.lastIndexOf('/') + 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function binEntries(manifest: Manifest): Array<[string, string]> {
|
|
61
|
+
const { bin } = manifest;
|
|
62
|
+
// A string `bin` is registered under the package's own (unscoped) name.
|
|
63
|
+
if (typeof bin === 'string' && bin.length > 0) {
|
|
64
|
+
const name = typeof manifest.name === 'string' ? unscoped(manifest.name) : '';
|
|
65
|
+
return name.length > 0 ? [[name, bin]] : [];
|
|
66
|
+
}
|
|
67
|
+
if (!bin || typeof bin !== 'object') return [];
|
|
68
|
+
return Object.entries(bin as Record<string, unknown>).flatMap(([key, value]) =>
|
|
69
|
+
typeof value === 'string' && value.length > 0 ? [[key, value] as [string, string]] : [],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The copy rooted at `root`, or null when no package (or no runnable bin) is
|
|
75
|
+
* there.
|
|
76
|
+
*
|
|
77
|
+
* The bin is preferred by NAME — `@kabmo-labs/kabmo` → `kabmo`, `ompchamber` →
|
|
78
|
+
* `ompchamber` — so a package that registers several scripts (the bridge
|
|
79
|
+
* release registers both) resolves to its own CLI instead of whichever key
|
|
80
|
+
* happens to come first.
|
|
81
|
+
*/
|
|
82
|
+
export function copyAt(root: string): InstalledCopy | null {
|
|
83
|
+
const resolved = resolve(root);
|
|
84
|
+
const manifest = readManifest(resolved);
|
|
85
|
+
if (!manifest) return null;
|
|
86
|
+
|
|
87
|
+
const packageName = typeof manifest.name === 'string' ? manifest.name : '';
|
|
88
|
+
const entries = binEntries(manifest);
|
|
89
|
+
const own = unscoped(packageName);
|
|
90
|
+
const ordered = [...entries.filter(([bin]) => bin === own), ...entries.filter(([bin]) => bin !== own)];
|
|
91
|
+
|
|
92
|
+
for (const [bin, script] of ordered) {
|
|
93
|
+
const cli = resolve(resolved, script);
|
|
94
|
+
if (existsSync(cli)) return { root: resolved, packageName, bin, cli };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The package directory behind a Bun bin directory.
|
|
101
|
+
*
|
|
102
|
+
* `bun pm bin -g` answers `<BUN_INSTALL>/bin`, and the packages sit one layout
|
|
103
|
+
* step away — the same `install/global/node_modules` shape
|
|
104
|
+
* `detectInstallMethod` matches. Named rather than inlined because the step is
|
|
105
|
+
* the one fact this module exists to get right.
|
|
106
|
+
*/
|
|
107
|
+
export function nodeModulesForBinDir(binDir: string): string {
|
|
108
|
+
return join(dirname(binDir), 'install', 'global', 'node_modules');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Bun's global `node_modules`, asked from the installer itself.
|
|
113
|
+
*
|
|
114
|
+
* `BUN_INSTALL` is the fallback for a Bun too old to answer `bun pm bin -g`; a
|
|
115
|
+
* host with neither still resolves the documented default, so the caller's own
|
|
116
|
+
* existence check decides.
|
|
117
|
+
*/
|
|
118
|
+
export function globalNodeModules(bunBin: string = resolveBunBin()): string {
|
|
119
|
+
try {
|
|
120
|
+
const probe = Bun.spawnSync({
|
|
121
|
+
cmd: [bunBin, 'pm', 'bin', '-g'],
|
|
122
|
+
stdin: 'ignore',
|
|
123
|
+
stdout: 'pipe',
|
|
124
|
+
stderr: 'ignore',
|
|
125
|
+
timeout: 15_000,
|
|
126
|
+
});
|
|
127
|
+
const binDir = probe.exitCode === 0 ? probe.stdout.toString().trim() : '';
|
|
128
|
+
if (binDir.length > 0) return nodeModulesForBinDir(binDir);
|
|
129
|
+
} catch {
|
|
130
|
+
// Fall through to the environment, then to the documented default.
|
|
131
|
+
}
|
|
132
|
+
const installRoot = (Bun.env.BUN_INSTALL ?? '').trim();
|
|
133
|
+
return join(installRoot.length > 0 ? resolve(installRoot) : join(homedir(), '.bun'), 'install', 'global', 'node_modules');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The global copy of `packageName`, or null when it is not installed. */
|
|
137
|
+
export function resolveInstalledCopy(packageName: string, bunBin: string = resolveBunBin()): InstalledCopy | null {
|
|
138
|
+
return copyAt(join(globalNodeModules(bunBin), packageName));
|
|
139
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { invalidateOmpCliCache, resolveOmpBin } from '@/server/lib/omp/core/cli';
|
|
18
18
|
import { fetchNpmLatest, OMP_PACKAGE } from '@/server/lib/updates/npm';
|
|
19
|
-
import { pumpStream } from '@/server/lib/updates/install';
|
|
19
|
+
import { pumpStream } from '@/server/lib/updates/install-steps';
|
|
20
20
|
import { isNewer } from '@/shared/lib/updates/semver';
|
|
21
21
|
import type { UpdateTargetInfo } from '@/shared/types/updates';
|
|
22
22
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/folders/batch-delete — unregister several workspaces in one request.
|
|
8
|
+
*
|
|
9
|
+
* Same semantics as `POST /api/folders/:folderId/delete`, which is what the
|
|
10
|
+
* workspace's own `⋯` menu performs: the sidebar registration goes, and a bound
|
|
11
|
+
* folder is tombstoned so the omp-project sync cannot re-add it on the next
|
|
12
|
+
* boot. Transcript files are NOT touched — removing a workspace has never meant
|
|
13
|
+
* removing the sessions on disk, and the sessions are separately selectable in
|
|
14
|
+
* the sidebar's session mode when that IS what the user wants.
|
|
15
|
+
*
|
|
16
|
+
* One request rather than N because a batch delete is one user action, and the
|
|
17
|
+
* confirmation dialog should report one summary instead of N answers.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { json } from '@/server/lib/remix-compat';
|
|
21
|
+
import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
|
|
22
|
+
import { methodNotAllowed, parseJsonBody } from '@/server/lib/route-adapter';
|
|
23
|
+
import { getDb } from '@/server/db.server';
|
|
24
|
+
import { deleteWorkspaceFolder } from '@/shared/lib/workspace/project-settings';
|
|
25
|
+
import { syncDiscoveryRootsWatch } from '@/server/lib/omp/config/roots-watch.server';
|
|
26
|
+
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
27
|
+
|
|
28
|
+
const MAX_BATCH = 200;
|
|
29
|
+
|
|
30
|
+
export async function batchDeleteFolders({ request }: ActionFunctionArgs) {
|
|
31
|
+
if (request.method !== 'POST') {
|
|
32
|
+
return methodNotAllowed({ request, params: {} });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parsed = await parseJsonBody<{ ids?: unknown }>(request);
|
|
36
|
+
if (!parsed.ok) return json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
37
|
+
|
|
38
|
+
const raw = parsed.body.ids;
|
|
39
|
+
if (!Array.isArray(raw) || raw.some((id) => typeof id !== 'string' || id.length === 0)) {
|
|
40
|
+
return json({ error: 'ids must be an array of workspace ids' }, { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
const ids = [...new Set(raw as string[])];
|
|
43
|
+
if (ids.length === 0) return json({ error: 'No workspaces selected' }, { status: 400 });
|
|
44
|
+
if (ids.length > MAX_BATCH) {
|
|
45
|
+
return json({ error: `At most ${MAX_BATCH} workspaces can be deleted at once.` }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const db = await getDb();
|
|
49
|
+
const removed: string[] = [];
|
|
50
|
+
const refused: { id: string; error: string; code?: string }[] = [];
|
|
51
|
+
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
// `deleteWorkspaceFolder` answers false for an id that is not a folder, and
|
|
54
|
+
// takes the id as a bound parameter — there is no path built from it here.
|
|
55
|
+
const deleted = await deleteWorkspaceFolder(db, id);
|
|
56
|
+
if (deleted) removed.push(id);
|
|
57
|
+
else refused.push({ id, error: 'Workspace not found', code: 'not_found' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One watcher reconcile and one republish for the whole batch: the removed
|
|
61
|
+
// folders' `.omp` roots leave scope together, and the sidebar re-reads once.
|
|
62
|
+
if (removed.length > 0) {
|
|
63
|
+
void syncDiscoveryRootsWatch();
|
|
64
|
+
emitRealtimeSignal('sidebar-structure');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return json({ success: refused.length === 0, removed, refused });
|
|
68
|
+
}
|
|
@@ -9,9 +9,13 @@ import {
|
|
|
9
9
|
toggleFolder,
|
|
10
10
|
updateFolderSettings,
|
|
11
11
|
} from '@/server/routes/folders/folder';
|
|
12
|
+
import { batchDeleteFolders } from '@/server/routes/folders/batch-delete';
|
|
12
13
|
|
|
13
14
|
export const foldersBindings: HandlerBinding[] = [
|
|
14
15
|
...bindingsFor(foldersCreate, '/api/folders'),
|
|
16
|
+
// Static segment, registered before `:folderId/*` for the same reason the
|
|
17
|
+
// sessions batch route is: an id route must not swallow it.
|
|
18
|
+
{ method: 'POST', path: '/api/folders/batch-delete', handler: batchDeleteFolders },
|
|
15
19
|
...actionBindings(foldersClone.action, '/api/folders/clone'),
|
|
16
20
|
...actionBindings(foldersMkdir.action, '/api/folders/mkdir'),
|
|
17
21
|
...actionBindings(pinFolder, '/api/folders/:folderId/pin'),
|