@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
|
@@ -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
|
|
|
@@ -15,6 +15,7 @@ import { readSettingsBlob } from '@/shared/lib/settings/keys';
|
|
|
15
15
|
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
16
16
|
import { windowChromeColor } from '@/shared/lib/theme/chrome';
|
|
17
17
|
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
18
|
+
import { androidShellVersion } from '@/shared/lib/updates/shell-ua';
|
|
18
19
|
|
|
19
20
|
/** HTML-escape a string for interpolation into the failure page's `<pre>`. */
|
|
20
21
|
function escapeHtmlText(value: string): string {
|
|
@@ -153,7 +154,13 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
153
154
|
// downgrade, the retired `noir` alias) renders the default palette instead of
|
|
154
155
|
// an unstyled page.
|
|
155
156
|
const theme = resolveTheme(settingsBlob.theme);
|
|
156
|
-
const
|
|
157
|
+
const userAgent = request.headers.get('user-agent') ?? '';
|
|
158
|
+
const initialIsMobile = MOBILE_UA.test(userAgent);
|
|
159
|
+
// Which APK is asking, when the request came from the Android shell. Read
|
|
160
|
+
// HERE because the shell is rendered per request and the client's download
|
|
161
|
+
// button needs the answer before its first paint: a fetch after hydration
|
|
162
|
+
// would draw the button late, or draw it and then remove it.
|
|
163
|
+
const shellVersion = androidShellVersion(userAgent);
|
|
157
164
|
|
|
158
165
|
const rendered = await renderShell();
|
|
159
166
|
if (!rendered.ok) return shellUnavailableResponse(rendered.reason);
|
|
@@ -165,7 +172,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
165
172
|
// questions: "this browser has a session" versus "there is a session to have".
|
|
166
173
|
// A Kabmo running open answers `authenticated: true` for every visitor, so
|
|
167
174
|
// the navbar cannot use it to decide whether a sign-out control applies.
|
|
168
|
-
const bootstrap = JSON.stringify({ initialIsMobile, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
175
|
+
const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
169
176
|
// The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
|
|
170
177
|
// a second tag: with both present Chrome reads the first one, so an appended
|
|
171
178
|
// tag would leave a dark theme painting light browser chrome.
|
|
@@ -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'),
|
|
@@ -95,12 +95,9 @@ export async function browseDirectories({ request }: LoaderFunctionArgs) {
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
* entry is listed, dot-prefixed files and folders included.
|
|
102
|
-
*/
|
|
103
|
-
|
|
98
|
+
// No name is filtered: `lib/fs/listing.ts` (shared with the realtime topic)
|
|
99
|
+
// lists every entry, and git-ignored ones are dimmed rather than dropped.
|
|
100
|
+
//
|
|
104
101
|
// Lazy listing: return only the immediate children of a directory. Folders are
|
|
105
102
|
// emitted with `children: null` meaning "not loaded yet" so the client can
|
|
106
103
|
// fetch them on demand — directories are NOT recursed here (keeps the initial
|
|
@@ -143,8 +140,26 @@ export async function listDirectory({ request }: LoaderFunctionArgs) {
|
|
|
143
140
|
/** Hard cap on emitted files so a huge tree cannot stall the request. */
|
|
144
141
|
const MAX_FILES = 3000;
|
|
145
142
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Directory names the RECURSIVE walk does not descend into.
|
|
145
|
+
*
|
|
146
|
+
* This is a budget guard, not the Files panel's noise table (there is none any
|
|
147
|
+
* more — `lib/fs/listing.ts` lists every entry). `/api/fs/list` returns a flat
|
|
148
|
+
* file index capped at `MAX_FILES` for the composer's `@` picker, and
|
|
149
|
+
* descending into a dependency tree spends that whole budget inside
|
|
150
|
+
* `node_modules` — leaving the picker with no project file in it. The tree
|
|
151
|
+
* panel is unaffected: it lists lazily, one directory per request, so a folder
|
|
152
|
+
* is only walked when someone expands it.
|
|
153
|
+
*/
|
|
154
|
+
const SKIP_DIRS: Record<string, true> = {
|
|
155
|
+
node_modules: true,
|
|
156
|
+
'.git': true,
|
|
157
|
+
dist: true,
|
|
158
|
+
build: true,
|
|
159
|
+
'.next': true,
|
|
160
|
+
'.cache': true,
|
|
161
|
+
coverage: true,
|
|
162
|
+
};
|
|
148
163
|
|
|
149
164
|
interface ListFileEntry {
|
|
150
165
|
name: string;
|
|
@@ -172,7 +187,7 @@ async function walk(dir: string, baseDir: string, out: ListFileEntry[]): Promise
|
|
|
172
187
|
if (entry.name.startsWith('.')) continue;
|
|
173
188
|
|
|
174
189
|
if (entry.isDirectory()) {
|
|
175
|
-
if (SKIP_DIRS
|
|
190
|
+
if (SKIP_DIRS[entry.name]) continue;
|
|
176
191
|
await walk(path.join(dir, entry.name), baseDir, out);
|
|
177
192
|
continue;
|
|
178
193
|
}
|