wmdev 0.1.0 → 0.2.1

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Web dashboard for [workmux](https://github.com/raine/workmux). Provides a browser UI with embedded terminals, PR status monitoring, and CI integration on top of workmux's worktree + tmux orchestration.
4
4
 
5
+ https://github.com/user-attachments/assets/fa13366d-e758-4221-94bf-13a5738bf7e7
6
+
5
7
  ## What is workmux?
6
8
 
7
9
  [workmux](https://github.com/raine/workmux) is a CLI tool that orchestrates git worktrees and tmux. It pairs each worktree with a tmux window, provisions files (copy/symlink), runs lifecycle hooks, and has first-class AI agent support. A single `workmux add` creates the worktree, opens a tmux window with configured panes, and starts your agent. `workmux merge` merges the branch, deletes the worktree, closes the window, and cleans up branches.
@@ -19,6 +21,7 @@ wmdev is a web UI that wraps workmux. It delegates core worktree lifecycle opera
19
21
  | Open/focus a worktree's tmux window | **workmux** (`workmux open`) |
20
22
  | List worktrees and agent status | **workmux** (`workmux list`, `workmux status`) |
21
23
  | File provisioning and lifecycle hooks | **workmux** (`.workmux.yaml` `files` and `post_create`) |
24
+ | Port allocation for worktree services | **wmdev** (when `portStart` is set in `.wmdev.yaml`) or **workmux** (via `post_create` hook) |
22
25
  | Browser terminal (xterm.js ↔ tmux) | **wmdev** |
23
26
  | Service health monitoring (port polling) | **wmdev** |
24
27
  | PR status tracking and badges | **wmdev** (polls `gh pr list`) |
@@ -61,9 +64,13 @@ wmdev uses two config files in the project root:
61
64
  ```yaml
62
65
  # Services to monitor — each maps a display name to a port env var.
63
66
  # The dashboard polls these ports and shows health status badges.
67
+ # When portStart is set, wmdev auto-allocates ports for new worktrees
68
+ # and writes them to .env.local (no post_create hook needed).
64
69
  services:
65
70
  - name: string # Display name (e.g. "BE", "FE")
66
71
  portEnv: string # Env var holding the port (e.g. "BACKEND_PORT")
72
+ portStart: number # (optional) Base port for slot 0 (e.g. 5111)
73
+ portStep: number # (optional) Increment per worktree slot (default: 1)
67
74
 
68
75
  # Profiles define the environment when creating a worktree via the dashboard.
69
76
  profiles:
@@ -108,8 +115,12 @@ linkedRepos: []
108
115
  services:
109
116
  - name: BE
110
117
  portEnv: DASHBOARD_PORT
118
+ portStart: 5111
119
+ portStep: 10
111
120
  - name: FE
112
121
  portEnv: FRONTEND_PORT
122
+ portStart: 5112
123
+ portStep: 10
113
124
 
114
125
  profiles:
115
126
  default:
@@ -140,6 +151,8 @@ linkedRepos:
140
151
  |-----------|------|----------|-------------|
141
152
  | `services[].name` | string | yes | Display name shown in the dashboard |
142
153
  | `services[].portEnv` | string | yes | Env var containing the service port (read from each worktree's `.env.local`) |
154
+ | `services[].portStart` | number | no | Base port for slot 0. When set, wmdev auto-allocates ports for new worktrees |
155
+ | `services[].portStep` | number | no | Port increment per worktree slot (default: `1`). Slot 0 is reserved for main |
143
156
  | `profiles.default.name` | string | yes | Identifier for the default profile |
144
157
  | `profiles.default.systemPrompt` | string | no | System prompt for the agent; `${VAR}` placeholders expanded at runtime |
145
158
  | `profiles.default.envPassthrough` | string[] | no | Env vars passed through to the agent process |
@@ -4,6 +4,8 @@ import { parse as parseYaml } from "yaml";
4
4
  export interface ServiceConfig {
5
5
  name: string;
6
6
  portEnv: string;
7
+ portStart?: number;
8
+ portStep?: number;
7
9
  }
8
10
 
9
11
  export interface ProfileConfig {
@@ -1,3 +1,5 @@
1
+ import type { ServiceConfig } from "./config";
2
+
1
3
  /** Read key=value pairs from a worktree's .env.local file. */
2
4
  export async function readEnvLocal(wtDir: string): Promise<Record<string, string>> {
3
5
  try {
@@ -13,24 +15,81 @@ export async function readEnvLocal(wtDir: string): Promise<Record<string, string
13
15
  }
14
16
  }
15
17
 
16
- /** Upsert a key=value pair in a worktree's .env.local file. */
17
- export async function upsertEnvLocal(wtDir: string, key: string, value: string): Promise<void> {
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> {
18
20
  const filePath = `${wtDir}/.env.local`;
19
21
  let lines: string[] = [];
20
22
  try {
21
23
  const content = (await Bun.file(filePath).text()).trim();
22
24
  if (content) lines = content.split("\n");
23
25
  } catch {
24
- // File doesn't exist yet, start with empty lines
26
+ // File doesn't exist yet
25
27
  }
26
28
 
27
- const pattern = new RegExp(`^${key}=`);
28
- const idx = lines.findIndex((l) => pattern.test(l));
29
- if (idx >= 0) {
30
- lines[idx] = `${key}=${value}`;
31
- } else {
32
- lines.push(`${key}=${value}`);
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
+ }
33
37
  }
34
38
 
35
39
  await Bun.write(filePath, lines.join("\n") + "\n");
36
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
+ }
package/backend/src/pr.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readEnvLocal, upsertEnvLocal } from "./env";
1
+ import { readEnvLocal, writeEnvLocal } from "./env";
2
2
  import type { LinkedRepoConfig } from "./config";
3
3
  import { log } from "./lib/log";
4
4
 
@@ -250,7 +250,7 @@ async function refreshStalePrData(wtDir: string): Promise<void> {
250
250
  }),
251
251
  );
252
252
 
253
- await upsertEnvLocal(wtDir, "PR_DATA", JSON.stringify(updated));
253
+ await writeEnvLocal(wtDir, { PR_DATA: JSON.stringify(updated) });
254
254
  }
255
255
 
256
256
  /** Sync PR status to .env.local for all worktrees that have open PRs. */
@@ -287,7 +287,7 @@ export async function syncPrStatus(
287
287
  if (!wtDir || seen.has(wtDir)) continue;
288
288
  seen.add(wtDir);
289
289
 
290
- await upsertEnvLocal(wtDir, "PR_DATA", JSON.stringify(entries));
290
+ await writeEnvLocal(wtDir, { PR_DATA: JSON.stringify(entries) });
291
291
  }
292
292
 
293
293
  if (seen.size > 0) {
@@ -1,5 +1,5 @@
1
1
  import { $ } from "bun";
2
- import { readEnvLocal } from "./env";
2
+ import { readEnvLocal, writeEnvLocal, readAllWorktreeEnvs, allocatePorts } from "./env";
3
3
  import { expandTemplate, type ProfileConfig, type SandboxProfileConfig, type ServiceConfig } from "./config";
4
4
  import { launchContainer, removeContainer } from "./docker";
5
5
  import { log } from "./lib/log";
@@ -166,17 +166,6 @@ export function parseWorktreePorcelain(output: string): Map<string, string> {
166
166
  return paths;
167
167
  }
168
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
169
  function ensureTmux(): void {
181
170
  const check = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
182
171
  if (check.exitCode !== 0) {
@@ -302,20 +291,22 @@ export async function addWorktree(
302
291
 
303
292
  const windowTarget = `wm-${branch}`;
304
293
 
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)}`);
294
+ // Parse worktree list once used for both dir lookup and port allocation
295
+ const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
296
+ const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
297
+ const wtDir = worktreeMap.get(branch) ?? null;
309
298
 
310
- // Append profile to .env.local (worktree-env creates it, we just add to it)
299
+ // Allocate ports + write PROFILE/AGENT to .env.local
311
300
  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
- }
301
+ const allPaths = [...worktreeMap.values()];
302
+ const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
303
+ const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
304
+ await writeEnvLocal(wtDir, { ...portAssignments, PROFILE: profile, AGENT: agent });
317
305
  }
318
306
 
307
+ const env = wtDir ? await readEnvLocal(wtDir) : {};
308
+ log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
309
+
319
310
  // For profiles with a system prompt, kill extra panes and send commands
320
311
  if (hasSystemPrompt && profileConfig) {
321
312
  // Kill extra panes (highest index first to avoid shifting)