wmdev 0.2.1 → 0.2.2

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/bin/wmdev.js CHANGED
@@ -126,7 +126,7 @@ process.on("SIGTERM", cleanup);
126
126
 
127
127
  // ── Start ────────────────────────────────────────────────────────────────────
128
128
 
129
- const backendEntry = join(PKG_ROOT, "backend", "src", "server.ts");
129
+ const backendEntry = join(PKG_ROOT, "backend", "dist", "server.js");
130
130
  const staticDir = join(PKG_ROOT, "frontend", "dist");
131
131
 
132
132
  if (!existsSync(staticDir)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wmdev",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Web dashboard for workmux — browser UI with embedded terminals, PR monitoring, and CI integration",
5
5
  "type": "module",
6
6
  "repository": {
@@ -27,15 +27,14 @@
27
27
  "dev": "bash dev.sh",
28
28
  "start": "bun bin/wmdev.js",
29
29
  "build": "cd frontend && bun run build",
30
- "prepublishOnly": "bun run build",
30
+ "build:backend": "bun build backend/src/server.ts --target=bun --outfile=backend/dist/server.js",
31
+ "prepublishOnly": "bun run build && bun run build:backend",
31
32
  "test": "bun run --cwd backend test && bun run --cwd frontend test",
32
33
  "test:coverage": "bun run --cwd backend test --coverage && bun run --cwd frontend test:coverage"
33
34
  },
34
35
  "files": [
35
36
  "bin/",
36
- "backend/src/*.ts",
37
- "backend/src/lib/",
38
- "backend/tsconfig.json",
37
+ "backend/dist/",
39
38
  "frontend/dist/"
40
39
  ],
41
40
  "devDependencies": {
@@ -1,103 +0,0 @@
1
- import { join } from "node:path";
2
- import { parse as parseYaml } from "yaml";
3
-
4
- export interface ServiceConfig {
5
- name: string;
6
- portEnv: string;
7
- portStart?: number;
8
- portStep?: number;
9
- }
10
-
11
- export interface ProfileConfig {
12
- name: string;
13
- systemPrompt?: string;
14
- envPassthrough?: string[];
15
- }
16
-
17
- export interface SandboxProfileConfig extends ProfileConfig {
18
- image: string;
19
- extraMounts?: { hostPath: string; guestPath?: string; writable?: boolean }[];
20
- }
21
-
22
- export interface LinkedRepoConfig {
23
- repo: string;
24
- alias: string;
25
- }
26
-
27
- export interface WmdevConfig {
28
- services: ServiceConfig[];
29
- profiles: {
30
- default: ProfileConfig;
31
- sandbox?: SandboxProfileConfig;
32
- };
33
- autoName: boolean;
34
- linkedRepos: LinkedRepoConfig[];
35
- }
36
-
37
- const DEFAULT_CONFIG: WmdevConfig = {
38
- services: [],
39
- profiles: { default: { name: "default" } },
40
- autoName: false,
41
- linkedRepos: [],
42
- };
43
-
44
- /** Check if .workmux.yaml has auto_name configured. */
45
- function hasAutoName(dir: string): boolean {
46
- try {
47
- const filePath = join(gitRoot(dir), ".workmux.yaml");
48
- const result = Bun.spawnSync(["cat", filePath], { stdout: "pipe", stderr: "pipe" });
49
- const text = new TextDecoder().decode(result.stdout).trim();
50
- if (!text) return false;
51
- const parsed = parseYaml(text) as Record<string, unknown>;
52
- const autoName = parsed.auto_name as Record<string, unknown> | undefined;
53
- return !!autoName?.model;
54
- } catch {
55
- return false;
56
- }
57
- }
58
-
59
- /** Resolve the git repository root from a directory. */
60
- export function gitRoot(dir: string): string {
61
- const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", cwd: dir });
62
- return new TextDecoder().decode(result.stdout).trim() || dir;
63
- }
64
-
65
- /** Load .wmdev.yaml from the git root, merging with defaults. */
66
- export function loadConfig(dir: string): WmdevConfig {
67
- try {
68
- const root = gitRoot(dir);
69
- const filePath = join(root, ".wmdev.yaml");
70
- const result = Bun.spawnSync(["cat", filePath], { stdout: "pipe" });
71
- const text = new TextDecoder().decode(result.stdout).trim();
72
- if (!text) return DEFAULT_CONFIG;
73
- const parsed = parseYaml(text) as Record<string, unknown>;
74
- const profiles = parsed.profiles as Record<string, unknown> | undefined;
75
- const defaultProfile = profiles?.default as ProfileConfig | undefined;
76
- const sandboxProfile = profiles?.sandbox as SandboxProfileConfig | undefined;
77
- const autoName = hasAutoName(dir);
78
- const linkedRepos: LinkedRepoConfig[] = Array.isArray(parsed.linkedRepos)
79
- ? (parsed.linkedRepos as Array<Record<string, unknown>>)
80
- .filter((r) => typeof r === "object" && r !== null && typeof r.repo === "string")
81
- .map((r) => ({
82
- repo: r.repo as string,
83
- alias: typeof r.alias === "string" ? r.alias : (r.repo as string).split("/").pop()!,
84
- }))
85
- : [];
86
- return {
87
- services: Array.isArray(parsed.services) ? parsed.services as ServiceConfig[] : DEFAULT_CONFIG.services,
88
- profiles: {
89
- default: defaultProfile?.name ? defaultProfile : DEFAULT_CONFIG.profiles.default,
90
- ...(sandboxProfile?.name && sandboxProfile?.image ? { sandbox: sandboxProfile } : {}),
91
- },
92
- autoName,
93
- linkedRepos,
94
- };
95
- } catch {
96
- return DEFAULT_CONFIG;
97
- }
98
- }
99
-
100
- /** Expand ${VAR} placeholders in a template string using an env map. */
101
- export function expandTemplate(template: string, env: Record<string, string>): string {
102
- return template.replace(/\$\{(\w+)\}/g, (_, key: string) => env[key] ?? "");
103
- }
@@ -1,432 +0,0 @@
1
- /**
2
- * Docker container lifecycle for sandbox worktrees.
3
- *
4
- * Replaces workmux's `-S` sandbox flag with direct `docker run -p` management.
5
- * Containers run as root with published ports (no socat needed).
6
- */
7
-
8
- import { access, constants, stat } from "node:fs/promises";
9
- import { type SandboxProfileConfig, type ServiceConfig } from "./config";
10
- import { log } from "./lib/log";
11
- import { loadRpcSecret } from "./rpc-secret";
12
-
13
- const DOCKER_RUN_TIMEOUT_MS = 60_000;
14
-
15
- /** Check if a path (file or directory) exists on the host. */
16
- async function pathExists(p: string): Promise<boolean> {
17
- try { await stat(p); return true; } catch { return false; }
18
- }
19
-
20
- /**
21
- * Sanitise a branch name into a Docker-safe segment.
22
- * Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.\-]*.
23
- * The "wm-" prefix (3) and "-<13-digit-ts>" suffix (14) consume 17 chars,
24
- * leaving 46 for the branch segment (total ≤ 63).
25
- */
26
- function sanitiseBranchForName(branch: string): string {
27
- const s = branch
28
- .replace(/[^a-zA-Z0-9_.-]/g, "-")
29
- .replace(/-{2,}/g, "-")
30
- .replace(/^[^a-zA-Z0-9]+/, "")
31
- .replace(/-+$/, "")
32
- .slice(0, 46);
33
- return s || "x";
34
- }
35
-
36
- /** Container naming: wm-{sanitised-branch}-{timestamp} */
37
- function containerName(branch: string): string {
38
- return `wm-${sanitiseBranchForName(branch)}-${Date.now()}`;
39
- }
40
-
41
- /** Return true if s is a valid port number string (integer 1–65535). */
42
- function isValidPort(s: string): boolean {
43
- const n = Number(s);
44
- return Number.isInteger(n) && n >= 1 && n <= 65535;
45
- }
46
-
47
- /** Return true if s is a valid environment variable key. */
48
- function isValidEnvKey(s: string): boolean {
49
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s);
50
- }
51
-
52
- export interface LaunchContainerOpts {
53
- branch: string;
54
- wtDir: string;
55
- mainRepoDir: string;
56
- sandboxConfig: SandboxProfileConfig;
57
- services: ServiceConfig[];
58
- env: Record<string, string>;
59
- }
60
-
61
- function buildWorkmuxStub(): string {
62
- return `#!/usr/bin/env python3
63
- import sys, json, os, urllib.request
64
-
65
- cmd = sys.argv[1] if len(sys.argv) > 1 else ""
66
- args = sys.argv[2:]
67
- host = os.environ.get("WORKMUX_RPC_HOST", "host.docker.internal")
68
- port = os.environ.get("WORKMUX_RPC_PORT", "5111")
69
- token = os.environ.get("WORKMUX_RPC_TOKEN", "")
70
- branch = os.environ.get("WORKMUX_BRANCH", "")
71
-
72
- payload = {"command": cmd, "args": args, "branch": branch}
73
- data = json.dumps(payload).encode()
74
- req = urllib.request.Request(
75
- f"http://{host}:{port}/rpc/workmux",
76
- data=data,
77
- headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
78
- )
79
- try:
80
- with urllib.request.urlopen(req, timeout=30) as resp:
81
- result = json.loads(resp.read())
82
- if result.get("ok"):
83
- print(result.get("output", ""))
84
- else:
85
- print(result.get("error", "RPC failed"), file=sys.stderr)
86
- sys.exit(1)
87
- except Exception as e:
88
- print(f"workmux rpc error: {e}", file=sys.stderr)
89
- sys.exit(1)
90
- `;
91
- }
92
-
93
- /**
94
- * Build the `docker run` argument list from the given options.
95
- *
96
- * This is a pure function — all I/O (path existence checks, env reads) must
97
- * be resolved by the caller and passed in as parameters.
98
- *
99
- * @param opts - Launch options (branch, dirs, config, env).
100
- * @param existingPaths - Set of host paths confirmed to exist; used to decide
101
- * which credential mounts to include.
102
- * @param home - Resolved home directory (e.g. Bun.env.HOME ?? "/root").
103
- * @param name - Pre-generated container name.
104
- */
105
- export function buildDockerRunArgs(
106
- opts: LaunchContainerOpts,
107
- existingPaths: Set<string>,
108
- home: string,
109
- name: string,
110
- rpcSecret: string,
111
- rpcPort: string,
112
- sshAuthSock: string | undefined,
113
- hostUid: number,
114
- hostGid: number,
115
- ): string[] {
116
- const { wtDir, mainRepoDir, sandboxConfig, services, env } = opts;
117
-
118
- const args: string[] = [
119
- "docker", "run", "-d",
120
- "--name", name,
121
- "-w", wtDir,
122
- "--add-host", "host.docker.internal:host-gateway",
123
- // Run as the host user so files created in mounted dirs (.git, worktree)
124
- // are owned by the right UID/GID instead of root.
125
- "--user", `${hostUid}:${hostGid}`,
126
- ];
127
-
128
- // Publish service ports bound to loopback only to avoid exposing dev services
129
- // on external interfaces. Skip invalid or duplicate port values.
130
- const seenPorts = new Set<string>();
131
- for (const svc of services) {
132
- const port = env[svc.portEnv];
133
- if (!port) continue;
134
- if (!isValidPort(port)) {
135
- log.warn(`[docker] skipping invalid port for ${svc.portEnv}: ${JSON.stringify(port)}`);
136
- continue;
137
- }
138
- if (seenPorts.has(port)) continue;
139
- seenPorts.add(port);
140
- args.push("-p", `127.0.0.1:${port}:${port}`);
141
- }
142
-
143
- // Core env vars — defined first so passthrough cannot override them.
144
- const reservedKeys = new Set([
145
- "HOME", "TERM", "IS_SANDBOX", "SSH_AUTH_SOCK",
146
- "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0",
147
- "GIT_CONFIG_KEY_1", "GIT_CONFIG_VALUE_1",
148
- ]);
149
- args.push("-e", "HOME=/root");
150
- args.push("-e", "TERM=xterm-256color");
151
- args.push("-e", "IS_SANDBOX=1");
152
-
153
- // Git safe.directory config so git works in mounted worktrees.
154
- args.push("-e", "GIT_CONFIG_COUNT=2");
155
- args.push("-e", `GIT_CONFIG_KEY_0=safe.directory`);
156
- args.push("-e", `GIT_CONFIG_VALUE_0=${wtDir}`);
157
- args.push("-e", `GIT_CONFIG_KEY_1=safe.directory`);
158
- args.push("-e", `GIT_CONFIG_VALUE_1=${mainRepoDir}`);
159
-
160
- // Pass through host env vars listed in sandboxConfig.
161
- if (sandboxConfig.envPassthrough) {
162
- for (const key of sandboxConfig.envPassthrough) {
163
- if (!isValidEnvKey(key)) {
164
- log.warn(`[docker] skipping invalid envPassthrough key: ${JSON.stringify(key)}`);
165
- continue;
166
- }
167
- if (reservedKeys.has(key)) continue;
168
- const val = Bun.env[key];
169
- if (val !== undefined) {
170
- args.push("-e", `${key}=${val}`);
171
- }
172
- }
173
- }
174
-
175
- // Pass through .env.local vars; skip reserved keys and invalid key names.
176
- for (const [key, val] of Object.entries(env)) {
177
- if (!isValidEnvKey(key)) {
178
- log.warn(`[docker] skipping invalid .env.local key: ${JSON.stringify(key)}`);
179
- continue;
180
- }
181
- if (reservedKeys.has(key)) continue;
182
- args.push("-e", `${key}=${val}`);
183
- }
184
-
185
- // Core mounts.
186
- args.push("-v", `${wtDir}:${wtDir}`);
187
- args.push("-v", `${mainRepoDir}/.git:${mainRepoDir}/.git`);
188
- args.push("-v", `${mainRepoDir}:${mainRepoDir}:ro`);
189
-
190
- // Claude config mounts.
191
- args.push("-v", `${home}/.claude:/root/.claude`);
192
- args.push("-v", `${home}/.claude.json:/root/.claude.json`);
193
-
194
- // Compute which guest paths are already covered by extraMounts so credential
195
- // mounts for the same path can be skipped (extraMounts win).
196
- const extraMountGuestPaths = new Set<string>();
197
- if (sandboxConfig.extraMounts) {
198
- for (const mount of sandboxConfig.extraMounts) {
199
- const hostPath = mount.hostPath.replace(/^~/, home);
200
- if (!hostPath.startsWith("/")) continue;
201
- extraMountGuestPaths.add(mount.guestPath ?? hostPath);
202
- }
203
- }
204
-
205
- // Git/GitHub credential mounts (read-only, only if they exist on host and
206
- // are not overridden by an extraMount for the same guest path).
207
- const credentialMounts = [
208
- { hostPath: `${home}/.gitconfig`, guestPath: "/root/.gitconfig" },
209
- { hostPath: `${home}/.ssh`, guestPath: "/root/.ssh" },
210
- { hostPath: `${home}/.config/gh`, guestPath: "/root/.config/gh" },
211
- ];
212
- for (const { hostPath, guestPath } of credentialMounts) {
213
- if (extraMountGuestPaths.has(guestPath)) continue;
214
- if (existingPaths.has(hostPath)) {
215
- args.push("-v", `${hostPath}:${guestPath}:ro`);
216
- }
217
- }
218
-
219
- // SSH agent forwarding — mount the socket so git+ssh works with
220
- // passphrase-protected keys and hardware tokens. Use --mount instead
221
- // of -v because Docker's -v tries to mkdir socket paths and fails.
222
- if (sshAuthSock && existingPaths.has(sshAuthSock)) {
223
- args.push("--mount", `type=bind,source=${sshAuthSock},target=${sshAuthSock}`);
224
- args.push("-e", `SSH_AUTH_SOCK=${sshAuthSock}`);
225
- }
226
-
227
- // Extra mounts from config; require absolute host paths after ~ expansion.
228
- if (sandboxConfig.extraMounts) {
229
- for (const mount of sandboxConfig.extraMounts) {
230
- const hostPath = mount.hostPath.replace(/^~/, home);
231
- if (!hostPath.startsWith("/")) {
232
- log.warn(`[docker] skipping extra mount with non-absolute host path: ${JSON.stringify(hostPath)}`);
233
- continue;
234
- }
235
- const guestPath = mount.guestPath ?? hostPath;
236
- const suffix = mount.writable ? "" : ":ro";
237
- args.push("-v", `${hostPath}:${guestPath}${suffix}`);
238
- }
239
- }
240
-
241
- // RPC env vars so workmux stub inside the container can reach the host.
242
- args.push("-e", `WORKMUX_RPC_HOST=host.docker.internal`);
243
- args.push("-e", `WORKMUX_RPC_PORT=${rpcPort}`);
244
- args.push("-e", `WORKMUX_RPC_TOKEN=${rpcSecret}`);
245
- args.push("-e", `WORKMUX_BRANCH=${opts.branch}`);
246
-
247
- // Image + command.
248
- args.push(sandboxConfig.image, "sleep", "infinity");
249
-
250
- return args;
251
- }
252
-
253
- /**
254
- * Launch a sandbox container for a worktree. Returns the container name.
255
- * If a container for this branch is already running, returns its name without launching a second one.
256
- */
257
- export async function launchContainer(opts: LaunchContainerOpts): Promise<string> {
258
- const { branch } = opts;
259
-
260
- // Idempotency: reuse an already-running container for this branch.
261
- const existing = await findContainer(branch);
262
- if (existing) {
263
- log.info(`[docker] reusing existing container ${existing} for branch ${branch}`);
264
- return existing;
265
- }
266
-
267
- if (!opts.sandboxConfig.image) {
268
- throw new Error("sandboxConfig.image is required but was empty");
269
- }
270
-
271
- const name = containerName(branch);
272
- const home = Bun.env.HOME ?? "/root";
273
- const rpcSecret = await loadRpcSecret();
274
- const rpcPort = Bun.env.DASHBOARD_PORT ?? "5111";
275
-
276
- // Resolve which credential paths exist on the host before building args.
277
- // Only forward SSH_AUTH_SOCK if the socket is world-accessible so the
278
- // Docker daemon (separate process) can bind-mount it.
279
- let sshAuthSock = Bun.env.SSH_AUTH_SOCK;
280
- if (sshAuthSock) {
281
- try {
282
- const st = await stat(sshAuthSock);
283
- // eslint-disable-next-line no-bitwise
284
- if (!st.isSocket() || (st.mode & 0o007) === 0) {
285
- log.debug(`[docker] skipping SSH_AUTH_SOCK (not world-accessible): ${sshAuthSock}`);
286
- sshAuthSock = undefined;
287
- }
288
- } catch {
289
- sshAuthSock = undefined;
290
- }
291
- }
292
- const credentialHostPaths = [
293
- `${home}/.gitconfig`,
294
- `${home}/.ssh`,
295
- `${home}/.config/gh`,
296
- ...(sshAuthSock ? [sshAuthSock] : []),
297
- ];
298
- const existingPaths = new Set<string>();
299
- await Promise.all(credentialHostPaths.map(async (p) => {
300
- if (await pathExists(p)) existingPaths.add(p);
301
- }));
302
-
303
- const args = buildDockerRunArgs(opts, existingPaths, home, name, rpcSecret, rpcPort, sshAuthSock, process.getuid!(), process.getgid!());
304
-
305
- log.info(`[docker] launching container: ${name}`);
306
- const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
307
-
308
- // Race process exit against a hard timeout so a hung daemon or slow image
309
- // pull does not block the server indefinitely.
310
- const timeout = Bun.sleep(DOCKER_RUN_TIMEOUT_MS).then(() => {
311
- proc.kill();
312
- return "timeout" as const;
313
- });
314
-
315
- const [exitResult, stderr, containerId] = await Promise.all([
316
- Promise.race([proc.exited, timeout]),
317
- new Response(proc.stderr).text(),
318
- new Response(proc.stdout).text(),
319
- ]);
320
-
321
- if (exitResult === "timeout") {
322
- await Bun.spawn(["docker", "rm", "-f", name], { stdout: "ignore", stderr: "ignore" }).exited;
323
- throw new Error(`docker run timed out after ${DOCKER_RUN_TIMEOUT_MS / 1000}s`);
324
- }
325
-
326
- if (exitResult !== 0) {
327
- // Clean up any stopped container docker may have left behind.
328
- await Bun.spawn(["docker", "rm", "-f", name], { stdout: "ignore", stderr: "ignore" }).exited;
329
- throw new Error(`docker run failed (exit ${exitResult}): ${stderr}`);
330
- }
331
-
332
- log.info(`[docker] container ${name} ready (id=${containerId.trim().slice(0, 12)})`);
333
-
334
- // Inject workmux stub so agents inside the container can call host-side workmux.
335
- const stub = buildWorkmuxStub();
336
- const injectProc = Bun.spawn(
337
- ["docker", "exec", "-u", "root", "-i", name, "sh", "-c",
338
- "cat > /usr/local/bin/workmux && chmod +x /usr/local/bin/workmux"],
339
- { stdin: "pipe", stdout: "pipe", stderr: "pipe" },
340
- );
341
- const { stdin } = injectProc;
342
- if (stdin) {
343
- stdin.write(stub);
344
- stdin.end();
345
- }
346
- const injectExit = await injectProc.exited;
347
- if (injectExit !== 0) {
348
- const injectStderr = await new Response(injectProc.stderr).text();
349
- log.warn(`[docker] workmux stub injection failed for ${name}: ${injectStderr}`);
350
- } else {
351
- log.debug(`[docker] workmux stub injected into ${name}`);
352
- }
353
-
354
- return name;
355
- }
356
-
357
- /**
358
- * Find the most-recently-started running container for a branch.
359
- * Returns the container name, or null if none is running.
360
- * Throws if the Docker daemon cannot be reached.
361
- */
362
- export async function findContainer(branch: string): Promise<string | null> {
363
- const sanitised = sanitiseBranchForName(branch);
364
- const prefix = `wm-${sanitised}-`;
365
- const proc = Bun.spawn(
366
- ["docker", "ps", "--filter", `name=${prefix}`, "--format", "{{.Names}}"],
367
- { stdout: "pipe", stderr: "pipe" },
368
- );
369
- const [exitCode, stdout, stderr] = await Promise.all([
370
- proc.exited,
371
- new Response(proc.stdout).text(),
372
- new Response(proc.stderr).text(),
373
- ]);
374
-
375
- if (exitCode !== 0) {
376
- throw new Error(`docker ps failed (exit ${exitCode}): ${stderr}`);
377
- }
378
-
379
- // Filter to exact prefix matches: the part after the prefix must be only
380
- // the numeric timestamp. This prevents "main" from matching "main-v2" containers.
381
- const names = stdout
382
- .trim()
383
- .split("\n")
384
- .filter(Boolean)
385
- .filter(n => n.startsWith(prefix) && /^\d+$/.test(n.slice(prefix.length)));
386
-
387
- // docker ps lists containers newest-first; return the first match.
388
- return names.at(0) ?? null;
389
- }
390
-
391
- /**
392
- * Remove all containers (running or stopped) for a branch.
393
- * Individual removal errors are logged but do not abort remaining removals.
394
- */
395
- export async function removeContainer(branch: string): Promise<void> {
396
- const sanitised = sanitiseBranchForName(branch);
397
- const prefix = `wm-${sanitised}-`;
398
- const listProc = Bun.spawn(
399
- ["docker", "ps", "-a", "--filter", `name=${prefix}`, "--format", "{{.Names}}"],
400
- { stdout: "pipe", stderr: "pipe" },
401
- );
402
- const [listExit, listOut, listErr] = await Promise.all([
403
- listProc.exited,
404
- new Response(listProc.stdout).text(),
405
- new Response(listProc.stderr).text(),
406
- ]);
407
-
408
- if (listExit !== 0) {
409
- log.error(`[docker] removeContainer: docker ps failed for ${branch}: ${listErr}`);
410
- return;
411
- }
412
-
413
- const names = listOut
414
- .trim()
415
- .split("\n")
416
- .filter(Boolean)
417
- .filter(n => n.startsWith(prefix) && /^\d+$/.test(n.slice(prefix.length)));
418
-
419
- await Promise.all(
420
- names.map(async (cname) => {
421
- log.info(`[docker] removing container: ${cname}`);
422
- const rmProc = Bun.spawn(["docker", "rm", "-f", cname], { stdout: "ignore", stderr: "pipe" });
423
- const [rmExit, rmErr] = await Promise.all([
424
- rmProc.exited,
425
- new Response(rmProc.stderr).text(),
426
- ]);
427
- if (rmExit !== 0) {
428
- log.error(`[docker] failed to remove container ${cname}: ${rmErr}`);
429
- }
430
- }),
431
- );
432
- }
@@ -1,95 +0,0 @@
1
- import type { ServiceConfig } from "./config";
2
-
3
- /** Read key=value pairs from a worktree's .env.local file. */
4
- export async function readEnvLocal(wtDir: string): Promise<Record<string, string>> {
5
- try {
6
- const text = (await Bun.file(`${wtDir}/.env.local`).text()).trim();
7
- const env: Record<string, string> = {};
8
- for (const line of text.split("\n")) {
9
- const match = line.match(/^(\w+)=(.*)$/);
10
- if (match) env[match[1]] = match[2];
11
- }
12
- return env;
13
- } catch {
14
- return {};
15
- }
16
- }
17
-
18
- /** Batch-write multiple key=value pairs to a worktree's .env.local (upsert each key). */
19
- export async function writeEnvLocal(wtDir: string, entries: Record<string, string>): Promise<void> {
20
- const filePath = `${wtDir}/.env.local`;
21
- let lines: string[] = [];
22
- try {
23
- const content = (await Bun.file(filePath).text()).trim();
24
- if (content) lines = content.split("\n");
25
- } catch {
26
- // File doesn't exist yet
27
- }
28
-
29
- for (const [key, value] of Object.entries(entries)) {
30
- const pattern = new RegExp(`^${key}=`);
31
- const idx = lines.findIndex((l) => pattern.test(l));
32
- if (idx >= 0) {
33
- lines[idx] = `${key}=${value}`;
34
- } else {
35
- lines.push(`${key}=${value}`);
36
- }
37
- }
38
-
39
- await Bun.write(filePath, lines.join("\n") + "\n");
40
- }
41
-
42
- /** Read .env.local from all worktree paths, optionally excluding one directory. */
43
- export async function readAllWorktreeEnvs(
44
- worktreePaths: string[],
45
- excludeDir?: string,
46
- ): Promise<Record<string, string>[]> {
47
- const results: Record<string, string>[] = [];
48
- for (const p of worktreePaths) {
49
- if (excludeDir && p === excludeDir) continue;
50
- results.push(await readEnvLocal(p));
51
- }
52
- return results;
53
- }
54
-
55
- /**
56
- * Pure: compute port assignments for a new worktree.
57
- * Uses the first allocatable service as a reference to reverse-compute
58
- * occupied slot indices. Index 0 is reserved for main. Returns a map
59
- * of portEnv → port value for all services that have portStart set.
60
- */
61
- export function allocatePorts(
62
- existingEnvs: Record<string, string>[],
63
- services: ServiceConfig[],
64
- ): Record<string, string> {
65
- const allocatable = services.filter((s) => s.portStart != null);
66
- if (allocatable.length === 0) return {};
67
-
68
- // Use the first allocatable service to discover occupied slot indices
69
- const ref = allocatable[0];
70
- const refStart = ref.portStart!;
71
- const refStep = ref.portStep ?? 1;
72
-
73
- const occupied = new Set<number>();
74
- for (const env of existingEnvs) {
75
- const raw = env[ref.portEnv];
76
- if (raw == null) continue;
77
- const port = Number(raw);
78
- if (!Number.isInteger(port) || port < refStart) continue;
79
- const diff = port - refStart;
80
- if (diff % refStep !== 0) continue;
81
- occupied.add(diff / refStep);
82
- }
83
-
84
- // Find the first free slot starting from 1 (0 is reserved for main)
85
- let slot = 1;
86
- while (occupied.has(slot)) slot++;
87
-
88
- const result: Record<string, string> = {};
89
- for (const svc of allocatable) {
90
- const start = svc.portStart!;
91
- const step = svc.portStep ?? 1;
92
- result[svc.portEnv] = String(start + slot * step);
93
- }
94
- return result;
95
- }
@@ -1,10 +0,0 @@
1
- export function jsonResponse(data: unknown, status = 200): Response {
2
- return new Response(JSON.stringify(data), {
3
- status,
4
- headers: { "Content-Type": "application/json" },
5
- });
6
- }
7
-
8
- export function errorResponse(message: string, status = 500): Response {
9
- return jsonResponse({ error: message }, status);
10
- }
@@ -1,14 +0,0 @@
1
- const DEBUG = Bun.env.WMDEV_DEBUG === "1";
2
-
3
- function ts(): string {
4
- return new Date().toISOString().slice(11, 23);
5
- }
6
-
7
- export const log = {
8
- info(msg: string): void { console.log(`[${ts()}] ${msg}`); },
9
- debug(msg: string): void { if (DEBUG) console.log(`[${ts()}] ${msg}`); },
10
- warn(msg: string): void { console.warn(`[${ts()}] ${msg}`); },
11
- error(msg: string, err?: unknown): void {
12
- err !== undefined ? console.error(`[${ts()}] ${msg}`, err) : console.error(`[${ts()}] ${msg}`);
13
- },
14
- };