103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
|
|
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
|
|
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
|
|
export function sanitizeId(raw: string): string | null {
|
|
if (!ID_RE.test(raw)) return null;
|
|
return raw.toLowerCase();
|
|
}
|
|
|
|
export function tmuxSessionName(sessionId: string): string {
|
|
return `bc-${sessionId}`;
|
|
}
|
|
|
|
export function tmuxWindowName(paneId: string): string {
|
|
return `term-${paneId}`;
|
|
}
|
|
|
|
interface CmdResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
}
|
|
|
|
// Wrap child_process.spawn with shell:false so each argv element is passed
|
|
// as a separate argument — no shell interpolation, no injection surface.
|
|
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
|
|
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
|
|
child.on('error', (err) => {
|
|
resolve({ stdout, stderr: stderr + String(err), code: 1 });
|
|
});
|
|
child.on('close', (code) => {
|
|
resolve({ stdout, stderr, code: code ?? 0 });
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
|
|
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
|
|
return res.code === 0;
|
|
}
|
|
|
|
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
|
|
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
|
|
if (res.code !== 0) return [];
|
|
return res.stdout.trim().split('\n').filter(Boolean);
|
|
}
|
|
|
|
export async function killWindow(
|
|
tmuxConfPath: string,
|
|
sessionName: string,
|
|
windowName: string,
|
|
): Promise<boolean> {
|
|
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
|
|
return res.code === 0;
|
|
}
|
|
|
|
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
|
|
// named window is present. The session's initial window is created with the
|
|
// target name (via `-n`) so we don't need a separate rename step.
|
|
export async function ensureWindow(
|
|
tmuxConfPath: string,
|
|
sessionName: string,
|
|
windowName: string,
|
|
projectRoot: string,
|
|
log: FastifyBaseLogger,
|
|
): Promise<void> {
|
|
if (!(await hasSession(tmuxConfPath, sessionName))) {
|
|
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
|
|
const res = await runTmux(tmuxConfPath, [
|
|
'new-session', '-d',
|
|
'-s', sessionName,
|
|
'-n', windowName,
|
|
'-c', projectRoot,
|
|
]);
|
|
if (res.code !== 0) {
|
|
log.error({ res }, 'tmux new-session failed');
|
|
throw new Error(`tmux new-session failed: ${res.stderr}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const windows = await listWindows(tmuxConfPath, sessionName);
|
|
if (windows.includes(windowName)) return;
|
|
|
|
const res = await runTmux(tmuxConfPath, [
|
|
'new-window',
|
|
'-t', sessionName,
|
|
'-n', windowName,
|
|
'-c', projectRoot,
|
|
]);
|
|
if (res.code !== 0) {
|
|
log.error({ res }, 'tmux new-window failed');
|
|
throw new Error(`tmux new-window failed: ${res.stderr}`);
|
|
}
|
|
}
|