feat: 🎸 v2 dev worktrees over neon

This commit is contained in:
amianthus
2026-05-15 23:38:55 +01:00
parent 03239f2d3b
commit 6e53558520
8 changed files with 673 additions and 252 deletions

View File

@@ -11,3 +11,43 @@ google:
family_name: Autumn
email_verified: true
locale: en
- uid: emulate-ayush-rodrigues
email: ayush@useautumn.com
name: Ayush Rodrigues
given_name: Ayush
family_name: Rodrigues
email_verified: true
locale: en
hd: useautumn.com
- uid: emulate-john-yeo
email: jy@useautumn.com
name: John Yeo
given_name: John
family_name: Yeo
email_verified: true
locale: en
hd: useautumn.com
- uid: emulate-tanvir-ahmed
email: tanvir@useautumn.com
name: Tanvir Ahmed
given_name: Tanvir
family_name: Ahmed
email_verified: true
locale: en
hd: useautumn.com
- uid: emulate-charlie-lamb
email: charlie@useautumn.com
name: Charlie Lamb
given_name: Charlie
family_name: Lamb
email_verified: true
locale: en
hd: useautumn.com
- uid: emulate-owen-greenhalgh
email: owen@useautumn.com
name: Owen Greenhalgh
given_name: Owen
family_name: Greenhalgh
email_verified: true
locale: en
hd: useautumn.com

View File

@@ -241,10 +241,17 @@ async function startDev() {
VITE_APP_ENV: viteAppEnv,
...(process.env.DB_SCHEMA && { DB_SCHEMA: process.env.DB_SCHEMA }),
...(worktreeNum > 1 && {
CLIENT_URL: `http://localhost:${VITE_PORT}`,
BETTER_AUTH_URL: `http://localhost:${SERVER_PORT}`,
VITE_BACKEND_URL: `http://localhost:${SERVER_PORT}`,
VITE_FRONTEND_URL: `http://localhost:${VITE_PORT}`,
CLIENT_URL:
process.env.CLIENT_URL ?? `http://localhost:${VITE_PORT}`,
BETTER_AUTH_URL:
process.env.BETTER_AUTH_URL ??
`http://localhost:${SERVER_PORT}`,
VITE_BACKEND_URL:
process.env.VITE_BACKEND_URL ??
`http://localhost:${SERVER_PORT}`,
VITE_FRONTEND_URL:
process.env.VITE_FRONTEND_URL ??
`http://localhost:${VITE_PORT}`,
EMULATE_GOOGLE_URL:
process.env.EMULATE_GOOGLE_URL ??
"https://google.emulate.localhost",

View File

@@ -1,6 +1,4 @@
import {
createHash,
} from "node:crypto";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
@@ -15,15 +13,23 @@ import { fileURLToPath } from "node:url";
type RegistryEntry = {
path: string;
worktreeNum: number;
schema: string;
createdAt: number;
branchId?: string;
branchName?: string;
databaseUrl?: string;
lastUsedAt?: number;
};
type Registry = Record<string, RegistryEntry>;
const REGISTRY_PATH = join(homedir(), ".autumn-worktrees.json");
const MAX_WORKTREE = 50;
const SCHEMA_NAME_RE = /^wt_[a-f0-9]{6}_\d+$/;
const BRANCH_NAME_RE = /^dw-wt-\d+-[a-f0-9]+$/;
const INACTIVITY_MS = 7 * 24 * 60 * 60 * 1000;
const NEON_PROJECT_ID = "weathered-morning-43833874";
const NEON_TEMPLATE_BRANCH = "dw-template";
const NEON_PARENT_BRANCH = "production";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(SCRIPT_DIR, "..");
@@ -57,20 +63,6 @@ function sh(
};
}
function shInherit(
cmd: string,
args: string[],
opts: { cwd?: string; env?: Record<string, string> } = {},
): number {
const proc = Bun.spawnSync([cmd, ...args], {
cwd: opts.cwd,
env: opts.env ?? (process.env as Record<string, string>),
stdout: "inherit",
stderr: "inherit",
});
return proc.exitCode ?? 1;
}
function loadRegistry(): Registry {
if (!existsSync(REGISTRY_PATH)) return {};
try {
@@ -98,10 +90,6 @@ function getWorktreeList(): string[] {
function getCanonicalWorktree(): string {
const list = getWorktreeList();
if (list.length === 0) {
// fallback: project root
return PROJECT_ROOT;
}
return list[0] ?? PROJECT_ROOT;
}
@@ -135,220 +123,320 @@ function allocateWorktreeNumber(
fatal(`no free worktree slot under ${MAX_WORKTREE}`);
}
function deriveSchema(path: string, worktreeNum: number): string {
if (worktreeNum === 1) return "public";
return `wt_${shortHash(path)}_${worktreeNum}`;
function deriveBranchName(path: string, worktreeNum: number): string {
return `dw-wt-${worktreeNum}-${shortHash(path)}`;
}
function reconcile(registry: Registry, databaseUrl: string): Registry {
const live = new Set(getWorktreeList());
const next: Registry = {};
const orphaned: RegistryEntry[] = [];
for (const [path, entry] of Object.entries(registry)) {
if (live.has(path) || entry.worktreeNum === 1) {
next[path] = entry;
} else {
orphaned.push(entry);
}
}
for (const o of orphaned) {
if (!SCHEMA_NAME_RE.test(o.schema)) continue;
log(`reconcile: dropping orphaned schema ${o.schema} (path gone: ${o.path})`);
dropSchema(o.schema, databaseUrl);
const localDir = join(SHARED_DIR, "drizzle-local", o.schema);
if (existsSync(localDir)) rmSync(localDir, { recursive: true, force: true });
}
return next;
// ─────────────────────────────────────────────────────────────
// Neon CLI wrappers
// ─────────────────────────────────────────────────────────────
type NeonBranch = {
id: string;
name: string;
created_at?: string;
};
function neon(args: string[]): { stdout: string; stderr: string; code: number } {
return sh("neon", args);
}
function schemaExists(schema: string, databaseUrl: string): boolean {
const res = sh(
"psql",
[
databaseUrl,
"-tAc",
`SELECT 1 FROM information_schema.schemata WHERE schema_name = '${schema}'`,
],
);
if (res.code !== 0) {
fatal(`psql check failed: ${res.stderr}`);
}
return res.stdout.trim() === "1";
}
function dropSchema(schema: string, databaseUrl: string): void {
if (!SCHEMA_NAME_RE.test(schema)) {
fatal(`refusing to drop schema with unexpected name: ${schema}`);
}
const res = sh("psql", [
databaseUrl,
"-c",
`DROP SCHEMA IF EXISTS "${schema}" CASCADE`,
function listBranches(): NeonBranch[] {
const res = neon([
"branches",
"list",
"--project-id",
NEON_PROJECT_ID,
"--output",
"json",
]);
if (res.code !== 0) {
console.error(`[dw] drop schema ${schema} failed: ${res.stderr}`);
fatal(`neon branches list failed: ${res.stderr || res.stdout}`);
}
try {
return JSON.parse(res.stdout) as NeonBranch[];
} catch {
fatal(`could not parse neon branches list output:\n${res.stdout}`);
}
}
function createSchema(schema: string, databaseUrl: string): void {
const res = sh("psql", [
databaseUrl,
"-c",
`CREATE SCHEMA IF NOT EXISTS "${schema}"`,
function findBranchByName(name: string): NeonBranch | undefined {
return listBranches().find((b) => b.name === name);
}
function createBranch(name: string, parent: string): NeonBranch {
if (!BRANCH_NAME_RE.test(name) && name !== NEON_TEMPLATE_BRANCH) {
fatal(`refusing to create branch with unexpected name: ${name}`);
}
log(`creating neon branch ${name} (parent: ${parent})`);
const res = neon([
"branches",
"create",
"--project-id",
NEON_PROJECT_ID,
"--name",
name,
"--parent",
parent,
"--output",
"json",
]);
if (res.code !== 0) {
fatal(`CREATE SCHEMA ${schema} failed: ${res.stderr}`);
fatal(`neon branches create failed: ${res.stderr || res.stdout}`);
}
try {
const parsed = JSON.parse(res.stdout) as { branch?: NeonBranch };
const branch = parsed.branch ?? (parsed as unknown as NeonBranch);
if (!branch?.id) fatal(`unexpected neon create output:\n${res.stdout}`);
return branch;
} catch {
fatal(`could not parse neon create output:\n${res.stdout}`);
}
}
function rewriteDatabaseUrl(url: string, schema: string): string {
const u = new URL(url);
const optsValue = `-c search_path=${schema},public`;
u.searchParams.set("options", optsValue);
return u.toString();
}
function rewriteDbEnv(
env: Record<string, string>,
schema: string,
): Record<string, string> {
const out = { ...env };
for (const key of [
"DATABASE_URL",
"DATABASE_CRITICAL_URL",
"DATABASE_REPLICA_URL",
]) {
const v = out[key];
if (v) out[key] = rewriteDatabaseUrl(v, schema);
function deleteBranch(idOrName: string): void {
const res = neon([
"branches",
"delete",
idOrName,
"--project-id",
NEON_PROJECT_ID,
]);
if (res.code !== 0) {
console.error(
`[dw] neon branches delete ${idOrName} failed: ${res.stderr || res.stdout}`,
);
} else {
log(`deleted neon branch ${idOrName}`);
}
return out;
}
async function generateInitialMigration(
schema: string,
function connectionString(
branchName: string,
opts: { pooled?: boolean } = {},
): string {
const args = [
"connection-string",
branchName,
"--project-id",
NEON_PROJECT_ID,
];
if (opts.pooled) args.push("--pooled");
const res = neon(args);
if (res.code !== 0) {
fatal(
`neon connection-string for ${branchName} failed: ${res.stderr || res.stdout}`,
);
}
return res.stdout.trim();
}
function ensureTemplateBranch(): void {
const branch = findBranchByName(NEON_TEMPLATE_BRANCH);
if (branch) return;
log(`bootstrap: ${NEON_TEMPLATE_BRANCH} missing, creating empty parent`);
createBranch(NEON_TEMPLATE_BRANCH, NEON_PARENT_BRANCH);
// Wipe the inherited schema so children start truly empty.
const url = connectionString(NEON_TEMPLATE_BRANCH);
const reset = sh("psql", [url, "-v", "ON_ERROR_STOP=1"], {
stdin: `DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public;\nCREATE EXTENSION IF NOT EXISTS pg_trgm;\n`,
});
if (reset.code !== 0) {
fatal(`failed to reset ${NEON_TEMPLATE_BRANCH}:\n${reset.stderr}`);
}
log(`${NEON_TEMPLATE_BRANCH} ready (empty + pg_trgm)`);
}
// ─────────────────────────────────────────────────────────────
// Migration apply (drizzle-kit generate → psql)
// ─────────────────────────────────────────────────────────────
function listSqlFiles(dir: string): string[] {
const res = Bun.spawnSync(["ls", dir]);
const stdout = res.stdout ? new TextDecoder().decode(res.stdout).trim() : "";
if (!stdout) return [];
return stdout.split("\n").filter((f) => f.endsWith(".sql"));
}
function writeTempDrizzleConfig(outDir: string): string {
const tmp = join(SHARED_DIR, `.dw-${process.pid}.config.ts`);
const content = `import { defineConfig } from "drizzle-kit";\nexport default defineConfig({\n\tdialect: "postgresql",\n\tout: ${JSON.stringify(outDir)},\n\tschema: "./db/schema.ts",\n\tdbCredentials: { url: process.env.DATABASE_URL! },\n});\n`;
writeFileSync(tmp, content);
return tmp;
}
function generateAndApplyMigration(
branchName: string,
databaseUrl: string,
): Promise<void> {
const outDir = join(SHARED_DIR, "drizzle-local", schema);
): void {
const outDir = join(SHARED_DIR, "drizzle-local", branchName);
if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
log(`generating initial migration for ${schema}`);
const gen = sh(
"bunx",
[
"drizzle-kit",
"generate",
"--config",
"drizzle.config.ts",
"--out",
outDir,
"--name",
"initial",
],
{
cwd: SHARED_DIR,
env: {
...(process.env as Record<string, string>),
NODE_OPTIONS: "--import tsx",
},
},
);
if (gen.code !== 0) {
fatal(`drizzle-kit generate failed:\n${gen.stdout}\n${gen.stderr}`);
}
const sqlFiles = Bun.spawnSync(["ls", outDir]).stdout
? new TextDecoder()
.decode(Bun.spawnSync(["ls", outDir]).stdout)
.trim()
.split("\n")
.filter((f) => f.endsWith(".sql"))
: [];
if (sqlFiles.length === 0) {
fatal(`no .sql files generated in ${outDir}`);
}
for (const f of sqlFiles) {
const p = join(outDir, f);
const original = readFileSync(p, "utf-8");
const rewritten = original.replace(/"public"\./g, `"${schema}".`);
writeFileSync(p, rewritten);
}
log(`rewrote ${sqlFiles.length} migration file(s) public → ${schema}`);
const migrateUrl = rewriteDatabaseUrl(databaseUrl, schema);
log(`applying initial migration into ${schema}`);
const drizzleConfigPath = writeTempMigrateConfig(outDir);
const drizzleConfigPath = writeTempDrizzleConfig(outDir);
try {
const mig = sh(
log(`generating initial migration for ${branchName}`);
const gen = sh(
"bunx",
[
"drizzle-kit",
"migrate",
"--config",
drizzleConfigPath,
],
["drizzle-kit", "generate", "--config", drizzleConfigPath],
{
cwd: SHARED_DIR,
env: {
...(process.env as Record<string, string>),
NODE_OPTIONS: "--import tsx",
DATABASE_URL: migrateUrl,
},
},
);
if (mig.code !== 0) {
fatal(`drizzle-kit migrate failed:\n${mig.stdout}\n${mig.stderr}`);
if (gen.code !== 0) {
fatal(`drizzle-kit generate failed:\n${gen.stdout}\n${gen.stderr}`);
}
const sqlFiles = listSqlFiles(outDir);
if (sqlFiles.length === 0) {
fatal(`no .sql files generated in ${outDir}`);
}
log(`applying ${sqlFiles.length} migration file(s) to ${branchName}`);
for (const f of sqlFiles) {
const p = join(outDir, f);
const sqlBody = readFileSync(p, "utf-8");
const mig = sh("psql", [databaseUrl, "-v", "ON_ERROR_STOP=1"], {
stdin: sqlBody,
});
if (mig.code !== 0) {
fatal(
`applying migration ${f} failed:\n${mig.stdout}\n${mig.stderr}`,
);
}
}
} finally {
if (existsSync(drizzleConfigPath)) rmSync(drizzleConfigPath);
}
}
function writeTempMigrateConfig(outDir: string): string {
const tmp = join(SHARED_DIR, `.dw-migrate-${process.pid}.config.ts`);
const content = `import { defineConfig } from "drizzle-kit";\nexport default defineConfig({\n\tdialect: "postgresql",\n\tout: ${JSON.stringify(outDir)},\n\tschema: "./db/schema.ts",\n\tdbCredentials: { url: process.env.DATABASE_URL! },\n});\n`;
writeFileSync(tmp, content);
return tmp;
}
async function loadDbFunctions(
schema: string,
databaseUrl: string,
): Promise<void> {
log(`loading DB functions into ${schema}`);
const migrateUrl = rewriteDatabaseUrl(databaseUrl, schema);
const code = shInherit(
"bun",
[
"-e",
`import { initializeDatabaseFunctions } from "./src/db/initializeDatabaseFunctions.js"; await initializeDatabaseFunctions(); process.exit(0);`,
],
{
cwd: join(PROJECT_ROOT, "server"),
env: {
...(process.env as Record<string, string>),
DATABASE_URL: migrateUrl,
},
},
function loadDbFunctions(branchName: string, databaseUrl: string): void {
log(`loading DB functions into ${branchName}`);
const sqlDir = join(
PROJECT_ROOT,
"server",
"src",
"internal",
"balances",
"utils",
"sql",
);
if (code !== 0) {
fatal(`loading DB functions into ${schema} failed (exit ${code})`);
const sqlFiles = [
"deductFromRollovers.sql",
"deductFromMainBalance.sql",
"unwindFromLockReceipt.sql",
"getTotalBalance.sql",
"deductFromAdditionalBalance.sql",
"getAvailableOverageFromSpendLimit.sql",
"performDeduction.sql",
"syncBalances.sql",
"syncBalancesV2.sql",
"resetCusEnts.sql",
];
for (const f of sqlFiles) {
const p = join(sqlDir, f);
const sqlBody = readFileSync(p, "utf-8");
const res = sh("psql", [databaseUrl, "-v", "ON_ERROR_STOP=1"], {
stdin: sqlBody,
});
if (res.code !== 0) {
fatal(
`loading DB function ${f} into ${branchName} failed:\n${res.stdout}\n${res.stderr}`,
);
}
}
}
async function setupSchemaIfMissing(
schema: string,
databaseUrl: string,
): Promise<void> {
if (schemaExists(schema, databaseUrl)) {
log(`schema ${schema} already exists, skipping setup`);
return;
// ─────────────────────────────────────────────────────────────
// Env / portless / emulate plumbing
// ─────────────────────────────────────────────────────────────
function rewriteDbEnv(
env: Record<string, string>,
branchUrl: string,
): Record<string, string> {
const out = { ...env };
out.DATABASE_URL = branchUrl;
out.DATABASE_CRITICAL_URL = branchUrl;
// Replica URL stays unset for agent branches (read from primary).
delete out.DATABASE_REPLICA_URL;
return out;
}
const EMULATE_PID_FILE = join(homedir(), ".autumn-emulate.pid");
const EMULATE_HEALTH_URL =
"https://google.emulate.localhost/.well-known/openid-configuration";
const START_EMULATE_SH = join(SCRIPT_DIR, "setup", "start-emulate.sh");
function emulateReachable(): boolean {
const res = sh("curl", [
"-sf",
"-o",
"/dev/null",
"--max-time",
"1",
EMULATE_HEALTH_URL,
]);
return res.code === 0;
}
function ensureEmulateRunning(): void {
if (emulateReachable()) return;
log("emulate.dev not reachable, spawning daemon");
const res = sh("bash", [START_EMULATE_SH]);
if (res.code !== 0) {
console.error(
`[dw] failed to start emulate daemon:\n${res.stdout}\n${res.stderr}`,
);
}
log(`first run for ${schema} — provisioning`);
createSchema(schema, databaseUrl);
await generateInitialMigration(schema, databaseUrl);
await loadDbFunctions(schema, databaseUrl);
}
function killPidFromFile(file: string): boolean {
if (!existsSync(file)) return false;
const pid = Number(readFileSync(file, "utf-8").trim());
if (!pid || Number.isNaN(pid)) return false;
try {
process.kill(pid, "SIGTERM");
} catch {}
rmSync(file, { force: true });
return true;
}
function killHostProcessByName(name: string): boolean {
const res = sh("pgrep", ["-f", name]);
const pids = res.stdout
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.filter((s) => /^\d+$/.test(s));
if (pids.length === 0) return false;
for (const pid of pids) {
try {
process.kill(Number(pid), "SIGTERM");
} catch {}
}
return true;
}
function stopEmulateAndPortless(): void {
const fromPid = killPidFromFile(EMULATE_PID_FILE);
const fromScan = killHostProcessByName("emulate --portless");
if (fromPid || fromScan) log("stopped emulate.dev");
const stop = sh("portless", ["proxy", "stop"]);
if (stop.code === 0) log("stopped portless proxy");
}
function hasOtherActiveWorktrees(
registry: Registry,
currentPath: string,
): boolean {
return Object.entries(registry).some(
([p, e]) => p !== currentPath && e.worktreeNum > 1,
);
}
function killOwnPorts(worktreeNum: number): void {
@@ -367,21 +455,167 @@ function killOwnPorts(worktreeNum: number): void {
}
}
function startDev(
worktreeNum: number,
schema: string,
databaseUrl: string,
): never {
const env = rewriteDbEnv(
{ ...(process.env as Record<string, string>) },
schema,
type WorktreeAliases = {
apiHost: string;
apiUrl: string;
viteHost: string;
viteUrl: string;
};
function aliasesFor(worktreeNum: number): WorktreeAliases {
const apiHost = `wt${worktreeNum}-api.localhost`;
const viteHost = `wt${worktreeNum}.localhost`;
return {
apiHost,
apiUrl: `https://${apiHost}`,
viteHost,
viteUrl: `https://${viteHost}`,
};
}
function registerPortlessAliases(worktreeNum: number): WorktreeAliases {
const offset = (worktreeNum - 1) * 100;
const aliases = aliasesFor(worktreeNum);
const SERVER_PORT = 8080 + offset;
const VITE_PORT = 3000 + offset;
for (const [name, port] of [
[`wt${worktreeNum}-api`, SERVER_PORT],
[`wt${worktreeNum}`, VITE_PORT],
] as const) {
const res = sh("portless", ["alias", name, String(port), "--force"]);
if (res.code !== 0) {
console.error(
`[dw] portless alias ${name} -> ${port} failed: ${res.stderr}`,
);
}
}
log(
`portless: ${aliases.viteUrl} → :${VITE_PORT}, ${aliases.apiUrl} → :${SERVER_PORT}`,
);
env.DB_SCHEMA = schema;
if (worktreeNum > 1 && !env.EMULATE_GOOGLE_URL) {
env.EMULATE_GOOGLE_URL = "https://google.emulate.localhost";
return aliases;
}
function unregisterPortlessAliases(worktreeNum: number): void {
for (const name of [`wt${worktreeNum}-api`, `wt${worktreeNum}`]) {
sh("portless", ["alias", "--remove", name]);
}
}
// ─────────────────────────────────────────────────────────────
// Reconcile (orphan branches + inactivity sweep)
// ─────────────────────────────────────────────────────────────
function reconcile(registry: Registry): Registry {
const live = new Set(getWorktreeList());
const next: Registry = {};
const now = Date.now();
const orphaned: RegistryEntry[] = [];
for (const [path, entry] of Object.entries(registry)) {
if (entry.worktreeNum === 1) {
next[path] = entry;
continue;
}
const lastUsed = entry.lastUsedAt ?? entry.createdAt;
const tooStale = now - lastUsed > INACTIVITY_MS;
if (!live.has(path)) {
orphaned.push(entry);
} else if (tooStale) {
log(
`reconcile: ${entry.path} unused for ${Math.round(
(now - lastUsed) / (24 * 60 * 60 * 1000),
)}d, dropping`,
);
orphaned.push(entry);
} else {
next[path] = entry;
}
}
log(`starting dev (worktree=${worktreeNum}, schema=${schema})`);
for (const o of orphaned) {
if (o.branchName && BRANCH_NAME_RE.test(o.branchName)) {
deleteBranch(o.branchName);
}
if (o.branchName) {
const localDir = join(SHARED_DIR, "drizzle-local", o.branchName);
if (existsSync(localDir))
rmSync(localDir, { recursive: true, force: true });
}
}
return next;
}
// ─────────────────────────────────────────────────────────────
// Setup / start
// ─────────────────────────────────────────────────────────────
async function setupAgentWorktree(
entry: RegistryEntry,
registry: Registry,
): Promise<RegistryEntry> {
const { branchName } = entry;
if (!branchName) fatal("entry missing branchName");
// If branch already exists on Neon and we have a URL, just refresh.
if (entry.branchId && findBranchByName(branchName)) {
const url = connectionString(branchName, { pooled: true });
const next: RegistryEntry = {
...entry,
databaseUrl: url,
lastUsedAt: Date.now(),
};
registry[entry.path] = next;
saveRegistry(registry);
return next;
}
// First-run provisioning.
log(`first run for ${branchName} — provisioning neon branch`);
ensureTemplateBranch();
const branch = createBranch(branchName, NEON_TEMPLATE_BRANCH);
// Use direct (non-pooled) URL for DDL; pooler can interfere with some DDL paths.
const directUrl = connectionString(branchName, { pooled: false });
generateAndApplyMigration(branchName, directUrl);
loadDbFunctions(branchName, directUrl);
// Pooled URL for runtime.
const pooledUrl = connectionString(branchName, { pooled: true });
const next: RegistryEntry = {
...entry,
branchId: branch.id,
databaseUrl: pooledUrl,
lastUsedAt: Date.now(),
};
registry[entry.path] = next;
saveRegistry(registry);
return next;
}
function startDev(entry: RegistryEntry): never {
const { worktreeNum, branchName, databaseUrl } = entry;
let env: Record<string, string> = {
...(process.env as Record<string, string>),
};
if (worktreeNum > 1) {
if (!databaseUrl) fatal("agent worktree missing databaseUrl");
env = rewriteDbEnv(env, databaseUrl);
if (!env.EMULATE_GOOGLE_URL) {
env.EMULATE_GOOGLE_URL = "https://google.emulate.localhost";
}
const portlessCa = join(homedir(), ".portless", "ca.pem");
if (existsSync(portlessCa) && !env.NODE_EXTRA_CA_CERTS) {
env.NODE_EXTRA_CA_CERTS = portlessCa;
}
const aliases = registerPortlessAliases(worktreeNum);
env.BETTER_AUTH_URL = aliases.apiUrl;
env.CLIENT_URL = aliases.viteUrl;
env.VITE_BACKEND_URL = aliases.apiUrl;
env.VITE_FRONTEND_URL = aliases.viteUrl;
}
log(
`starting dev (worktree=${worktreeNum}${branchName ? `, branch=${branchName}` : ""})`,
);
const proc = Bun.spawn(
[
"bun",
@@ -406,48 +640,79 @@ function startDev(
return undefined as never;
}
// ─────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────
async function cmdDefault(): Promise<void> {
if (process.env.NODE_ENV === "production") {
fatal("bun dw is disabled in production");
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) fatal("DATABASE_URL not set (is infisical wrapping bun dw?)");
const canonical = getCanonicalWorktree();
const cwd = getCurrentWorktree();
let registry = loadRegistry();
registry = reconcile(registry, databaseUrl);
registry = reconcile(registry);
let entry = registry[cwd];
if (!entry) {
const worktreeNum = allocateWorktreeNumber(cwd, registry, canonical);
const schema = deriveSchema(cwd, worktreeNum);
entry = { path: cwd, worktreeNum, schema, createdAt: Date.now() };
const branchName =
worktreeNum === 1 ? undefined : deriveBranchName(cwd, worktreeNum);
entry = {
path: cwd,
worktreeNum,
createdAt: Date.now(),
...(branchName && { branchName }),
};
registry[cwd] = entry;
saveRegistry(registry);
log(`registered ${cwd} as worktree ${worktreeNum} (schema=${schema})`);
log(
`registered ${cwd} as worktree ${worktreeNum}${branchName ? ` (branch=${branchName})` : ""}`,
);
} else {
log(`resuming worktree ${entry.worktreeNum} (schema=${entry.schema})`);
entry.lastUsedAt = Date.now();
registry[cwd] = entry;
saveRegistry(registry);
log(
`resuming worktree ${entry.worktreeNum}${entry.branchName ? ` (branch=${entry.branchName})` : ""}`,
);
}
if (entry.worktreeNum > 1) {
await setupSchemaIfMissing(entry.schema, databaseUrl);
entry = await setupAgentWorktree(entry, registry);
ensureEmulateRunning();
}
killOwnPorts(entry.worktreeNum);
startDev(entry.worktreeNum, entry.schema, databaseUrl);
startDev(entry);
}
async function cmdTeardown(opts: { all?: boolean }): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) fatal("DATABASE_URL not set");
const registry = loadRegistry();
let registry = loadRegistry();
if (opts.all) {
const next = reconcile(registry, databaseUrl);
for (const entry of Object.values(registry)) {
if (entry.worktreeNum === 1) continue;
if (entry.branchName) deleteBranch(entry.branchName);
unregisterPortlessAliases(entry.worktreeNum);
if (entry.branchName) {
const localDir = join(
SHARED_DIR,
"drizzle-local",
entry.branchName,
);
if (existsSync(localDir))
rmSync(localDir, { recursive: true, force: true });
}
}
const next: Registry = {};
for (const [p, e] of Object.entries(registry)) {
if (e.worktreeNum === 1) next[p] = e;
}
saveRegistry(next);
log("teardown --all complete (orphaned schemas dropped)");
stopEmulateAndPortless();
log("teardown --all complete");
return;
}
@@ -460,12 +725,21 @@ async function cmdTeardown(opts: { all?: boolean }): Promise<void> {
if (entry.worktreeNum === 1) {
fatal("refusing to teardown canonical worktree (worktreeNum=1)");
}
dropSchema(entry.schema, databaseUrl);
const localDir = join(SHARED_DIR, "drizzle-local", entry.schema);
if (existsSync(localDir)) rmSync(localDir, { recursive: true, force: true });
if (entry.branchName) deleteBranch(entry.branchName);
unregisterPortlessAliases(entry.worktreeNum);
if (entry.branchName) {
const localDir = join(SHARED_DIR, "drizzle-local", entry.branchName);
if (existsSync(localDir)) rmSync(localDir, { recursive: true, force: true });
}
delete registry[cwd];
saveRegistry(registry);
log(`tore down ${entry.schema}`);
log(`tore down ${entry.branchName ?? "worktree " + entry.worktreeNum}`);
if (!hasOtherActiveWorktrees(registry, cwd)) {
stopEmulateAndPortless();
} else {
log("other agent worktrees still active; leaving emulate + portless running");
}
}
function cmdList(): void {
@@ -477,28 +751,41 @@ function cmdList(): void {
console.log("(no registered worktrees)");
return;
}
const now = Date.now();
for (const e of entries) {
const offset = (e.worktreeNum - 1) * 100;
const lastUsed = e.lastUsedAt ?? e.createdAt;
const ageDays = Math.round((now - lastUsed) / (24 * 60 * 60 * 1000));
console.log(
` ${e.worktreeNum.toString().padStart(2)} | ${e.schema.padEnd(20)} | server :${8080 + offset} vite :${3000 + offset} | ${e.path}`,
` ${e.worktreeNum.toString().padStart(2)} | ${(
e.branchName ?? "(canonical)"
).padEnd(24)} | server :${8080 + offset} vite :${3000 + offset} | ${ageDays}d | ${e.path}`,
);
}
}
async function cmdReset(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) fatal("DATABASE_URL not set");
const cwd = getCurrentWorktree();
const registry = loadRegistry();
const entry = registry[cwd];
if (!entry || entry.worktreeNum === 1) {
fatal("reset only valid in a registered agent worktree");
}
dropSchema(entry.schema, databaseUrl);
const localDir = join(SHARED_DIR, "drizzle-local", entry.schema);
if (existsSync(localDir)) rmSync(localDir, { recursive: true, force: true });
log(`reset ${entry.schema}, re-running setup`);
await setupSchemaIfMissing(entry.schema, databaseUrl);
if (entry.branchName) deleteBranch(entry.branchName);
if (entry.branchName) {
const localDir = join(SHARED_DIR, "drizzle-local", entry.branchName);
if (existsSync(localDir)) rmSync(localDir, { recursive: true, force: true });
}
const cleared: RegistryEntry = {
...entry,
branchId: undefined,
databaseUrl: undefined,
lastUsedAt: Date.now(),
};
registry[cwd] = cleared;
saveRegistry(registry);
log(`reset ${entry.branchName ?? entry.path}, re-provisioning…`);
await setupAgentWorktree(cleared, registry);
}
async function main(): Promise<void> {

View File

@@ -1,20 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Idempotent emulate.dev launcher. Starts a single Google emulator
# instance shared by every agent worktree on this host.
# Idempotent daemonized launcher for the emulate.dev Google emulator + portless
# proxy. Shared host-wide across every agent worktree. Logs go to
# ~/.autumn-emulate.log, PID at ~/.autumn-emulate.pid.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEED="$ROOT/emulate.config.yaml"
LOG="$HOME/.autumn-emulate.log"
PID_FILE="$HOME/.autumn-emulate.pid"
if curl -sf -o /dev/null --max-time 1 "https://google.emulate.localhost/.well-known/openid-configuration"; then
echo "[emulate] google emulator already reachable at https://google.emulate.localhost"
reachable() {
curl -sf -o /dev/null --max-time 1 "https://google.emulate.localhost/.well-known/openid-configuration"
}
if reachable; then
echo "[emulate] already reachable at https://google.emulate.localhost"
exit 0
fi
if ! command -v npx >/dev/null 2>&1; then
echo "[emulate] npx not found; install Node.js / npm" >&2
if ! command -v bunx >/dev/null 2>&1; then
echo "[emulate] bunx not found; install bun" >&2
exit 1
fi
echo "[emulate] launching emulate (portless, google only) with seed $SEED"
exec npx --yes emulate --portless --service google --seed "$SEED"
if ! command -v portless >/dev/null 2>&1; then
echo "[emulate] installing portless globally via bun"
bun install -g portless >/dev/null
fi
if ! command -v emulate >/dev/null 2>&1; then
echo "[emulate] installing emulate globally via bun"
bun install -g emulate >/dev/null
fi
if ! curl -sf -o /dev/null --max-time 1 -k "https://localhost" 2>/dev/null; then
echo "[emulate] starting portless proxy (may prompt for sudo for port 443)"
portless proxy start
fi
# Clean up any stale pid file pointing at a dead process.
if [[ -f "$PID_FILE" ]]; then
if ! kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then
rm -f "$PID_FILE"
fi
fi
echo "[emulate] spawning emulate daemon → $LOG"
nohup emulate --portless --service google --seed "$SEED" \
>"$LOG" 2>&1 </dev/null &
echo $! >"$PID_FILE"
disown
# Block briefly until the emulator is actually serving so callers can race.
for _ in $(seq 1 30); do
if reachable; then
echo "[emulate] ready at https://google.emulate.localhost (pid $(cat "$PID_FILE"))"
exit 0
fi
sleep 0.3
done
echo "[emulate] emulator failed to come up within 9s; tail of $LOG:" >&2
tail -n 20 "$LOG" >&2 || true
exit 1

View File

@@ -4,6 +4,7 @@ import { ac, ALL_SCOPES, invitation, roles, schemas } from "@autumn/shared";
// emulate.dev Google: rewrite outbound Google OAuth host so agent worktrees
// can use any redirect URI without registering it in the real Google console.
// Real Google's oauth2.googleapis.com/token maps to emulate's /oauth2/token path.
if (
process.env.EMULATE_GOOGLE_URL &&
process.env.NODE_ENV !== "production"
@@ -19,7 +20,7 @@ if (
: (input as Request).url;
if (url.startsWith("https://oauth2.googleapis.com")) {
return originalFetch(
url.replace("https://oauth2.googleapis.com", emulate),
url.replace("https://oauth2.googleapis.com", `${emulate}/oauth2`),
init,
);
}
@@ -62,11 +63,25 @@ import { beforeSessionCreated } from "./authUtils/beforeSessionCreated.js";
import { ADMIN_USER_IDs } from "./constants.js";
import { getScopesForUserInOrg } from "./authUtils/customSessionScopes.js";
// HTTPS agent worktrees go through portless (e.g. wtN-api.localhost). The
// OAuth flow leaves and returns via a third-party host (emulate.dev), so the
// state cookie must be SameSite=None+Secure to survive the round trip.
const isHttpsBaseUrl = process.env.BETTER_AUTH_URL?.startsWith("https://");
const options = {
baseURL: process.env.BETTER_AUTH_URL,
telemetry: {
enabled: false,
},
...(isHttpsBaseUrl && {
advanced: {
useSecureCookies: true,
defaultCookieAttributes: {
sameSite: "none" as const,
secure: true,
},
},
}),
database: drizzleAdapter(db, {
provider: "pg",
@@ -123,7 +138,10 @@ const options = {
// Worktree ports follow worktreeOffset = (N-1)*100; accept any localhost
// port the running stack might use as origin.
const origin = request?.headers.get("origin") ?? null;
if (origin && /^https?:\/\/localhost:\d+$/.test(origin)) {
if (
origin &&
/^https?:\/\/(?:[a-zA-Z0-9-]+\.)*localhost(?::\d+)?$/.test(origin)
) {
origins.push(origin);
}
if (process.env.CLIENT_URL) origins.push(process.env.CLIENT_URL);

View File

@@ -17,12 +17,12 @@ export const ALLOWED_ORIGINS = [
"https://localhost:8080",
];
/** Allow any localhost origin in dev for multi-worktree support */
/** Allow any *.localhost or localhost origin in dev for multi-worktree support */
export const isAllowedOrigin = (origin: string): string | undefined => {
if (ALLOWED_ORIGINS.includes(origin)) return origin;
if (
process.env.NODE_ENV !== "production" &&
/^https?:\/\/localhost:\d+$/.test(origin)
/^https?:\/\/(?:[a-zA-Z0-9-]+\.)*localhost(?::\d+)?$/.test(origin)
) {
return origin;
}

2
shared/.gitignore vendored
View File

@@ -35,4 +35,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Per-worktree drizzle artifacts (created by `bun dw`)
drizzle-local/
.dw-migrate-*.config.ts
.dw-*.config.ts

View File

@@ -2,9 +2,27 @@ import path from "node:path";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function printPortlessUrl(): Plugin {
return {
name: "print-portless-url",
apply: "serve",
configureServer(server) {
const portlessUrl = process.env.VITE_FRONTEND_URL;
if (!portlessUrl) return;
const originalPrint = server.printUrls.bind(server);
server.printUrls = () => {
originalPrint();
server.config.logger.info(
` \x1b[32m➜\x1b[0m \x1b[1mPortless\x1b[0m: \x1b[36m${portlessUrl}/\x1b[0m`,
);
};
},
};
}
// https://vite.dev/config/
export default defineConfig({
define: {
@@ -22,6 +40,7 @@ export default defineConfig({
project: process.env.VITE_SENTRY_PROJECT,
telemetry: false,
}),
printPortlessUrl(),
],
resolve: {
@@ -74,10 +93,15 @@ export default defineConfig({
? Number.parseInt(process.env.VITE_PORT, 10)
: 3000,
strictPort: false, // Allow fallback to next available port
// Make the printed "Local:" URL reflect portless when available.
...(process.env.VITE_FRONTEND_URL && {
origin: process.env.VITE_FRONTEND_URL,
}),
allowedHosts: [
"dev.useautumn.com",
"client.dev.useautumn.com",
"localhost",
".localhost",
],
watch: {
usePolling: true, // Required for file watching in Docker on Windows