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 { 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 { const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]); return res.code === 0; } export async function listWindows(tmuxConfPath: string, sessionName: string): Promise { 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 { 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 { 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}`); } }