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 +13 -0
- package/backend/src/config.ts +2 -0
- package/backend/src/env.ts +68 -9
- package/backend/src/pr.ts +3 -3
- package/backend/src/workmux.ts +13 -22
- package/frontend/dist/assets/{index-CYGRCW8S.js → index-CRpS9q83.js} +18 -18
- package/frontend/dist/index.html +1 -1
- package/package.json +5 -2
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 |
|
package/backend/src/config.ts
CHANGED
package/backend/src/env.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
export async function
|
|
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
|
|
26
|
+
// File doesn't exist yet
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,
|
|
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
|
|
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
|
|
290
|
+
await writeEnvLocal(wtDir, { PR_DATA: JSON.stringify(entries) });
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
if (seen.size > 0) {
|
package/backend/src/workmux.ts
CHANGED
|
@@ -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
|
-
//
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
|
|
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
|
-
//
|
|
299
|
+
// Allocate ports + write PROFILE/AGENT to .env.local
|
|
311
300
|
if (wtDir) {
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
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)
|