From 6e53558520c4091604145e41c4d056f3b76c2364 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 15 May 2026 23:38:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20v2=20dev=20worktrees=20o?= =?UTF-8?q?ver=20neon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emulate.config.yaml | 40 ++ scripts/dev.ts | 15 +- scripts/dw.ts | 755 ++++++++++++++++++++++---------- scripts/setup/start-emulate.sh | 61 ++- server/src/utils/auth.ts | 22 +- server/src/utils/corsOrigins.ts | 4 +- shared/.gitignore | 2 +- vite/vite.config.ts | 26 +- 8 files changed, 673 insertions(+), 252 deletions(-) diff --git a/emulate.config.yaml b/emulate.config.yaml index eac73bc99..271c546ec 100644 --- a/emulate.config.yaml +++ b/emulate.config.yaml @@ -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 diff --git a/scripts/dev.ts b/scripts/dev.ts index e408727f3..114d51ed8 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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", diff --git a/scripts/dw.ts b/scripts/dw.ts index c70c1a925..fb39d50b0 100644 --- a/scripts/dw.ts +++ b/scripts/dw.ts @@ -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; 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 } = {}, -): number { - const proc = Bun.spawnSync([cmd, ...args], { - cwd: opts.cwd, - env: opts.env ?? (process.env as Record), - 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, - schema: string, -): Record { - 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 { - 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), - 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), 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 { - 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), - 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 { - if (schemaExists(schema, databaseUrl)) { - log(`schema ${schema} already exists, skipping setup`); - return; +// ───────────────────────────────────────────────────────────── +// Env / portless / emulate plumbing +// ───────────────────────────────────────────────────────────── + +function rewriteDbEnv( + env: Record, + branchUrl: string, +): Record { + 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) }, - 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 { + 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 = { + ...(process.env as Record), + }; + 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 { 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 { - 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 { 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 { - 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 { diff --git a/scripts/setup/start-emulate.sh b/scripts/setup/start-emulate.sh index 8cf23350f..388b4deef 100644 --- a/scripts/setup/start-emulate.sh +++ b/scripts/setup/start-emulate.sh @@ -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 "$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 diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index 792dd46c0..3bf2d35bc 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -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); diff --git a/server/src/utils/corsOrigins.ts b/server/src/utils/corsOrigins.ts index 6bbd27474..89a36ad27 100644 --- a/server/src/utils/corsOrigins.ts +++ b/server/src/utils/corsOrigins.ts @@ -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; } diff --git a/shared/.gitignore b/shared/.gitignore index 1593847b5..bdb5cdf1e 100644 --- a/shared/.gitignore +++ b/shared/.gitignore @@ -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 diff --git a/vite/vite.config.ts b/vite/vite.config.ts index bd211d3cb..494f528f2 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -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