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/README.md +6 -17
- package/backend/dist/server.js +8657 -0
- package/bin/wmdev.js +1 -1
- package/package.json +4 -5
- package/backend/src/config.ts +0 -103
- package/backend/src/docker.ts +0 -432
- package/backend/src/env.ts +0 -95
- package/backend/src/http.ts +0 -10
- package/backend/src/lib/log.ts +0 -14
- package/backend/src/pr.ts +0 -333
- package/backend/src/rpc-secret.ts +0 -21
- package/backend/src/rpc.ts +0 -96
- package/backend/src/server.ts +0 -471
- package/backend/src/terminal.ts +0 -310
- package/backend/src/workmux.ts +0 -474
- package/backend/tsconfig.json +0 -15
package/backend/src/terminal.ts
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import { log } from "./lib/log";
|
|
2
|
-
|
|
3
|
-
interface TerminalSession {
|
|
4
|
-
proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
5
|
-
groupedSessionName: string;
|
|
6
|
-
scrollback: string[];
|
|
7
|
-
scrollbackBytes: number;
|
|
8
|
-
onData: ((data: string) => void) | null;
|
|
9
|
-
onExit: ((exitCode: number) => void) | null;
|
|
10
|
-
cancelled: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface AttachCmdOptions {
|
|
14
|
-
gName: string;
|
|
15
|
-
worktreeName: string;
|
|
16
|
-
tmuxSession: string;
|
|
17
|
-
cols: number;
|
|
18
|
-
rows: number;
|
|
19
|
-
initialPane?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Scope session names per backend instance using the dashboard port so multiple
|
|
23
|
-
// dashboards sharing the same tmux server don't collide or kill each other's sessions.
|
|
24
|
-
const DASH_PORT = Bun.env.DASHBOARD_PORT || "5111";
|
|
25
|
-
const SESSION_PREFIX = `wm-dash-${DASH_PORT}-`;
|
|
26
|
-
const MAX_SCROLLBACK_BYTES = 1 * 1024 * 1024; // 1 MB
|
|
27
|
-
const sessions = new Map<string, TerminalSession>();
|
|
28
|
-
let sessionCounter = 0;
|
|
29
|
-
|
|
30
|
-
function groupedName(): string {
|
|
31
|
-
return `${SESSION_PREFIX}${++sessionCounter}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function buildAttachCmd(opts: AttachCmdOptions): string {
|
|
35
|
-
const windowTarget = `wm-${opts.worktreeName}`;
|
|
36
|
-
const paneTarget = `${opts.gName}:${windowTarget}.${opts.initialPane ?? 0}`;
|
|
37
|
-
return [
|
|
38
|
-
`tmux new-session -d -s "${opts.gName}" -t "${opts.tmuxSession}"`,
|
|
39
|
-
`tmux set-option -t "${opts.tmuxSession}" window-size latest`,
|
|
40
|
-
`tmux set-option -t "${opts.gName}" mouse on`,
|
|
41
|
-
`tmux set-option -t "${opts.gName}" set-clipboard on`,
|
|
42
|
-
`tmux select-window -t "${opts.gName}:${windowTarget}"`,
|
|
43
|
-
// Unzoom if a previous session left a pane zoomed (zoom state is shared across grouped sessions)
|
|
44
|
-
`if [ "$(tmux display-message -t '${opts.gName}:${windowTarget}' -p '#{window_zoomed_flag}')" = "1" ]; then tmux resize-pane -Z -t '${opts.gName}:${windowTarget}'; fi`,
|
|
45
|
-
`tmux select-pane -t "${paneTarget}"`,
|
|
46
|
-
// On mobile, zoom the selected pane to fill the window
|
|
47
|
-
...(opts.initialPane !== undefined ? [`tmux resize-pane -Z -t "${paneTarget}"`] : []),
|
|
48
|
-
`stty rows ${opts.rows} cols ${opts.cols}`,
|
|
49
|
-
`exec tmux attach-session -t "${opts.gName}"`,
|
|
50
|
-
].join(" && ");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function asyncTmux(args: string[]): Promise<{ exitCode: number; stderr: string }> {
|
|
54
|
-
const proc = Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
55
|
-
const exitCode = await proc.exited;
|
|
56
|
-
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
57
|
-
return { exitCode, stderr };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Kill any orphaned wm-dash-* tmux sessions left from previous server runs. */
|
|
61
|
-
export function cleanupStaleSessions(): void {
|
|
62
|
-
try {
|
|
63
|
-
const result = Bun.spawnSync(
|
|
64
|
-
["tmux", "list-sessions", "-F", "#{session_name}"],
|
|
65
|
-
{ stdout: "pipe", stderr: "pipe" }
|
|
66
|
-
);
|
|
67
|
-
if (result.exitCode !== 0) return;
|
|
68
|
-
const lines = new TextDecoder().decode(result.stdout).trim().split("\n");
|
|
69
|
-
for (const name of lines) {
|
|
70
|
-
if (name.startsWith(SESSION_PREFIX)) {
|
|
71
|
-
Bun.spawnSync(["tmux", "kill-session", "-t", name]);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
// No tmux server running
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Kill a tmux session by name, logging unexpected failures. */
|
|
80
|
-
function killTmuxSession(name: string): void {
|
|
81
|
-
const result = Bun.spawnSync(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
|
|
82
|
-
if (result.exitCode !== 0) {
|
|
83
|
-
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
84
|
-
if (!stderr.includes("can't find session")) {
|
|
85
|
-
log.warn(`[term] killTmuxSession(${name}) exit=${result.exitCode} ${stderr}`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Pure: parse `tmux list-windows -a` output to find the session owning
|
|
92
|
-
* a worktree window. Skips wm-dash-* viewer sessions.
|
|
93
|
-
* Returns the session name, or null if not found.
|
|
94
|
-
*/
|
|
95
|
-
export function parseTmuxSessionForWorktree(
|
|
96
|
-
tmuxOutput: string,
|
|
97
|
-
worktreeName: string,
|
|
98
|
-
): string | null {
|
|
99
|
-
const windowName = `wm-${worktreeName}`;
|
|
100
|
-
const lines = tmuxOutput.trim().split("\n").filter(Boolean);
|
|
101
|
-
// First pass: exact window match, skip viewer sessions
|
|
102
|
-
for (const line of lines) {
|
|
103
|
-
const colonIdx = line.indexOf(":");
|
|
104
|
-
if (colonIdx === -1) continue;
|
|
105
|
-
const session = line.slice(0, colonIdx);
|
|
106
|
-
const name = line.slice(colonIdx + 1);
|
|
107
|
-
if (name === windowName && !session.startsWith("wm-dash-")) {
|
|
108
|
-
return session;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// Fallback: any non-viewer session with a wm-* window
|
|
112
|
-
for (const line of lines) {
|
|
113
|
-
const colonIdx = line.indexOf(":");
|
|
114
|
-
if (colonIdx === -1) continue;
|
|
115
|
-
const session = line.slice(0, colonIdx);
|
|
116
|
-
const name = line.slice(colonIdx + 1);
|
|
117
|
-
if (name.startsWith("wm-") && !session.startsWith("wm-dash-")) {
|
|
118
|
-
return session;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Find the tmux session that owns the window for a given worktree.
|
|
125
|
-
* Skips wm-dash-* grouped/viewer sessions to find the real workmux session. */
|
|
126
|
-
async function findTmuxSessionForWorktree(worktreeName: string): Promise<string> {
|
|
127
|
-
try {
|
|
128
|
-
const proc = Bun.spawn(
|
|
129
|
-
["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"],
|
|
130
|
-
{ stdout: "pipe", stderr: "pipe" },
|
|
131
|
-
);
|
|
132
|
-
if (await proc.exited !== 0) return "0";
|
|
133
|
-
const output = await new Response(proc.stdout).text();
|
|
134
|
-
return parseTmuxSessionForWorktree(output, worktreeName) ?? "0";
|
|
135
|
-
} catch {
|
|
136
|
-
// No tmux server running
|
|
137
|
-
}
|
|
138
|
-
return "0";
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export async function attach(
|
|
142
|
-
worktreeName: string,
|
|
143
|
-
cols: number,
|
|
144
|
-
rows: number,
|
|
145
|
-
initialPane?: number
|
|
146
|
-
): Promise<string> {
|
|
147
|
-
log.debug(`[term] attach(${worktreeName}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeName)}`);
|
|
148
|
-
if (sessions.has(worktreeName)) {
|
|
149
|
-
await detach(worktreeName);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const tmuxSession = await findTmuxSessionForWorktree(worktreeName);
|
|
153
|
-
const gName = groupedName();
|
|
154
|
-
log.debug(`[term] attach(${worktreeName}) tmuxSession=${tmuxSession} gName=${gName} window=wm-${worktreeName}`);
|
|
155
|
-
|
|
156
|
-
// Kill stale session with same name if it exists (leftover from previous server run)
|
|
157
|
-
killTmuxSession(gName);
|
|
158
|
-
|
|
159
|
-
const cmd = buildAttachCmd({ gName, worktreeName, tmuxSession, cols, rows, initialPane });
|
|
160
|
-
|
|
161
|
-
const proc = Bun.spawn(["script", "-q", "-c", cmd, "/dev/null"], {
|
|
162
|
-
stdin: "pipe",
|
|
163
|
-
stdout: "pipe",
|
|
164
|
-
stderr: "pipe",
|
|
165
|
-
env: { ...Bun.env, TERM: "xterm-256color" },
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const session: TerminalSession = {
|
|
169
|
-
proc,
|
|
170
|
-
groupedSessionName: gName,
|
|
171
|
-
scrollback: [],
|
|
172
|
-
scrollbackBytes: 0,
|
|
173
|
-
onData: null,
|
|
174
|
-
onExit: null,
|
|
175
|
-
cancelled: false,
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
sessions.set(worktreeName, session);
|
|
179
|
-
log.debug(`[term] attach(${worktreeName}) spawned pid=${proc.pid}`);
|
|
180
|
-
|
|
181
|
-
// Read stdout → push to scrollback + callback
|
|
182
|
-
(async () => {
|
|
183
|
-
const reader = proc.stdout.getReader();
|
|
184
|
-
try {
|
|
185
|
-
while (true) {
|
|
186
|
-
if (session.cancelled) break;
|
|
187
|
-
const { done, value } = await reader.read();
|
|
188
|
-
if (done) break;
|
|
189
|
-
const str = new TextDecoder().decode(value);
|
|
190
|
-
const encoder = new TextEncoder();
|
|
191
|
-
session.scrollbackBytes += encoder.encode(str).byteLength;
|
|
192
|
-
session.scrollback.push(str);
|
|
193
|
-
while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
|
|
194
|
-
const removed = session.scrollback.shift()!;
|
|
195
|
-
session.scrollbackBytes -= encoder.encode(removed).byteLength;
|
|
196
|
-
}
|
|
197
|
-
session.onData?.(str);
|
|
198
|
-
}
|
|
199
|
-
} catch (err) {
|
|
200
|
-
// Stream closed normally — no action needed.
|
|
201
|
-
// Log anything unexpected so it surfaces during debugging.
|
|
202
|
-
if (!session.cancelled) {
|
|
203
|
-
log.error(`[term] stdout reader error(${worktreeName})`, err);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
})();
|
|
207
|
-
|
|
208
|
-
// Read stderr → log for diagnostics
|
|
209
|
-
(async () => {
|
|
210
|
-
const reader = proc.stderr.getReader();
|
|
211
|
-
try {
|
|
212
|
-
while (true) {
|
|
213
|
-
const { done, value } = await reader.read();
|
|
214
|
-
if (done) break;
|
|
215
|
-
log.debug(`[term] stderr(${worktreeName}): ${new TextDecoder().decode(value).trimEnd()}`);
|
|
216
|
-
}
|
|
217
|
-
} catch { /* stream closed */ }
|
|
218
|
-
})();
|
|
219
|
-
|
|
220
|
-
proc.exited.then((exitCode) => {
|
|
221
|
-
log.debug(`[term] proc exited(${worktreeName}) pid=${proc.pid} code=${exitCode}`);
|
|
222
|
-
// Only clean up if this session is still the active one (not replaced by a new attach)
|
|
223
|
-
if (sessions.get(worktreeName) === session) {
|
|
224
|
-
session.onExit?.(exitCode);
|
|
225
|
-
sessions.delete(worktreeName);
|
|
226
|
-
} else {
|
|
227
|
-
log.debug(`[term] proc exited(${worktreeName}) stale session, skipping cleanup`);
|
|
228
|
-
}
|
|
229
|
-
killTmuxSession(gName);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
return worktreeName;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export async function detach(worktreeName: string): Promise<void> {
|
|
236
|
-
const session = sessions.get(worktreeName);
|
|
237
|
-
if (!session) {
|
|
238
|
-
log.debug(`[term] detach(${worktreeName}) no session found`);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
log.debug(`[term] detach(${worktreeName}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
|
|
243
|
-
session.cancelled = true;
|
|
244
|
-
session.proc.kill();
|
|
245
|
-
sessions.delete(worktreeName);
|
|
246
|
-
|
|
247
|
-
killTmuxSession(session.groupedSessionName);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function write(worktreeName: string, data: string): void {
|
|
251
|
-
const session = sessions.get(worktreeName);
|
|
252
|
-
if (!session) {
|
|
253
|
-
log.warn(`[term] write(${worktreeName}) NO SESSION - input dropped (${data.length} bytes)`);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
try {
|
|
257
|
-
session.proc.stdin.write(new TextEncoder().encode(data));
|
|
258
|
-
session.proc.stdin.flush();
|
|
259
|
-
} catch (err) {
|
|
260
|
-
log.error(`[term] write(${worktreeName}) stdin closed`, err);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export async function resize(worktreeName: string, cols: number, rows: number): Promise<void> {
|
|
265
|
-
const session = sessions.get(worktreeName);
|
|
266
|
-
if (!session) return;
|
|
267
|
-
const windowTarget = `${session.groupedSessionName}:wm-${worktreeName}`;
|
|
268
|
-
const result = await asyncTmux(["tmux", "resize-window", "-t", windowTarget, "-x", String(cols), "-y", String(rows)]);
|
|
269
|
-
if (result.exitCode !== 0) log.warn(`[term] resize failed: ${result.stderr}`);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function getScrollback(worktreeName: string): string {
|
|
273
|
-
return sessions.get(worktreeName)?.scrollback.join("") ?? "";
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export function setCallbacks(
|
|
277
|
-
worktreeName: string,
|
|
278
|
-
onData: (data: string) => void,
|
|
279
|
-
onExit: (exitCode: number) => void
|
|
280
|
-
): void {
|
|
281
|
-
const session = sessions.get(worktreeName);
|
|
282
|
-
if (session) {
|
|
283
|
-
session.onData = onData;
|
|
284
|
-
session.onExit = onExit;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export async function selectPane(worktreeName: string, paneIndex: number): Promise<void> {
|
|
289
|
-
const session = sessions.get(worktreeName);
|
|
290
|
-
if (!session) {
|
|
291
|
-
log.debug(`[term] selectPane(${worktreeName}) no session found`);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
const windowTarget = `wm-${worktreeName}`;
|
|
295
|
-
const target = `${session.groupedSessionName}:${windowTarget}.${paneIndex}`;
|
|
296
|
-
log.debug(`[term] selectPane(${worktreeName}) pane=${paneIndex} target=${target}`);
|
|
297
|
-
const [r1, r2] = await Promise.all([
|
|
298
|
-
asyncTmux(["tmux", "select-pane", "-t", target]),
|
|
299
|
-
asyncTmux(["tmux", "resize-pane", "-Z", "-t", target]),
|
|
300
|
-
]);
|
|
301
|
-
log.debug(`[term] selectPane(${worktreeName}) select=${r1.exitCode} zoom=${r2.exitCode}`);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export function clearCallbacks(worktreeName: string): void {
|
|
305
|
-
const session = sessions.get(worktreeName);
|
|
306
|
-
if (session) {
|
|
307
|
-
session.onData = null;
|
|
308
|
-
session.onExit = null;
|
|
309
|
-
}
|
|
310
|
-
}
|