wmdev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ import { $ } from "bun";
2
+ import { readEnvLocal } from "./env";
3
+ import { expandTemplate, type ProfileConfig, type SandboxProfileConfig, type ServiceConfig } from "./config";
4
+ import { launchContainer, removeContainer } from "./docker";
5
+ import { log } from "./lib/log";
6
+
7
+ export interface Worktree {
8
+ branch: string;
9
+ agent: string;
10
+ mux: string;
11
+ unmerged: string;
12
+ path: string;
13
+ }
14
+
15
+ export interface WorktreeStatus {
16
+ worktree: string;
17
+ status: string;
18
+ elapsed: string;
19
+ title: string;
20
+ }
21
+
22
+ const WORKTREE_HEADERS = ["BRANCH", "AGENT", "MUX", "UNMERGED", "PATH"] as const;
23
+ const STATUS_HEADERS = ["WORKTREE", "STATUS", "ELAPSED", "TITLE"] as const;
24
+
25
+ function parseTable<T>(
26
+ output: string,
27
+ mapper: (cols: string[]) => T,
28
+ expectedHeaders?: readonly string[],
29
+ ): T[] {
30
+ const lines = output.trim().split("\n").filter(Boolean);
31
+ if (lines.length < 2) return [];
32
+
33
+ const headerLine = lines[0];
34
+
35
+ if (expectedHeaders) {
36
+ const actual = headerLine.trim().split(/\s+/).map(h => h.toUpperCase());
37
+ const match = expectedHeaders.every((h, i) => actual[i] === h.toUpperCase());
38
+ if (!match) {
39
+ log.warn(`[parseTable] unexpected headers: got [${actual.join(", ")}], expected [${expectedHeaders.join(", ")}]`);
40
+ }
41
+ }
42
+
43
+ // Find column positions based on header spacing
44
+ const colStarts: number[] = [];
45
+ let inSpace = true;
46
+ for (let i = 0; i < headerLine.length; i++) {
47
+ if (headerLine[i] !== " " && inSpace) {
48
+ colStarts.push(i);
49
+ inSpace = false;
50
+ } else if (headerLine[i] === " " && !inSpace) {
51
+ inSpace = true;
52
+ }
53
+ }
54
+
55
+ return lines.slice(1).map(line => {
56
+ const cols = colStarts.map((start, idx) => {
57
+ const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length;
58
+ return line.slice(start, end).trim();
59
+ });
60
+ return mapper(cols);
61
+ });
62
+ }
63
+
64
+ /** Build env with TMUX set so workmux can resolve agent states outside tmux. */
65
+ function workmuxEnv(): Record<string, string | undefined> {
66
+ if (process.env.TMUX) return process.env;
67
+ const tmpdir = process.env.TMUX_TMPDIR || "/tmp";
68
+ const uid = process.getuid?.() ?? 1000;
69
+ return { ...process.env, TMUX: `${tmpdir}/tmux-${uid}/default,0,0` };
70
+ }
71
+
72
+ export async function listWorktrees(): Promise<Worktree[]> {
73
+ const result = await $`workmux list`.env(workmuxEnv()).text();
74
+ return parseTable(result, (cols) => ({
75
+ branch: cols[0] ?? "",
76
+ agent: cols[1] ?? "",
77
+ mux: cols[2] ?? "",
78
+ unmerged: cols[3] ?? "",
79
+ path: cols[4] ?? "",
80
+ }), WORKTREE_HEADERS);
81
+ }
82
+
83
+ export async function getStatus(): Promise<WorktreeStatus[]> {
84
+ const result = await $`workmux status`.env(workmuxEnv()).text();
85
+ return parseTable(result, (cols) => ({
86
+ worktree: cols[0] ?? "",
87
+ status: cols[1] ?? "",
88
+ elapsed: cols[2] ?? "",
89
+ title: cols[3] ?? "",
90
+ }), STATUS_HEADERS);
91
+ }
92
+
93
+ async function tryExec(args: string[]): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
94
+ const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
95
+ const stdout = await new Response(proc.stdout).text();
96
+ const stderr = await new Response(proc.stderr).text();
97
+ const exitCode = await proc.exited;
98
+
99
+ if (exitCode !== 0) {
100
+ const msg = `${args.join(" ")} failed (exit ${exitCode}): ${stderr || stdout}`;
101
+ log.error(`[workmux:exec] ${msg}`);
102
+ return { ok: false, error: msg };
103
+ }
104
+ return { ok: true, stdout: stdout.trim() };
105
+ }
106
+
107
+ export { readEnvLocal } from "./env";
108
+
109
+ function buildAgentCmd(env: Record<string, string>, agent: string, profileConfig: ProfileConfig, isSandbox: boolean, prompt?: string): string {
110
+ const systemPrompt = profileConfig.systemPrompt
111
+ ? expandTemplate(profileConfig.systemPrompt, env)
112
+ : "";
113
+ // Escape for double-quoted shell context: backslash, double-quote, dollar, backtick.
114
+ const innerEscaped = systemPrompt.replace(/["\\$`]/g, "\\$&");
115
+ const promptEscaped = prompt ? prompt.replace(/["\\$`]/g, "\\$&") : "";
116
+
117
+ // For sandbox, env is passed via Docker -e flags, no inline prefix needed.
118
+ // For non-sandbox, build inline env prefix for passthrough vars.
119
+ // Merge host env with worktree env; worktree env takes precedence.
120
+ const envPrefix = !isSandbox && profileConfig.envPassthrough?.length
121
+ ? buildEnvPrefix(profileConfig.envPassthrough, { ...process.env, ...env })
122
+ : "";
123
+
124
+ const promptSuffix = promptEscaped ? ` "${promptEscaped}"` : "";
125
+
126
+ if (agent === "codex") {
127
+ return systemPrompt
128
+ ? `${envPrefix}codex --yolo -c "developer_instructions=${innerEscaped}"${promptSuffix}`
129
+ : `${envPrefix}codex --yolo${promptSuffix}`;
130
+ }
131
+ const skipPerms = isSandbox ? " --dangerously-skip-permissions" : "";
132
+ return systemPrompt
133
+ ? `${envPrefix}claude${skipPerms} --append-system-prompt "${innerEscaped}"${promptSuffix}`
134
+ : `${envPrefix}claude${skipPerms}${promptSuffix}`;
135
+ }
136
+
137
+ /** Build an inline env prefix (e.g. "KEY='val' KEY2='val2' ") for vars listed in envPassthrough. */
138
+ function buildEnvPrefix(keys: string[], env: Record<string, string | undefined>): string {
139
+ const parts: string[] = [];
140
+ for (const key of keys) {
141
+ const val = env[key];
142
+ if (val) {
143
+ const escaped = val.replace(/'/g, "'\\''");
144
+ parts.push(`${key}='${escaped}'`);
145
+ }
146
+ }
147
+ return parts.length > 0 ? parts.join(" ") + " " : "";
148
+ }
149
+
150
+ /**
151
+ * Pure: parse `git worktree list --porcelain` output into a branch→path map.
152
+ * Detached HEAD entries (line === "detached") are skipped — they have no branch
153
+ * name to key on.
154
+ */
155
+ export function parseWorktreePorcelain(output: string): Map<string, string> {
156
+ const paths = new Map<string, string>();
157
+ let currentPath = "";
158
+ for (const line of output.split("\n")) {
159
+ if (line.startsWith("worktree ")) {
160
+ currentPath = line.slice("worktree ".length);
161
+ } else if (line.startsWith("branch ")) {
162
+ const name = line.slice("branch ".length).replace("refs/heads/", "");
163
+ if (currentPath) paths.set(name, currentPath);
164
+ }
165
+ }
166
+ return paths;
167
+ }
168
+
169
+ /** Find the on-disk path for a worktree branch via `git worktree list`. */
170
+ function findWorktreeDir(branch: string): string | null {
171
+ const result = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
172
+ if (result.exitCode !== 0) {
173
+ log.warn(`[workmux] git worktree list failed (exit ${result.exitCode})`);
174
+ return null;
175
+ }
176
+ const output = new TextDecoder().decode(result.stdout);
177
+ return parseWorktreePorcelain(output).get(branch) ?? null;
178
+ }
179
+
180
+ function ensureTmux(): void {
181
+ const check = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
182
+ if (check.exitCode !== 0) {
183
+ const started = Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
184
+ if (started.exitCode !== 0) {
185
+ log.debug("[workmux] tmux session already exists (concurrent start)");
186
+ } else {
187
+ log.debug("[workmux] restarted tmux session");
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Sanitize user input into a valid git branch name. */
193
+ function sanitizeBranchName(raw: string): string {
194
+ return raw
195
+ .toLowerCase()
196
+ .replace(/\s+/g, "-")
197
+ .replace(/[~^:?*\[\]\\]+/g, "")
198
+ .replace(/@\{/g, "")
199
+ .replace(/\.{2,}/g, ".")
200
+ .replace(/\/{2,}/g, "/")
201
+ .replace(/-{2,}/g, "-")
202
+ .replace(/^[.\-/]+|[.\-/]+$/g, "")
203
+ .replace(/\.lock$/i, "");
204
+ }
205
+
206
+ function randomName(len: number): string {
207
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
208
+ let result = "";
209
+ for (let i = 0; i < len; i++) {
210
+ result += chars[Math.floor(Math.random() * chars.length)];
211
+ }
212
+ return result;
213
+ }
214
+
215
+ /** Parse branch name from workmux add output (e.g. "Branch: my-feature"). */
216
+ function parseBranchFromOutput(output: string): string | null {
217
+ const match = output.match(/branch:\s*(\S+)/i);
218
+ return match?.[1] ?? null;
219
+ }
220
+
221
+ export interface AddWorktreeOpts {
222
+ prompt?: string;
223
+ profile?: string;
224
+ agent?: string;
225
+ autoName?: boolean;
226
+ profileConfig?: ProfileConfig;
227
+ isSandbox?: boolean;
228
+ sandboxConfig?: SandboxProfileConfig;
229
+ services?: ServiceConfig[];
230
+ mainRepoDir?: string;
231
+ }
232
+
233
+ export async function addWorktree(
234
+ rawBranch: string | undefined,
235
+ opts?: AddWorktreeOpts
236
+ ): Promise<{ ok: true; branch: string; output: string } | { ok: false; error: string }> {
237
+ ensureTmux();
238
+ const profile = opts?.profile ?? "default";
239
+ const agent = opts?.agent ?? "claude";
240
+ const profileConfig = opts?.profileConfig;
241
+ const isSandbox = opts?.isSandbox === true;
242
+ const hasSystemPrompt = !!profileConfig?.systemPrompt;
243
+ const args: string[] = ["workmux", "add", "-b"]; // -b = background (don't switch tmux)
244
+ let branch = "";
245
+ let useAutoName = false;
246
+
247
+ if (isSandbox) {
248
+ // Sandbox: we manage panes ourselves, don't pass -p (we pass prompt to claude directly)
249
+ args.push("-C"); // --no-pane-cmds
250
+ // No -p: workmux can't use it with -C
251
+ // No -A: auto-name needs -p which we can't pass
252
+ if (rawBranch) {
253
+ branch = sanitizeBranchName(rawBranch);
254
+ if (!branch) {
255
+ return { ok: false, error: `"${rawBranch}" is not a valid branch name after sanitization` };
256
+ }
257
+ } else {
258
+ branch = randomName(8);
259
+ }
260
+ args.push(branch);
261
+ } else {
262
+ // Non-sandbox: skip default pane commands for profiles with a system prompt (custom pane setup)
263
+ if (hasSystemPrompt) {
264
+ args.push("-C"); // --no-pane-cmds
265
+ }
266
+
267
+ if (opts?.prompt) args.push("-p", opts.prompt);
268
+
269
+ // Branch name resolution:
270
+ // 1. User provided a name → sanitize and use it
271
+ // 2. No name + prompt + autoName → let workmux generate via -A
272
+ // 3. No name + (no prompt or no autoName) → random
273
+ useAutoName = !rawBranch && !!opts?.prompt && !!opts?.autoName;
274
+
275
+ if (rawBranch) {
276
+ branch = sanitizeBranchName(rawBranch);
277
+ if (!branch) {
278
+ return { ok: false, error: `"${rawBranch}" is not a valid branch name after sanitization` };
279
+ }
280
+ args.push(branch);
281
+ } else if (useAutoName) {
282
+ args.push("-A");
283
+ } else {
284
+ branch = randomName(8);
285
+ args.push(branch);
286
+ }
287
+ }
288
+
289
+ log.debug(`[workmux:add] running: ${args.join(" ")}`);
290
+ const execResult = await tryExec(args);
291
+ if (!execResult.ok) return { ok: false, error: execResult.error };
292
+ const result = execResult.stdout;
293
+
294
+ // When using -A, extract the branch name from workmux output
295
+ if (useAutoName) {
296
+ const parsed = parseBranchFromOutput(result);
297
+ if (!parsed) {
298
+ return { ok: false, error: `Failed to parse branch name from workmux output: ${JSON.stringify(result)}` };
299
+ }
300
+ branch = parsed;
301
+ }
302
+
303
+ const windowTarget = `wm-${branch}`;
304
+
305
+ // Read worktree dir from git (tmux pane may not have cd'd yet with -C)
306
+ const wtDir = findWorktreeDir(branch);
307
+ const env = wtDir ? await readEnvLocal(wtDir) : {};
308
+ log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
309
+
310
+ // Append profile to .env.local (worktree-env creates it, we just add to it)
311
+ if (wtDir) {
312
+ const envPath = `${wtDir}/.env.local`;
313
+ const existing = await Bun.file(envPath).text().catch(() => "");
314
+ if (!existing.includes("PROFILE=")) {
315
+ await Bun.write(envPath, existing.trimEnd() + `\nPROFILE=${profile}\nAGENT=${agent}\n`);
316
+ }
317
+ }
318
+
319
+ // For profiles with a system prompt, kill extra panes and send commands
320
+ if (hasSystemPrompt && profileConfig) {
321
+ // Kill extra panes (highest index first to avoid shifting)
322
+ const paneCountResult = Bun.spawnSync(
323
+ ["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_index}"],
324
+ { stdout: "pipe", stderr: "pipe" }
325
+ );
326
+ if (paneCountResult.exitCode === 0) {
327
+ const paneIds = new TextDecoder().decode(paneCountResult.stdout).trim().split("\n");
328
+ // Kill all panes except pane 0
329
+ for (let i = paneIds.length - 1; i >= 1; i--) {
330
+ Bun.spawnSync(["tmux", "kill-pane", "-t", `${windowTarget}.${paneIds[i]}`]);
331
+ }
332
+ }
333
+
334
+ // Launch Docker container for sandbox worktrees
335
+ let containerName: string | undefined;
336
+ if (isSandbox && opts?.sandboxConfig && wtDir) {
337
+ const mainRepoDir = opts.mainRepoDir ?? process.cwd();
338
+ containerName = await launchContainer({
339
+ branch,
340
+ wtDir,
341
+ mainRepoDir,
342
+ sandboxConfig: opts.sandboxConfig,
343
+ services: opts.services ?? [],
344
+ env,
345
+ });
346
+ }
347
+
348
+ // Build and send agent command (pass prompt for sandbox — we handle it directly)
349
+ const agentCmd = buildAgentCmd(env, agent, profileConfig, isSandbox, isSandbox ? opts?.prompt : undefined);
350
+
351
+ if (containerName) {
352
+ // Sandbox: enter container, run entrypoint visibly, then start agent
353
+ const dockerExec = `docker exec -it -w ${wtDir} ${containerName} bash`;
354
+ Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, dockerExec, "Enter"]);
355
+ // Wait for shell to be ready, then chain entrypoint → agent
356
+ await Bun.sleep(500);
357
+ const entrypointThenAgent = `/usr/local/bin/entrypoint.sh && ${agentCmd}`;
358
+ log.debug(`[workmux] sending to ${windowTarget}.0:\n${entrypointThenAgent}`);
359
+ Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, entrypointThenAgent, "Enter"]);
360
+ // Shell pane: host shell in worktree dir
361
+ Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir ?? process.cwd()]);
362
+ } else {
363
+ // Non-sandbox: run agent directly in pane 0
364
+ log.debug(`[workmux] sending command to ${windowTarget}.0:\n${agentCmd}`);
365
+ Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, agentCmd, "Enter"]);
366
+ // Open a shell pane on the right (1/3 width) in the worktree dir
367
+ Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir ?? process.cwd()]);
368
+ }
369
+ // Keep focus on the agent pane (left)
370
+ Bun.spawnSync(["tmux", "select-pane", "-t", `${windowTarget}.0`]);
371
+ }
372
+
373
+ return { ok: true, branch, output: result };
374
+ }
375
+
376
+ export async function removeWorktree(name: string): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
377
+ log.debug(`[workmux:rm] running: workmux rm --force ${name}`);
378
+ await removeContainer(name);
379
+ const result = await tryExec(["workmux", "rm", "--force", name]);
380
+ if (!result.ok) return result;
381
+ return { ok: true, output: result.stdout };
382
+ }
383
+
384
+ const TMUX_TIMEOUT_MS = 5_000;
385
+
386
+ /** Run a tmux subprocess and await exit with a timeout. Kills the process on timeout. */
387
+ async function tmuxExec(args: string[], opts: { stdin?: Uint8Array } = {}): Promise<{ exitCode: number; stderr: string }> {
388
+ const proc = Bun.spawn(args, {
389
+ stdin: opts.stdin ?? "ignore",
390
+ stdout: "ignore",
391
+ stderr: "pipe",
392
+ });
393
+
394
+ const timeout = Bun.sleep(TMUX_TIMEOUT_MS).then(() => {
395
+ proc.kill();
396
+ return "timeout" as const;
397
+ });
398
+
399
+ const result = await Promise.race([proc.exited, timeout]);
400
+ if (result === "timeout") {
401
+ return { exitCode: -1, stderr: "timed out after 5s (agent may be busy)" };
402
+ }
403
+ const stderr = (await new Response(proc.stderr).text()).trim();
404
+ return { exitCode: result, stderr };
405
+ }
406
+
407
+ export async function sendPrompt(
408
+ branch: string,
409
+ text: string,
410
+ pane = 0,
411
+ preamble?: string,
412
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
413
+ const windowName = `wm-${branch}`;
414
+ const session = await findWorktreeSession(windowName);
415
+ if (!session) {
416
+ return { ok: false, error: `tmux window "${windowName}" not found` };
417
+ }
418
+ const target = `${session}:${windowName}.${pane}`;
419
+ log.debug(`[send:${branch}] target=${target} textBytes=${text.length}${preamble ? ` preamble=${preamble.length}b` : ""}`);
420
+
421
+ // Type the preamble as regular keystrokes so it shows inline in the agent,
422
+ // then paste the bulk payload via a tmux buffer (appears as [pasted text]).
423
+ if (preamble) {
424
+ const { exitCode, stderr } = await tmuxExec(["tmux", "send-keys", "-t", target, "-l", "--", preamble]);
425
+ if (exitCode !== 0) {
426
+ return { ok: false, error: `send-keys preamble failed${stderr ? `: ${stderr}` : ""}` };
427
+ }
428
+ }
429
+
430
+ const cleaned = text.replace(/\0/g, "");
431
+
432
+ // Use a unique buffer name per invocation to avoid races when concurrent
433
+ // sendPrompt calls overlap (e.g. two worktrees sending at the same time).
434
+ const bufName = `wm-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
435
+
436
+ // Load text into a named tmux buffer via stdin — avoids all send-keys
437
+ // escaping/chunking issues and handles any text size in a single operation.
438
+ const load = await tmuxExec(["tmux", "load-buffer", "-b", bufName, "-"], { stdin: new TextEncoder().encode(cleaned) });
439
+ if (load.exitCode !== 0) {
440
+ return { ok: false, error: `load-buffer failed${load.stderr ? `: ${load.stderr}` : ""}` };
441
+ }
442
+
443
+ // Paste buffer into target pane; -d deletes the buffer after pasting.
444
+ const paste = await tmuxExec(["tmux", "paste-buffer", "-b", bufName, "-t", target, "-d"]);
445
+ if (paste.exitCode !== 0) {
446
+ return { ok: false, error: `paste-buffer failed${paste.stderr ? `: ${paste.stderr}` : ""}` };
447
+ }
448
+
449
+ return { ok: true };
450
+ }
451
+
452
+ async function findWorktreeSession(windowName: string): Promise<string | null> {
453
+ const proc = Bun.spawn(
454
+ ["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"],
455
+ { stdout: "pipe", stderr: "pipe" }
456
+ );
457
+ if (await proc.exited !== 0) return null;
458
+ const output = (await new Response(proc.stdout).text()).trim();
459
+ if (!output) return null;
460
+ for (const line of output.split("\n")) {
461
+ const colonIdx = line.indexOf(":");
462
+ if (colonIdx === -1) continue;
463
+ const session = line.slice(0, colonIdx);
464
+ const name = line.slice(colonIdx + 1);
465
+ if (name === windowName) return session;
466
+ }
467
+ return null;
468
+ }
469
+
470
+ export async function openWorktree(name: string): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
471
+ const result = await tryExec(["workmux", "open", name]);
472
+ if (!result.ok) return result;
473
+ return { ok: true, output: result.stdout };
474
+ }
475
+
476
+ export async function mergeWorktree(name: string): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
477
+ log.debug(`[workmux:merge] running: workmux merge ${name}`);
478
+ await removeContainer(name);
479
+ const result = await tryExec(["workmux", "merge", name]);
480
+ if (!result.ok) return result;
481
+ return { ok: true, output: result.stdout };
482
+ }
483
+
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "typeRoots": ["../node_modules/@types"],
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
package/bin/wmdev.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { resolve, dirname, join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // ── Helpers ──────────────────────────────────────────────────────────────────
8
+
9
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ function usage() {
12
+ console.log(`
13
+ wmdev — Dev dashboard for managing Git worktrees
14
+
15
+ Usage:
16
+ wmdev Start the dashboard
17
+ wmdev --port N Set port (default: 5111)
18
+ wmdev --debug Show debug-level logs
19
+ wmdev --help Show this help message
20
+
21
+ Environment:
22
+ DASHBOARD_PORT Same as --port (flag takes precedence)
23
+ `);
24
+ }
25
+
26
+ // ── Parse args ───────────────────────────────────────────────────────────────
27
+
28
+ const args = process.argv.slice(2);
29
+ let port = parseInt(process.env.DASHBOARD_PORT || "5111");
30
+ let debug = false;
31
+
32
+ for (let i = 0; i < args.length; i++) {
33
+ switch (args[i]) {
34
+ case "--port":
35
+ port = parseInt(args[++i]);
36
+ if (Number.isNaN(port)) {
37
+ console.error("Error: --port requires a numeric value");
38
+ process.exit(1);
39
+ }
40
+ break;
41
+ case "--debug":
42
+ debug = true;
43
+ break;
44
+ case "--help":
45
+ case "-h":
46
+ usage();
47
+ process.exit(0);
48
+ break;
49
+ default:
50
+ console.error(`Unknown option: ${args[i]}\nRun wmdev --help for usage.`);
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ // ── Load env files from CWD (.env.local overrides .env) ─────────────────────
56
+
57
+ async function loadEnvFile(path) {
58
+ if (!existsSync(path)) return;
59
+ const lines = (await Bun.file(path).text()).split("\n");
60
+ for (const line of lines) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed || trimmed.startsWith("#")) continue;
63
+ const eq = trimmed.indexOf("=");
64
+ if (eq === -1) continue;
65
+ const key = trimmed.slice(0, eq).trim();
66
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
67
+ if (!(key in process.env)) {
68
+ process.env[key] = val;
69
+ }
70
+ }
71
+ }
72
+
73
+ await loadEnvFile(resolve(process.cwd(), ".env.local"));
74
+ await loadEnvFile(resolve(process.cwd(), ".env"));
75
+
76
+ // ── Shared env for child processes ───────────────────────────────────────────
77
+
78
+ const baseEnv = { ...process.env, DASHBOARD_PORT: String(port), WMDEV_PROJECT_DIR: process.cwd(), ...(debug ? { WMDEV_DEBUG: "1" } : {}) };
79
+
80
+ // ── Prefixed output ──────────────────────────────────────────────────────────
81
+
82
+ function pipeWithPrefix(stream, prefix) {
83
+ const reader = stream.getReader();
84
+ const decoder = new TextDecoder();
85
+ let buffer = "";
86
+
87
+ (async () => {
88
+ while (true) {
89
+ const { done, value } = await reader.read();
90
+ if (done) break;
91
+ buffer += decoder.decode(value, { stream: true });
92
+ const lines = buffer.split("\n");
93
+ buffer = lines.pop();
94
+ for (const line of lines) {
95
+ console.log(`${prefix} ${line}`);
96
+ }
97
+ }
98
+ if (buffer) {
99
+ console.log(`${prefix} ${buffer}`);
100
+ }
101
+ })();
102
+ }
103
+
104
+ // ── Process management ───────────────────────────────────────────────────────
105
+
106
+ const children = [];
107
+ let exiting = false;
108
+
109
+ function cleanup() {
110
+ if (exiting) return;
111
+ exiting = true;
112
+ for (const child of children) {
113
+ try { child.kill("SIGTERM"); } catch {}
114
+ }
115
+ // Force-kill stragglers after 1s, then exit
116
+ setTimeout(() => {
117
+ for (const child of children) {
118
+ try { child.kill("SIGKILL"); } catch {}
119
+ }
120
+ process.exit(0);
121
+ }, 1000).unref();
122
+ }
123
+
124
+ process.on("SIGINT", cleanup);
125
+ process.on("SIGTERM", cleanup);
126
+
127
+ // ── Start ────────────────────────────────────────────────────────────────────
128
+
129
+ const backendEntry = join(PKG_ROOT, "backend", "src", "server.ts");
130
+ const staticDir = join(PKG_ROOT, "frontend", "dist");
131
+
132
+ if (!existsSync(staticDir)) {
133
+ console.error(
134
+ `Error: frontend/dist/ not found. Run 'bun run build' first.`,
135
+ );
136
+ process.exit(1);
137
+ }
138
+
139
+ console.log(`Starting wmdev on port ${port}...`);
140
+
141
+ const be = Bun.spawn(["bun", backendEntry], {
142
+ env: { ...baseEnv, WMDEV_STATIC_DIR: staticDir },
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ });
146
+ children.push(be);
147
+ pipeWithPrefix(be.stdout, "[BE]");
148
+ pipeWithPrefix(be.stderr, "[BE]");
149
+
150
+ await be.exited;