@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.
Files changed (61) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +323 -323
  4. package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
  5. package/dist/client/static/js/index-yn17t04b.css +1 -0
  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/components/auth/login-screen/index.test.tsx +93 -0
  11. package/src/client/components/auth/login-screen/index.tsx +43 -22
  12. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  13. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  15. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  16. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  17. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  18. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  19. package/src/client/components/common/batch-selection/types.ts +42 -0
  20. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  21. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  22. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  23. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  24. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  25. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  26. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  27. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  28. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  29. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  30. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  31. package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
  32. package/src/client/components/layout/session-sidebar/index.tsx +87 -0
  33. package/src/client/components/mobile/SessionSidebar.tsx +78 -0
  34. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  35. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  37. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
  39. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  40. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  41. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  42. package/src/client/tailwind.css +6 -0
  43. package/src/server/lib/lifecycle/restart.ts +52 -14
  44. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  45. package/src/server/lib/updates/apply.ts +8 -0
  46. package/src/server/lib/updates/install-steps.ts +81 -0
  47. package/src/server/lib/updates/install.ts +19 -67
  48. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  49. package/src/server/lib/updates/installed-cli.ts +139 -0
  50. package/src/server/lib/updates/omp.ts +1 -1
  51. package/src/server/routes/folders/batch-delete.ts +68 -0
  52. package/src/server/routes/folders/index.ts +4 -0
  53. package/src/server/routes/sessions/batch-delete.test.ts +139 -0
  54. package/src/server/routes/sessions/batch-delete.ts +60 -0
  55. package/src/server/routes/sessions/delete.ts +13 -51
  56. package/src/server/routes/sessions/index.ts +5 -0
  57. package/src/server/routes/updates/apply.ts +1 -1
  58. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  59. package/src/shared/lib/workspace/batch-select.ts +86 -0
  60. package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
  61. 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
- function runSync(cmd: string[], cwd?: string): { ok: boolean; out: string } {
103
- try {
104
- const proc = Bun.spawnSync({
105
- cmd,
106
- cwd,
107
- stdin: 'ignore',
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'),