codebay 0.14.0 → 0.16.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/.mochi/manifest.json +778 -646
- package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
- package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
- package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
- package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
- package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
- package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
- package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
- package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
- package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
- package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
- package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
- package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
- package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
- package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
- package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
- package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
- package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
- package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
- package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
- package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +43 -3
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
- package/package.json +13 -9
- package/src/__fixtures__/IdeBarStrip.svelte +41 -0
- package/src/avatars/dog.ts +1 -1
- package/src/avatars/lightning.ts +8 -9
- package/src/avatars/rocket.ts +4 -5
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +112 -1
- package/src/components/DropZone.svelte +77 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/HealthBox.svelte +6 -5
- package/src/components/IdeBar.svelte +222 -76
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +83 -2
- package/src/components/SettingsView.svelte +273 -0
- package/src/components/TerminalPane.svelte +54 -0
- package/src/components/UiShowcase.svelte +23 -3
- package/src/container-injections/claude-code-install.ts +1 -1
- package/src/container-injections/claude-history.ts +52 -0
- package/src/container-injections/claude-statusline.ts +4 -1
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/container-injections/tmux.ts +6 -2
- package/src/index.ts +35 -7
- package/src/lib/agent-run-stream.ts +286 -0
- package/src/lib/agent-runs.server.ts +735 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +36 -7
- package/src/lib/container-files.server.ts +1 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +124 -1
- package/src/lib/devcontainer.server.ts +93 -16
- package/src/lib/docker-client.server.ts +11 -5
- package/src/lib/drives.ts +11 -0
- package/src/lib/exec.server.ts +66 -5
- package/src/lib/injections.server.ts +5 -1
- package/src/lib/instances.server.ts +66 -7
- package/src/lib/log-capture.server.ts +63 -23
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/osc52.ts +18 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/lib/upload.ts +32 -0
- package/src/lib/uploads.server.ts +164 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +55 -0
- package/src/mcp/tools.server.ts +660 -0
- package/src/pages/Instance.svelte +6 -2
- package/src/pages/Settings.svelte +15 -0
- package/src/routes.ts +79 -3
- package/src/types.ts +17 -0
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
- package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
- package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
package/src/lib/exec.server.ts
CHANGED
|
@@ -22,6 +22,37 @@ function collector(): { stream: Writable; text: () => string } {
|
|
|
22
22
|
const STDIN_ENV = '__CODEBAY_EXEC_STDIN';
|
|
23
23
|
const STDIN_VAR = 'CODEBAY_STDIN';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Linux rejects any single argv/env string over 128 KiB (`MAX_ARG_STRLEN`), and the stdin carrier
|
|
27
|
+
* is one env entry — so this, minus headroom for the name, is the hard ceiling on any payload.
|
|
28
|
+
*/
|
|
29
|
+
export const EXEC_STDIN_MAX_BYTES = 128 * 1024 - 1024;
|
|
30
|
+
|
|
31
|
+
/** The largest UTF-8 payload that still fits the carrier once base64 has inflated it by 4/3. */
|
|
32
|
+
export const EXEC_STDIN_MAX_BYTES_BASE64 = Math.floor((EXEC_STDIN_MAX_BYTES / 4) * 3);
|
|
33
|
+
|
|
34
|
+
/** Builds the exec target from an instance row; `container_id` must already be known non-null. */
|
|
35
|
+
export function execTargetFor(row: {
|
|
36
|
+
container_id: string | null;
|
|
37
|
+
remote_user: string | null;
|
|
38
|
+
}): ExecTarget {
|
|
39
|
+
return { containerId: row.container_id!, remoteUser: row.remote_user };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Everything after the last `marker`, or null when absent — login-shell profile noise precedes every script's own output. */
|
|
43
|
+
export function afterMarker(stdout: string, marker: string): string | null {
|
|
44
|
+
const at = stdout.lastIndexOf(marker);
|
|
45
|
+
return at === -1 ? null : stdout.slice(at + marker.length);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Just the marker's own line, untrimmed — an empty leading field is meaningful to some callers. */
|
|
49
|
+
export function markerLine(stdout: string, marker: string): string | null {
|
|
50
|
+
const rest = afterMarker(stdout, marker);
|
|
51
|
+
if (rest === null) return null;
|
|
52
|
+
const nl = rest.indexOf('\n');
|
|
53
|
+
return (nl === -1 ? rest : rest.slice(0, nl)).replace(/\r$/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
/**
|
|
26
57
|
* Real exec stdin is unusable on Bun — dockerode's hijack path needs an `upgrade` event
|
|
27
58
|
* Bun never emits, and `openStdin` needs full-duplex client HTTP Bun also lacks.
|
|
@@ -31,16 +62,41 @@ function wrapWithStdin(script: string): string {
|
|
|
31
62
|
return `${STDIN_VAR}="$${STDIN_ENV}"; unset ${STDIN_ENV}\n${script}`;
|
|
32
63
|
}
|
|
33
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolves false on timeout. A host-side backstop only: the container process keeps running, so a
|
|
67
|
+
* caller that needs the command itself killed must also wrap it in the container's own `timeout`.
|
|
68
|
+
*/
|
|
69
|
+
function streamEnded(stream: NodeJS.ReadableStream, timeoutMs?: number): Promise<boolean> {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const timer = timeoutMs ? setTimeout(() => resolve(false), timeoutMs) : null;
|
|
72
|
+
const settle = (fn: () => void) => {
|
|
73
|
+
if (timer) clearTimeout(timer);
|
|
74
|
+
fn();
|
|
75
|
+
};
|
|
76
|
+
stream.on('end', () => settle(() => resolve(true)));
|
|
77
|
+
stream.on('error', (e) => settle(() => reject(e)));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
34
81
|
/**
|
|
35
82
|
* The one place the `bash -lc` container-exec pattern lives; every injection goes through it.
|
|
36
83
|
* `stdin` reaches the script as `$CODEBAY_STDIN`, and `args` as `$0`, `$1`, ….
|
|
37
84
|
*/
|
|
38
85
|
export async function execInContainer(
|
|
39
86
|
target: ExecTarget,
|
|
40
|
-
opts: { script: string; stdin?: string; args?: string[]; capture?: boolean }
|
|
87
|
+
opts: { script: string; stdin?: string; args?: string[]; capture?: boolean; timeoutMs?: number }
|
|
41
88
|
): Promise<{ ok: boolean; stdout: string; error?: string }> {
|
|
42
89
|
const user = target.remoteUser?.trim() || 'root';
|
|
43
90
|
const hasStdin = opts.stdin !== undefined;
|
|
91
|
+
const stdinBytes = hasStdin ? Buffer.byteLength(opts.stdin!, 'utf8') : 0;
|
|
92
|
+
if (stdinBytes > EXEC_STDIN_MAX_BYTES) {
|
|
93
|
+
// The kernel would fail the exec with an opaque E2BIG; say what the limit actually is.
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
stdout: '',
|
|
97
|
+
error: `payload of ${stdinBytes} bytes exceeds the ${EXEC_STDIN_MAX_BYTES}-byte limit a container exec can carry`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
44
100
|
try {
|
|
45
101
|
const exec = await (await getDocker()).getContainer(target.containerId).exec({
|
|
46
102
|
Cmd: [
|
|
@@ -60,10 +116,15 @@ export async function execInContainer(
|
|
|
60
116
|
const out = collector();
|
|
61
117
|
const err = collector();
|
|
62
118
|
exec.modem.demuxStream(stream, out.stream, err.stream);
|
|
63
|
-
await
|
|
64
|
-
|
|
65
|
-
stream.
|
|
66
|
-
|
|
119
|
+
if (!(await streamEnded(stream, opts.timeoutMs))) {
|
|
120
|
+
// Otherwise the exec socket and its demux collectors live on for as long as the command does.
|
|
121
|
+
stream.destroy();
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
stdout: opts.capture ? out.text().trim() : '',
|
|
125
|
+
error: `timed out after ${opts.timeoutMs}ms`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
67
128
|
|
|
68
129
|
const code = (await exec.inspect()).ExitCode ?? 1;
|
|
69
130
|
const stdout = opts.capture ? out.text().trim() : '';
|
|
@@ -8,6 +8,7 @@ import { gitIdentity } from '../container-injections/git-identity.ts';
|
|
|
8
8
|
import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
|
|
9
9
|
import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
|
|
10
10
|
import { claudeCodeSkills } from '../container-injections/claude-code-skills.ts';
|
|
11
|
+
import { claudeHistory } from '../container-injections/claude-history.ts';
|
|
11
12
|
import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
|
|
12
13
|
import { codeServerDark } from '../container-injections/code-server-dark.ts';
|
|
13
14
|
import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
|
|
@@ -70,7 +71,10 @@ function buildStages(claudeInjection: Injection): Injection[][] {
|
|
|
70
71
|
claudeInjection,
|
|
71
72
|
claudeCodeSkills,
|
|
72
73
|
claudeCodeIdeExtension,
|
|
73
|
-
claudeEffortLevel
|
|
74
|
+
claudeEffortLevel,
|
|
75
|
+
// Only writes `projects/**` + `history.jsonl`, which no other injection touches, and
|
|
76
|
+
// lands before the injections-done sentinel releases the launcher's `claude`.
|
|
77
|
+
claudeHistory
|
|
74
78
|
],
|
|
75
79
|
// git-identity needs stage 1's safe.directory; ttyd shares the apt/dpkg lock with tmux and
|
|
76
80
|
// the /usr/local/bin symlink with claude-code-install, so it trails both. code-server-dark
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
1
2
|
import { rm, stat } from 'node:fs/promises';
|
|
2
3
|
import { basename, join } from 'node:path';
|
|
3
4
|
import {
|
|
5
|
+
claudeMirrorDir,
|
|
4
6
|
CODE_SERVER_PORT,
|
|
5
7
|
DATA_DIR,
|
|
6
8
|
DEFAULT_COPY_IGNORE,
|
|
@@ -18,6 +20,9 @@ import {
|
|
|
18
20
|
deleteInstanceRow,
|
|
19
21
|
getInstance,
|
|
20
22
|
getOption,
|
|
23
|
+
getRun,
|
|
24
|
+
openRunFor,
|
|
25
|
+
openRuns,
|
|
21
26
|
insertForward,
|
|
22
27
|
insertInstance,
|
|
23
28
|
listForwards,
|
|
@@ -38,6 +43,7 @@ import {
|
|
|
38
43
|
stopContainer
|
|
39
44
|
} from './docker.server.ts';
|
|
40
45
|
import {
|
|
46
|
+
AGENT_RUN_MARKER,
|
|
41
47
|
copyWorkspace,
|
|
42
48
|
devcontainerCliAvailable,
|
|
43
49
|
devcontainerUp,
|
|
@@ -67,12 +73,15 @@ import {
|
|
|
67
73
|
syncHealthMonitors
|
|
68
74
|
} from './health.server.ts';
|
|
69
75
|
import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.server.ts';
|
|
76
|
+
import { currentRunSummaries, runSummary, setRunChangeHook, stopRun } from './agent-runs.server.ts';
|
|
70
77
|
import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
|
|
71
78
|
import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
|
|
79
|
+
import { uploadEnabled } from './uploads.server.ts';
|
|
72
80
|
import type { ServerWebSocket } from 'bun';
|
|
73
81
|
import {
|
|
74
82
|
isInstanceFilter,
|
|
75
83
|
normalizeMode,
|
|
84
|
+
type AgentRunSummary,
|
|
76
85
|
type Instance,
|
|
77
86
|
type InstanceFilter,
|
|
78
87
|
type InstanceHealth,
|
|
@@ -186,6 +195,9 @@ export function subscribeLogs(id: string, onChunk: (chunk: string) => void): ()
|
|
|
186
195
|
export type StreamEvent =
|
|
187
196
|
| { type: 'instances'; data: Instance[] }
|
|
188
197
|
| { type: 'health'; data: { id: string; health: InstanceHealth } }
|
|
198
|
+
// Its own frame rather than a list reconcile: run progress ticks every few seconds, and
|
|
199
|
+
// `reconcileInstances` runs a `docker inspect` per instance.
|
|
200
|
+
| { type: 'run'; data: AgentRunSummary }
|
|
189
201
|
| { type: 'preflight'; data: { docker: boolean; cli: boolean } }
|
|
190
202
|
// `name` is the chosen sprite, or null for the default box logo. Lets the header swap live.
|
|
191
203
|
| { type: 'pet'; data: { name: string | null } }
|
|
@@ -194,7 +206,9 @@ export type StreamEvent =
|
|
|
194
206
|
// The global default editor surface, so the picker's toggle follows a settings change.
|
|
195
207
|
| { type: 'default-mode'; data: { mode: InstanceMode } }
|
|
196
208
|
// The colour scheme, so a change in the settings popup repaints the window behind it.
|
|
197
|
-
| { type: 'theme'; data: { value: Theme } }
|
|
209
|
+
| { type: 'theme'; data: { value: Theme } }
|
|
210
|
+
// Arms/disarms the drop zones on every open page when the setting flips.
|
|
211
|
+
| { type: 'upload-enabled'; data: { enabled: boolean } };
|
|
198
212
|
|
|
199
213
|
interface StreamHub {
|
|
200
214
|
sockets: Set<ServerWebSocket<unknown>>;
|
|
@@ -240,6 +254,11 @@ export function broadcastHealth(id: string, health: InstanceHealth): void {
|
|
|
240
254
|
broadcast({ type: 'health', data: { id, health } });
|
|
241
255
|
}
|
|
242
256
|
|
|
257
|
+
setRunChangeHook((runId) => {
|
|
258
|
+
const row = getRun(runId);
|
|
259
|
+
if (row) broadcast({ type: 'run', data: runSummary(row) });
|
|
260
|
+
});
|
|
261
|
+
|
|
243
262
|
/** Called from the settings action so every open dashboard swaps its header logo without a reload. */
|
|
244
263
|
export function broadcastPet(name: string | null): void {
|
|
245
264
|
broadcast({ type: 'pet', data: { name } });
|
|
@@ -263,6 +282,11 @@ export function broadcastTheme(value: Theme): void {
|
|
|
263
282
|
broadcast({ type: 'theme', data: { value } });
|
|
264
283
|
}
|
|
265
284
|
|
|
285
|
+
/** Settings opens in its own popup, so every open dashboard/IDE tab needs the new toggle pushed. */
|
|
286
|
+
export function broadcastUploadEnabled(enabled: boolean): void {
|
|
287
|
+
broadcast({ type: 'upload-enabled', data: { enabled } });
|
|
288
|
+
}
|
|
289
|
+
|
|
266
290
|
async function reconcileInstances(force = false): Promise<void> {
|
|
267
291
|
const list = await listInstances();
|
|
268
292
|
const listJson = JSON.stringify(list);
|
|
@@ -301,6 +325,7 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
|
|
|
301
325
|
// Seed everything up front so a fresh or reconnected client is correct without waiting a tick.
|
|
302
326
|
void listInstances().then((list) => sendTo(ws, { type: 'instances', data: list }));
|
|
303
327
|
for (const snap of currentHealthSnapshots()) sendTo(ws, { type: 'health', data: snap });
|
|
328
|
+
for (const run of currentRunSummaries()) sendTo(ws, { type: 'run', data: run });
|
|
304
329
|
void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
|
|
305
330
|
// Keeps a reconnecting client correct even if the pet changed while it was away.
|
|
306
331
|
sendTo(ws, { type: 'pet', data: { name: getOption('pet') || null } });
|
|
@@ -312,6 +337,8 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
|
|
|
312
337
|
});
|
|
313
338
|
// Same, for the default mode the picker's toggle seeds from.
|
|
314
339
|
sendTo(ws, { type: 'default-mode', data: { mode: getDefaultMode() } });
|
|
340
|
+
// Same, for the upload toggle, so a reconnecting client's drop zones match the current setting.
|
|
341
|
+
sendTo(ws, { type: 'upload-enabled', data: { enabled: uploadEnabled() } });
|
|
315
342
|
}
|
|
316
343
|
|
|
317
344
|
export function streamClose(ws: ServerWebSocket<unknown>): void {
|
|
@@ -381,7 +408,18 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
|
|
|
381
408
|
} else {
|
|
382
409
|
appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
|
|
383
410
|
const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
|
|
384
|
-
await copyWorkspace(
|
|
411
|
+
const { dereferencedSymlinks } = await copyWorkspace(
|
|
412
|
+
row.source_path,
|
|
413
|
+
row.workspace_path,
|
|
414
|
+
ignore
|
|
415
|
+
);
|
|
416
|
+
if (dereferencedSymlinks > 0) {
|
|
417
|
+
appendLog(
|
|
418
|
+
row.id,
|
|
419
|
+
`⚠ ${dereferencedSymlinks} symlink(s) copied as regular files — Windows needs Developer Mode (or admin rights) to recreate symlinks, so edits to them no longer write through to their targets
|
|
420
|
+
`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
385
423
|
appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
|
|
386
424
|
}
|
|
387
425
|
await seedDeclaredPorts(row);
|
|
@@ -467,6 +505,10 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
467
505
|
}));
|
|
468
506
|
appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
|
|
469
507
|
const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
|
|
508
|
+
// Mounted read-only for claude-history to restore from, so it has to exist before the boot —
|
|
509
|
+
// and it has to be mounted *after* the drain above, or a rebuild would restore a stale mirror.
|
|
510
|
+
const mirrorDir = claudeMirrorDir(row.id);
|
|
511
|
+
mkdirSync(mirrorDir, { recursive: true });
|
|
470
512
|
const { imageSource, configPath, overrideConfigPath } = await writeOverrideConfig(
|
|
471
513
|
row.workspace_path,
|
|
472
514
|
row.host_port,
|
|
@@ -474,7 +516,8 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
474
516
|
defaultImage,
|
|
475
517
|
row.mode,
|
|
476
518
|
getClaudePermissionMode(),
|
|
477
|
-
customEnvVarsConfig()?.vars ?? []
|
|
519
|
+
customEnvVarsConfig()?.vars ?? [],
|
|
520
|
+
mirrorDir
|
|
478
521
|
);
|
|
479
522
|
updateInstance(row.id, { image_source: imageSource });
|
|
480
523
|
|
|
@@ -680,6 +723,8 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
680
723
|
for (const { id, health } of currentHealthSnapshots()) {
|
|
681
724
|
openPorts.set(id, new Set(health.openPorts));
|
|
682
725
|
}
|
|
726
|
+
// One query for every sandbox rather than one per row, since this runs on every reconcile tick.
|
|
727
|
+
const runs = new Map(openRuns().map((run) => [run.instance_id, runSummary(run)]));
|
|
683
728
|
const forwards = new Map<
|
|
684
729
|
string,
|
|
685
730
|
{ container_port: number; host_port: number; open: boolean }[]
|
|
@@ -700,7 +745,8 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
700
745
|
avatar: effectiveAvatarName(row),
|
|
701
746
|
git_branch: branches.get(row.id) ?? null,
|
|
702
747
|
attention: getAttention(row.id),
|
|
703
|
-
forwarded_ports: forwards.get(row.id) ?? []
|
|
748
|
+
forwarded_ports: forwards.get(row.id) ?? [],
|
|
749
|
+
active_run: runs.get(row.id) ?? null
|
|
704
750
|
}));
|
|
705
751
|
}
|
|
706
752
|
|
|
@@ -771,6 +817,9 @@ export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): I
|
|
|
771
817
|
const row = getInstance(id);
|
|
772
818
|
if (!row) throw new Error('Instance not found');
|
|
773
819
|
if (inFlight.has(id)) return row; // a build is already in flight
|
|
820
|
+
// The rebuild replaces the container the run's detached process lives in, so it can never
|
|
821
|
+
// report back — fail it now rather than leaving the poller to time it out.
|
|
822
|
+
void cancelActiveRun(id, 'the sandbox was rebuilt');
|
|
774
823
|
// Claimed before the broadcast below, which reconciles synchronously: an unclaimed `creating`
|
|
775
824
|
// row reads as orphaned, and reconcile would flip it straight back off `creating`.
|
|
776
825
|
inFlight.add(id);
|
|
@@ -807,7 +856,9 @@ function quote(value: string): string {
|
|
|
807
856
|
export async function relaunchSurface(row: InstanceRow): Promise<void> {
|
|
808
857
|
if (!row.container_id) return;
|
|
809
858
|
|
|
810
|
-
|
|
859
|
+
// No run survives a container restart, but its marker does when the container was stopped out
|
|
860
|
+
// from under it — left in place, every launcher would keep opening a plain shell instead of Claude.
|
|
861
|
+
const steps: string[] = [`rm -f "$HOME/${AGENT_RUN_MARKER}"`];
|
|
811
862
|
// The folderOpen task's run-once gate is meant to span one container run, but the marker file
|
|
812
863
|
// outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
|
|
813
864
|
if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
|
|
@@ -845,9 +896,16 @@ export async function startInstance(id: string): Promise<InstanceRow> {
|
|
|
845
896
|
return getInstance(id)!;
|
|
846
897
|
}
|
|
847
898
|
|
|
899
|
+
/** Runs are per-container, so any transition that replaces or stops one has to settle them first. */
|
|
900
|
+
async function cancelActiveRun(id: string, reason: string): Promise<void> {
|
|
901
|
+
const open = openRunFor(id);
|
|
902
|
+
if (open) await stopRun(open.id, reason).catch(() => undefined);
|
|
903
|
+
}
|
|
904
|
+
|
|
848
905
|
export async function stopInstance(id: string): Promise<InstanceRow> {
|
|
849
906
|
const row = getInstance(id);
|
|
850
907
|
if (!row) throw new Error('Instance not found');
|
|
908
|
+
await cancelActiveRun(id, 'the sandbox was stopped');
|
|
851
909
|
// A later delete can't exec into a stopped container, so this is the last chance to mirror.
|
|
852
910
|
stopLogCapture(id);
|
|
853
911
|
if (row.container_id) await runCapturePass(row).catch(() => undefined);
|
|
@@ -864,10 +922,11 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
|
|
|
864
922
|
export async function deleteInstance(id: string): Promise<void> {
|
|
865
923
|
const row = getInstance(id);
|
|
866
924
|
if (!row) return;
|
|
925
|
+
await cancelActiveRun(id, 'the sandbox was deleted');
|
|
867
926
|
stopHealthMonitor(id);
|
|
868
927
|
// Mirror the tail of the session before the transcripts die with the container; the periodic
|
|
869
|
-
// chain is stopped first so it can't race this last pass.
|
|
870
|
-
//
|
|
928
|
+
// chain is stopped first so it can't race this last pass. The flat <LOGS_DIR>/*-<id>.jsonl
|
|
929
|
+
// archive is deliberately left on disk; the per-instance mirror goes with the instance below.
|
|
871
930
|
stopLogCapture(id);
|
|
872
931
|
if (row.container_id) {
|
|
873
932
|
await runCapturePass(row).catch(() => undefined);
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
statSync,
|
|
8
8
|
writeFileSync
|
|
9
9
|
} from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import { LOGS_DIR } from './config.server.ts';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { claudeMirrorDir, LOGS_DIR } from './config.server.ts';
|
|
12
12
|
import { getInstance, type InstanceRow } from './db.server.ts';
|
|
13
13
|
import { isRunning } from './docker.server.ts';
|
|
14
14
|
import { execInContainer } from './exec.server.ts';
|
|
@@ -17,58 +17,83 @@ import { execInContainer } from './exec.server.ts';
|
|
|
17
17
|
const CAPTURE_MS = 10_000;
|
|
18
18
|
|
|
19
19
|
const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
|
|
20
|
-
const FETCH_MARKER = '__CODEBAY_FETCH__';
|
|
20
|
+
export const FETCH_MARKER = '__CODEBAY_FETCH__';
|
|
21
21
|
const FILE_MARKER = '__CODEBAY_FILE__';
|
|
22
22
|
const END_MARKER = '__CODEBAY_END__';
|
|
23
23
|
|
|
24
24
|
export interface ManifestEntry {
|
|
25
25
|
path: string;
|
|
26
|
+
/** Path relative to the container's Claude config dir, e.g. `projects/-workspaces-foo/x.jsonl`. */
|
|
27
|
+
relPath: string;
|
|
26
28
|
size: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
interface FetchJob {
|
|
30
32
|
path: string;
|
|
33
|
+
relPath: string;
|
|
31
34
|
hostFileName: string;
|
|
32
35
|
/** 1-indexed byte to `tail -c +N` from; 1 re-pulls the whole file. */
|
|
33
36
|
startByte: number;
|
|
34
|
-
mode: 'append' | 'rollover';
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* Lists the transcript + history files with their byte sizes
|
|
39
|
-
*
|
|
40
|
+
* Lists the transcript + history files with their byte sizes, led by the config dir itself so the
|
|
41
|
+
* parser can derive each file's layout-preserving relative path rather than guess at the encoding.
|
|
42
|
+
* `find`/`wc -c` (not GNU `-printf`) so it works on busybox containers too; the marker line lets
|
|
43
|
+
* the parser skip login-shell noise.
|
|
40
44
|
*/
|
|
41
|
-
function manifestScript(): string {
|
|
45
|
+
export function manifestScript(): string {
|
|
42
46
|
return (
|
|
43
47
|
`h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
|
|
44
48
|
`printf '%s\\n' '${MANIFEST_MARKER}'; ` +
|
|
49
|
+
`printf '%s\\n' "$cfg"; ` +
|
|
45
50
|
`{ find "$cfg" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/null; ` +
|
|
46
51
|
`find "$cfg/projects" -type f -name '*.jsonl' 2>/dev/null; } | ` +
|
|
47
52
|
`while IFS= read -r f; do printf '%s\\t%s\\n' "$f" "$(wc -c < "$f" | tr -d ' ')"; done; true`
|
|
48
53
|
);
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* One framed base64 block of `pathExpr` from `offsetExpr` to EOF (or `maxBytes`) — the shape
|
|
58
|
+
* `parseFetchBlocks` reads. Both exprs are spliced into a shell script, so callers pass shell
|
|
59
|
+
* exprs, not values.
|
|
60
|
+
*/
|
|
61
|
+
export function tailBlockScript(pathExpr: string, offsetExpr: string, maxBytes?: number): string {
|
|
62
|
+
const cap = maxBytes ? ` | head -c ${maxBytes}` : '';
|
|
63
|
+
return (
|
|
64
|
+
`printf '${FILE_MARKER}\\t%s\\n' "${pathExpr}"; ` +
|
|
65
|
+
`tail -c "+${offsetExpr}" "${pathExpr}" 2>/dev/null${cap} | base64; ` +
|
|
66
|
+
`printf '\\n${END_MARKER}\\n'`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
/** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
|
|
52
71
|
function fetchScript(): string {
|
|
53
72
|
return (
|
|
54
73
|
`printf '%s\\n' '${FETCH_MARKER}'; ` +
|
|
55
74
|
`while [ "$#" -ge 2 ]; do p="$1"; o="$2"; shift 2; ` +
|
|
56
|
-
|
|
57
|
-
`tail -c "+$o" "$p" 2>/dev/null | base64; ` +
|
|
58
|
-
`printf '\\n${END_MARKER}\\n'; done`
|
|
75
|
+
`${tailBlockScript('$p', '$o')}; done`
|
|
59
76
|
);
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
export function parseManifest(stdout: string): ManifestEntry[] {
|
|
63
80
|
const at = stdout.indexOf(MANIFEST_MARKER);
|
|
64
81
|
if (at === -1) return [];
|
|
82
|
+
const [, cfgLine, ...lines] = stdout.slice(at + MANIFEST_MARKER.length).split('\n');
|
|
83
|
+
// Trailing slashes would leave the relative path leading with one, breaking the host join.
|
|
84
|
+
const cfg = (cfgLine ?? '').trim().replace(/\/+$/, '');
|
|
85
|
+
if (!cfg) return [];
|
|
65
86
|
const out: ManifestEntry[] = [];
|
|
66
|
-
for (const line of
|
|
87
|
+
for (const line of lines) {
|
|
67
88
|
const tab = line.indexOf('\t');
|
|
68
89
|
if (tab === -1) continue;
|
|
69
90
|
const path = line.slice(0, tab);
|
|
70
91
|
const size = Number(line.slice(tab + 1).trim());
|
|
71
|
-
if (path
|
|
92
|
+
if (!path || !Number.isFinite(size) || !path.startsWith(`${cfg}/`)) continue;
|
|
93
|
+
const relPath = path.slice(cfg.length + 1);
|
|
94
|
+
// `find` can't emit `..` today, but this gets joined onto a host dir, so don't rely on that.
|
|
95
|
+
if (relPath.split('/').includes('..')) continue;
|
|
96
|
+
out.push({ path, relPath, size });
|
|
72
97
|
}
|
|
73
98
|
return out;
|
|
74
99
|
}
|
|
@@ -82,19 +107,22 @@ export function hostFileNameFor(instanceId: string, containerPath: string): stri
|
|
|
82
107
|
return `history-${instanceId}.jsonl`;
|
|
83
108
|
}
|
|
84
109
|
|
|
85
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank.
|
|
112
|
+
* Sized against the layout-preserving mirror, not the flat archive, because that one tracks the
|
|
113
|
+
* container 1:1 — it's what gets mounted back in, so it must stay a byte-exact copy.
|
|
114
|
+
*/
|
|
86
115
|
export function planFetch(
|
|
87
116
|
instanceId: string,
|
|
88
117
|
manifest: ManifestEntry[],
|
|
89
|
-
hostSize: (
|
|
118
|
+
hostSize: (relPath: string) => number
|
|
90
119
|
): FetchJob[] {
|
|
91
120
|
const jobs: FetchJob[] = [];
|
|
92
|
-
for (const { path, size } of manifest) {
|
|
121
|
+
for (const { path, relPath, size } of manifest) {
|
|
93
122
|
const hostFileName = hostFileNameFor(instanceId, path);
|
|
94
|
-
const existing = hostSize(
|
|
123
|
+
const existing = hostSize(relPath);
|
|
95
124
|
if (size === existing) continue;
|
|
96
|
-
|
|
97
|
-
else jobs.push({ path, hostFileName, startByte: existing + 1, mode: 'append' });
|
|
125
|
+
jobs.push({ path, relPath, hostFileName, startByte: size < existing ? 1 : existing + 1 });
|
|
98
126
|
}
|
|
99
127
|
return jobs;
|
|
100
128
|
}
|
|
@@ -118,7 +146,7 @@ export function parseFetchBlocks(stdout: string): { path: string; base64: string
|
|
|
118
146
|
return blocks;
|
|
119
147
|
}
|
|
120
148
|
|
|
121
|
-
function sizeOnDisk(path: string): number {
|
|
149
|
+
export function sizeOnDisk(path: string): number {
|
|
122
150
|
try {
|
|
123
151
|
return statSync(path).size;
|
|
124
152
|
} catch {
|
|
@@ -166,6 +194,7 @@ function updateIndex(logsDir: string, row: InstanceRow): void {
|
|
|
166
194
|
interface CaptureDeps {
|
|
167
195
|
exec?: typeof execInContainer;
|
|
168
196
|
logsDir?: string;
|
|
197
|
+
mirrorDir?: string;
|
|
169
198
|
}
|
|
170
199
|
|
|
171
200
|
/**
|
|
@@ -176,6 +205,7 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
|
|
|
176
205
|
if (!row.container_id) return false;
|
|
177
206
|
const exec = deps.exec ?? execInContainer;
|
|
178
207
|
const logsDir = deps.logsDir ?? LOGS_DIR;
|
|
208
|
+
const mirrorDir = deps.mirrorDir ?? claudeMirrorDir(row.id);
|
|
179
209
|
const target = { containerId: row.container_id, remoteUser: row.remote_user };
|
|
180
210
|
|
|
181
211
|
const manifestRes = await exec(target, { script: manifestScript(), capture: true });
|
|
@@ -183,7 +213,7 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
|
|
|
183
213
|
const manifest = parseManifest(manifestRes.stdout);
|
|
184
214
|
|
|
185
215
|
mkdirSync(logsDir, { recursive: true });
|
|
186
|
-
const jobs = planFetch(row.id, manifest, (
|
|
216
|
+
const jobs = planFetch(row.id, manifest, (rel) => sizeOnDisk(join(mirrorDir, rel)));
|
|
187
217
|
|
|
188
218
|
if (jobs.length) {
|
|
189
219
|
const args = ['fetch'];
|
|
@@ -195,9 +225,19 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
|
|
|
195
225
|
const job = byPath.get(block.path);
|
|
196
226
|
if (!job) continue;
|
|
197
227
|
const bytes = Buffer.from(block.base64, 'base64');
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
228
|
+
const archive = join(logsDir, job.hostFileName);
|
|
229
|
+
const mirror = join(mirrorDir, job.relPath);
|
|
230
|
+
mkdirSync(dirname(mirror), { recursive: true });
|
|
231
|
+
// The archive keeps every generation; the mirror only ever holds what the container
|
|
232
|
+
// holds, since it gets mounted back in and a stale byte would resurface as history.
|
|
233
|
+
// Any whole-file pull rolls, since a pre-mirror instance's archive would otherwise double.
|
|
234
|
+
if (job.startByte === 1) {
|
|
235
|
+
rollOver(archive);
|
|
236
|
+
writeFileSync(mirror, bytes);
|
|
237
|
+
} else {
|
|
238
|
+
appendFileSync(mirror, bytes);
|
|
239
|
+
}
|
|
240
|
+
appendFileSync(archive, bytes);
|
|
201
241
|
}
|
|
202
242
|
}
|
|
203
243
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { getOption, setOption } from './db.server.ts';
|
|
3
|
+
import { timingSafeEqualStr } from './crypto.server.ts';
|
|
4
|
+
|
|
5
|
+
/** The MCP endpoint's path; `auth.server.ts` and the CSRF filter both key off this exact value. */
|
|
6
|
+
export const MCP_PATH = '/mcp';
|
|
7
|
+
|
|
8
|
+
export const MCP_ENABLED_KEY = 'mcp_enabled';
|
|
9
|
+
export const MCP_TOKEN_KEY = 'mcp_token';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Off until someone opts in. The token gates the endpoint, but an install that never wanted an
|
|
13
|
+
* agent-facing control plane shouldn't expose one at all.
|
|
14
|
+
*/
|
|
15
|
+
export function mcpEnabled(): boolean {
|
|
16
|
+
return getOption(MCP_ENABLED_KEY) === '1';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setMcpEnabled(enabled: boolean): void {
|
|
20
|
+
setOption(MCP_ENABLED_KEY, enabled ? '1' : '0');
|
|
21
|
+
if (enabled) getMcpToken();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mint(): string {
|
|
25
|
+
return `cb_${randomBytes(32).toString('base64url')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minted on first read and persisted, so the settings page and the route can't disagree. Unlike
|
|
30
|
+
* every other secret here this one is shown to the user in plaintext — copying it into an MCP
|
|
31
|
+
* client is the whole point of it existing.
|
|
32
|
+
*/
|
|
33
|
+
export function getMcpToken(): string {
|
|
34
|
+
const existing = getOption(MCP_TOKEN_KEY);
|
|
35
|
+
if (existing) return existing;
|
|
36
|
+
const token = mint();
|
|
37
|
+
setOption(MCP_TOKEN_KEY, token);
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function regenerateMcpToken(): string {
|
|
42
|
+
const token = mint();
|
|
43
|
+
setOption(MCP_TOKEN_KEY, token);
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Constant-time, like the bridge token check, so a wrong guess leaks nothing by timing. */
|
|
48
|
+
export function mcpAuthOk(request: Request): boolean {
|
|
49
|
+
const header = request.headers.get('authorization');
|
|
50
|
+
if (!header?.startsWith('Bearer ')) return false;
|
|
51
|
+
return timingSafeEqualStr(header.slice(7).trim(), getMcpToken());
|
|
52
|
+
}
|
package/src/lib/osc52.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSC 52 is how a program inside the container asks the *outer* terminal to put text on the host
|
|
3
|
+
* clipboard — what tmux's `set-clipboard on` emits on copy. xterm.js ships no handler for it, so
|
|
4
|
+
* every such write was silently dropped and copy out of an instance was impossible.
|
|
5
|
+
*/
|
|
6
|
+
export function decodeOsc52(data: string): string | null {
|
|
7
|
+
const sep = data.indexOf(';');
|
|
8
|
+
if (sep === -1) return null;
|
|
9
|
+
const payload = data.slice(sep + 1).replace(/\s+/g, '');
|
|
10
|
+
// `?` is a read request; answering it would hand the host clipboard to the container.
|
|
11
|
+
if (payload === '' || payload === '?') return null;
|
|
12
|
+
try {
|
|
13
|
+
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
|
|
14
|
+
return new TextDecoder().decode(bytes);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/lib/picker.server.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { dirname, join, sep } from 'node:path';
|
|
5
5
|
import { getOption, setOption } from './db.server';
|
|
6
6
|
import { findDevcontainerConfig } from './devcontainer.server.ts';
|
|
7
|
+
import { DRIVES_ROOT } from './drives.ts';
|
|
7
8
|
import type { BrowseResult, DirEntry } from '../types.ts';
|
|
8
9
|
|
|
9
10
|
const LAST_VIEWED_FOLDER = 'last_viewed_folder';
|
|
10
11
|
|
|
12
|
+
const isWindows = process.platform === 'win32';
|
|
13
|
+
|
|
11
14
|
function hasDevcontainer(dir: string): boolean {
|
|
12
15
|
return findDevcontainerConfig(dir) !== null;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
/** Probed rather than enumerated, so listing the drives needs no shell-out. */
|
|
19
|
+
function driveRoots(): DirEntry[] {
|
|
20
|
+
const roots: DirEntry[] = [];
|
|
21
|
+
for (let i = 0; i < 26; i++) {
|
|
22
|
+
const path = `${String.fromCharCode(65 + i)}:${sep}`;
|
|
23
|
+
if (existsSync(path)) roots.push({ name: path, path, hasDevcontainer: hasDevcontainer(path) });
|
|
24
|
+
}
|
|
25
|
+
return roots;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
/** Persists every folder it lists, so an argument-less call resumes where the user left off. */
|
|
16
29
|
export async function browse(path?: string): Promise<BrowseResult> {
|
|
17
30
|
let target: string;
|
|
@@ -22,6 +35,12 @@ export async function browse(path?: string): Promise<BrowseResult> {
|
|
|
22
35
|
target = saved && existsSync(saved) ? saved : homedir();
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
// Deliberately not persisted as the last-viewed folder: it is not a real directory, so a
|
|
39
|
+
// resume would fall through to the home dir anyway.
|
|
40
|
+
if (isWindows && target === DRIVES_ROOT) {
|
|
41
|
+
return { path: DRIVES_ROOT, hasDevcontainer: false, parent: null, entries: driveRoots() };
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
const info = await stat(target);
|
|
26
45
|
if (!info.isDirectory()) throw new Error(`Not a folder: ${target}`);
|
|
27
46
|
|
|
@@ -40,7 +59,9 @@ export async function browse(path?: string): Promise<BrowseResult> {
|
|
|
40
59
|
return {
|
|
41
60
|
path: target,
|
|
42
61
|
hasDevcontainer: hasDevcontainer(target),
|
|
43
|
-
|
|
62
|
+
// A Windows drive root is its own dirname, which would otherwise strand the user on
|
|
63
|
+
// whichever drive their home directory sits on.
|
|
64
|
+
parent: parent === target ? (isWindows ? DRIVES_ROOT : null) : parent,
|
|
44
65
|
entries
|
|
45
66
|
};
|
|
46
67
|
}
|