Files
cfw-autumn/scripts/dw/helpers/tmux.ts
amianthus 25e920bbd3 feat(dw): 🎸 parallel agent worktrees with isolated infra + test harness
bun dw spins up a fully isolated per-worktree dev stack: Neon DB branch,
Dragonfly + ElasticMQ via docker compose, portless aliases, tmux
session, and emulate.dev Google OAuth. Workers, dev server, vite,
checkout, and stripe-listen run as concurrently siblings. bun setup-test
auto-runs on first dw and seeds unit-test-org via
clearOrg+setupOrg+ensureDefaultStripeAccount with 13 features + team
invites for all 5 useautumn.com members. Hardcoded localhost URLs in
test scenarios now read AUTUMN_TEST_BASE_URL/AUTUMN_TEST_VITE_URL.
preload-env.ts loads .env.local so bun t/cm/setup-test route to the
worktree server. 1265-line scripts/dw.ts split into scripts/dw/ module
(index + commands + helpers). server/src/db/initDrizzle.ts reverted to
upstream (search_path concerns solved by per-branch Neon isolation).
Workers enabled in agent worktrees now that SQS is per-worktree via
ElasticMQ.
2026-05-16 02:02:43 +01:00

77 lines
2.2 KiB
TypeScript

import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { sh, fatal, log } from "./shell.ts";
export function tmuxSessionName(worktreeNum: number): string {
return `dw-wt-${worktreeNum}`;
}
export function ensureTmuxInstalled(): void {
const res = sh("which", ["tmux"]);
if (res.code !== 0 || !res.stdout) {
fatal("tmux not found on PATH; install tmux to use headless dev wrapping");
}
}
export function tmuxSessionExists(name: string): boolean {
const res = sh("tmux", ["has-session", "-t", name]);
return res.code === 0;
}
export function killTmuxSession(name: string): void {
if (!tmuxSessionExists(name)) return;
sh("tmux", ["kill-session", "-t", name]);
}
export function spawnDevInTmux(
name: string,
env: Record<string, string>,
args: string[],
cwd: string,
): void {
ensureTmuxInstalled();
if (tmuxSessionExists(name)) {
log(`tmux session ${name} already exists, killing first`);
killTmuxSession(name);
}
// Single-quote and escape each env value safely for shell.
const exports = Object.entries(env)
.map(([k, v]) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) return "";
const escaped = String(v).replace(/'/g, "'\\''");
return `export ${k}='${escaped}'`;
})
.filter(Boolean)
.join("\n");
const quotedArgs = args
.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
.join(" ");
const script = `#!/usr/bin/env bash\nset -e\ncd '${cwd.replace(/'/g, "'\\''")}'\n${exports}\nexec ${quotedArgs}\n`;
const scriptPath = join(
tmpdir(),
`dw-tmux-${name}-${process.pid}-${Date.now()}.sh`,
);
writeFileSync(scriptPath, script, { mode: 0o700 });
const res = sh("tmux", [
"new-session",
"-d",
"-s",
name,
"bash",
scriptPath,
]);
if (res.code !== 0) {
if (existsSync(scriptPath)) rmSync(scriptPath, { force: true });
fatal(`tmux new-session failed: ${res.stderr || res.stdout}`);
}
// tmux has spawned bash which holds the script open; safe to delete shortly.
// Give it a brief tick by deferring removal via a separate shell.
sh("bash", ["-c", `(sleep 5 && rm -f '${scriptPath}') >/dev/null 2>&1 &`]);
log(`started dev inside tmux session ${name} (use 'bun dw logs' / 'bun dw attach')`);
}