diff --git a/package.json b/package.json index 7c4588d6e..647989790 100644 --- a/package.json +++ b/package.json @@ -75,10 +75,10 @@ "t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/testScripts/testDispatcher.ts", "cm": "cd server && bun cm", "d": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dev.ts", - "dw": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw.ts", - "dw:teardown": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw.ts teardown", - "dw:list": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw.ts list", - "dw:reset": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw.ts reset", + "dw": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts", + "dw:teardown": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts teardown", + "dw:list": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts list", + "dw:reset": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts reset", "d:prod": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dev.ts --production", "dx": "bun scripts/dx.ts", "d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test --recursive -- bun scripts/dev.ts", diff --git a/scripts/dev.ts b/scripts/dev.ts index 114d51ed8..c108d8a90 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -18,7 +18,7 @@ const SERVER_PORT = process.env.SERVER_PORT const CHECKOUT_PORT = process.env.CHECKOUT_PORT ? Number.parseInt(process.env.CHECKOUT_PORT, 10) : 3001 + portOffset; -const skipWorkers = worktreeNum > 1; +const skipWorkers = false; const isProductionMode = process.argv.includes("--production"); const envFile = process.env.ENV_FILE ?? ".env"; @@ -147,7 +147,7 @@ async function startDev() { } if (worktreeNum > 1) { - console.log(`Starting worktree ${worktreeNum} (no workers)...\n`); + console.log(`Starting worktree ${worktreeNum}...\n`); } else if (isProductionMode) { console.log("Starting local servers with NODE_ENV=production...\n"); } else { @@ -203,7 +203,9 @@ async function startDev() { ? `"cd server && bun ${workersScript}"` : `"cd server && bun ${workersScript}"`, ); + } + if (worktreeNum === 1) { names.push("trigger"); colors.push("cyan"); cmds.push( @@ -224,6 +226,22 @@ async function startDev() { : `"cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`, ); + // Stripe CLI webhook tunnel — agent worktrees only, silently skip if CLI absent. + // Forwards to the direct localhost port (not portless) so we avoid CA trust issues. + if (worktreeNum > 1) { + const stripeAvailable = + Bun.spawnSync(["which", "stripe"]).exitCode === 0; + if (stripeAvailable) { + const forwardUrl = `http://localhost:${SERVER_PORT}/webhooks/connect/sandbox`; + names.push("stripe"); + colors.push("cyan"); + const stripeCmd = `stripe listen --forward-to ${forwardUrl} --skip-verify`; + cmds.push( + isWindows ? `"${stripeCmd}"` : `"${stripeCmd}"`, + ); + } + } + shellArgs = [ isWindows ? "cmd" : "sh", isWindows ? "/c" : "-c", @@ -255,6 +273,7 @@ async function startDev() { EMULATE_GOOGLE_URL: process.env.EMULATE_GOOGLE_URL ?? "https://google.emulate.localhost", + STRIPE_WEBHOOK_SKIP_VERIFY: "true", }), }, stdout: "inherit", diff --git a/scripts/dw.ts b/scripts/dw.ts deleted file mode 100644 index fb39d50b0..000000000 --- a/scripts/dw.ts +++ /dev/null @@ -1,812 +0,0 @@ -import { createHash } from "node:crypto"; -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -type RegistryEntry = { - path: string; - worktreeNum: number; - 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 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, ".."); -const SHARED_DIR = join(PROJECT_ROOT, "shared"); - -function log(msg: string): void { - console.log(`[dw] ${msg}`); -} - -function fatal(msg: string): never { - console.error(`[dw] ${msg}`); - process.exit(1); -} - -function sh( - cmd: string, - args: string[], - opts: { cwd?: string; env?: Record; stdin?: string } = {}, -): { stdout: string; stderr: string; code: number } { - const proc = Bun.spawnSync([cmd, ...args], { - cwd: opts.cwd, - env: opts.env ?? (process.env as Record), - stdin: opts.stdin ? new TextEncoder().encode(opts.stdin) : undefined, - stdout: "pipe", - stderr: "pipe", - }); - return { - stdout: new TextDecoder().decode(proc.stdout).trim(), - stderr: new TextDecoder().decode(proc.stderr).trim(), - code: proc.exitCode ?? 1, - }; -} - -function loadRegistry(): Registry { - if (!existsSync(REGISTRY_PATH)) return {}; - try { - return JSON.parse(readFileSync(REGISTRY_PATH, "utf-8")); - } catch { - log(`registry at ${REGISTRY_PATH} unreadable, resetting`); - return {}; - } -} - -function saveRegistry(reg: Registry): void { - writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2)); -} - -function getWorktreeList(): string[] { - const res = sh("git", ["worktree", "list", "--porcelain"], { - cwd: PROJECT_ROOT, - }); - if (res.code !== 0) return []; - return res.stdout - .split("\n") - .filter((l) => l.startsWith("worktree ")) - .map((l) => l.slice("worktree ".length).trim()); -} - -function getCanonicalWorktree(): string { - const list = getWorktreeList(); - return list[0] ?? PROJECT_ROOT; -} - -function getCurrentWorktree(): string { - const res = sh("git", ["rev-parse", "--show-toplevel"], { - cwd: PROJECT_ROOT, - }); - if (res.code !== 0) fatal("not inside a git worktree"); - return res.stdout; -} - -function shortHash(input: string): string { - return createHash("sha1").update(input).digest("hex").slice(0, 6); -} - -function allocateWorktreeNumber( - path: string, - registry: Registry, - canonical: string, -): number { - if (path === canonical) return 1; - const used = new Set( - Object.values(registry).map((e) => e.worktreeNum), - ); - used.add(1); - const preferred = (parseInt(shortHash(path), 16) % (MAX_WORKTREE - 1)) + 2; - for (let i = 0; i < MAX_WORKTREE; i++) { - const candidate = ((preferred - 2 + i) % (MAX_WORKTREE - 1)) + 2; - if (!used.has(candidate)) return candidate; - } - fatal(`no free worktree slot under ${MAX_WORKTREE}`); -} - -function deriveBranchName(path: string, worktreeNum: number): string { - return `dw-wt-${worktreeNum}-${shortHash(path)}`; -} - -// ───────────────────────────────────────────────────────────── -// 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 listBranches(): NeonBranch[] { - const res = neon([ - "branches", - "list", - "--project-id", - NEON_PROJECT_ID, - "--output", - "json", - ]); - if (res.code !== 0) { - 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 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(`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 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}`); - } -} - -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, -): void { - const outDir = join(SHARED_DIR, "drizzle-local", branchName); - if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }); - mkdirSync(outDir, { recursive: true }); - - const drizzleConfigPath = writeTempDrizzleConfig(outDir); - try { - log(`generating initial migration for ${branchName}`); - const gen = sh( - "bunx", - ["drizzle-kit", "generate", "--config", drizzleConfigPath], - { - 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 = 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 loadDbFunctions(branchName: string, databaseUrl: string): void { - log(`loading DB functions into ${branchName}`); - const sqlDir = join( - PROJECT_ROOT, - "server", - "src", - "internal", - "balances", - "utils", - "sql", - ); - 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}`, - ); - } - } -} - -// ───────────────────────────────────────────────────────────── -// 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}`, - ); - } -} - -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 { - const offset = (worktreeNum - 1) * 100; - const ports = [8080 + offset, 3000 + offset, 3001 + offset]; - if (process.platform === "win32") return; - const lsof = sh("lsof", ports.flatMap((p) => ["-ti", `:${p}`])); - const pids = lsof.stdout.split("\n").filter(Boolean); - for (const pid of pids) { - try { - process.kill(Number(pid), "SIGKILL"); - } catch {} - } - if (pids.length > 0) { - log(`killed ${pids.length} process(es) on ports ${ports.join(", ")}`); - } -} - -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}`, - ); - 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; - } - } - - 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", - "scripts/dev.ts", - "--worktree", - String(worktreeNum), - ...process.argv.slice(3), - ], - { - cwd: PROJECT_ROOT, - env, - stdout: "inherit", - stderr: "inherit", - }, - ); - - const forward = (sig: NodeJS.Signals) => () => proc.kill(sig); - process.on("SIGINT", forward("SIGINT")); - process.on("SIGTERM", forward("SIGTERM")); - - proc.exited.then((code) => process.exit(code ?? 0)); - return undefined as never; -} - -// ───────────────────────────────────────────────────────────── -// Commands -// ───────────────────────────────────────────────────────────── - -async function cmdDefault(): Promise { - if (process.env.NODE_ENV === "production") { - fatal("bun dw is disabled in production"); - } - - const canonical = getCanonicalWorktree(); - const cwd = getCurrentWorktree(); - let registry = loadRegistry(); - registry = reconcile(registry); - - let entry = registry[cwd]; - if (!entry) { - const worktreeNum = allocateWorktreeNumber(cwd, registry, canonical); - 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}${branchName ? ` (branch=${branchName})` : ""}`, - ); - } else { - entry.lastUsedAt = Date.now(); - registry[cwd] = entry; - saveRegistry(registry); - log( - `resuming worktree ${entry.worktreeNum}${entry.branchName ? ` (branch=${entry.branchName})` : ""}`, - ); - } - - if (entry.worktreeNum > 1) { - entry = await setupAgentWorktree(entry, registry); - ensureEmulateRunning(); - } - - killOwnPorts(entry.worktreeNum); - startDev(entry); -} - -async function cmdTeardown(opts: { all?: boolean }): Promise { - let registry = loadRegistry(); - - if (opts.all) { - 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); - stopEmulateAndPortless(); - log("teardown --all complete"); - return; - } - - const cwd = getCurrentWorktree(); - const entry = registry[cwd]; - if (!entry) { - log(`no registry entry for ${cwd}, nothing to teardown`); - return; - } - if (entry.worktreeNum === 1) { - fatal("refusing to teardown canonical worktree (worktreeNum=1)"); - } - 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.branchName ?? "worktree " + entry.worktreeNum}`); - - if (!hasOtherActiveWorktrees(registry, cwd)) { - stopEmulateAndPortless(); - } else { - log("other agent worktrees still active; leaving emulate + portless running"); - } -} - -function cmdList(): void { - const registry = loadRegistry(); - const entries = Object.values(registry).sort( - (a, b) => a.worktreeNum - b.worktreeNum, - ); - if (entries.length === 0) { - 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.branchName ?? "(canonical)" - ).padEnd(24)} | server :${8080 + offset} vite :${3000 + offset} | ${ageDays}d | ${e.path}`, - ); - } -} - -async function cmdReset(): Promise { - const cwd = getCurrentWorktree(); - const registry = loadRegistry(); - const entry = registry[cwd]; - if (!entry || entry.worktreeNum === 1) { - fatal("reset only valid in a registered agent worktree"); - } - 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 { - const sub = process.argv[2]; - if (!sub || sub.startsWith("--")) { - await cmdDefault(); - return; - } - switch (sub) { - case "teardown": - await cmdTeardown({ all: process.argv.includes("--all") }); - break; - case "list": - cmdList(); - break; - case "reset": - await cmdReset(); - break; - default: - fatal(`unknown subcommand: ${sub} (use: teardown | list | reset)`); - } -} - -await main(); diff --git a/scripts/dw/commands/attach.ts b/scripts/dw/commands/attach.ts new file mode 100644 index 000000000..9bc3bb00f --- /dev/null +++ b/scripts/dw/commands/attach.ts @@ -0,0 +1,18 @@ +import { fatal } from "../helpers/shell.ts"; +import { resolveAgentEntryOrFatal } from "../helpers/registry.ts"; +import { tmuxSessionName, ensureTmuxInstalled, tmuxSessionExists } from "../helpers/tmux.ts"; + +export function cmdAttach(): void { + const entry = resolveAgentEntryOrFatal("attach"); + const name = tmuxSessionName(entry.worktreeNum); + ensureTmuxInstalled(); + if (!tmuxSessionExists(name)) { + fatal(`no tmux session for ${name}, run 'bun dw' first`); + } + const proc = Bun.spawn(["tmux", "attach", "-t", name], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + proc.exited.then((code) => process.exit(code ?? 0)); +} diff --git a/scripts/dw/commands/default.ts b/scripts/dw/commands/default.ts new file mode 100644 index 000000000..ca53af4ba --- /dev/null +++ b/scripts/dw/commands/default.ts @@ -0,0 +1,62 @@ +import { fatal, log } from "../helpers/shell.ts"; +import { getCanonicalWorktree, getCurrentWorktree } from "../helpers/git.ts"; +import { + loadRegistry, + saveRegistry, + reconcile, + allocateWorktreeNumber, + deriveBranchName, +} from "../helpers/registry.ts"; +import { setupAgentWorktree, autoSetupTestOrg } from "../helpers/setup.ts"; +import { ensureComposeStack } from "../helpers/compose.ts"; +import { writeEnvLocalFiles } from "../helpers/env-files.ts"; +import { killOwnPorts } from "../helpers/ports.ts"; +import { startDev } from "../helpers/start.ts"; +import { ensureEmulateRunning } from "../helpers/emulate.ts"; + +export async function cmdDefault(): Promise { + if (process.env.NODE_ENV === "production") { + fatal("bun dw is disabled in production"); + } + + const canonical = getCanonicalWorktree(); + const cwd = getCurrentWorktree(); + let registry = loadRegistry(); + registry = reconcile(registry); + + let entry = registry[cwd]; + if (!entry) { + const worktreeNum = allocateWorktreeNumber(cwd, registry, canonical); + 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}${branchName ? ` (branch=${branchName})` : ""}`, + ); + } else { + entry.lastUsedAt = Date.now(); + registry[cwd] = entry; + saveRegistry(registry); + log( + `resuming worktree ${entry.worktreeNum}${entry.branchName ? ` (branch=${entry.branchName})` : ""}`, + ); + } + + if (entry.worktreeNum > 1) { + entry = await setupAgentWorktree(entry, registry); + ensureComposeStack(entry.worktreeNum); + writeEnvLocalFiles(entry); + await autoSetupTestOrg(entry); + ensureEmulateRunning(); + } + + killOwnPorts(entry.worktreeNum); + startDev(entry); +} diff --git a/scripts/dw/commands/list.ts b/scripts/dw/commands/list.ts new file mode 100644 index 000000000..9e1b59556 --- /dev/null +++ b/scripts/dw/commands/list.ts @@ -0,0 +1,23 @@ +import { loadRegistry } from "../helpers/registry.ts"; + +export function cmdList(): void { + const registry = loadRegistry(); + const entries = Object.values(registry).sort( + (a, b) => a.worktreeNum - b.worktreeNum, + ); + if (entries.length === 0) { + 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.branchName ?? "(canonical)" + ).padEnd(24)} | server :${8080 + offset} vite :${3000 + offset} | ${ageDays}d | ${e.path}`, + ); + } +} diff --git a/scripts/dw/commands/logs.ts b/scripts/dw/commands/logs.ts new file mode 100644 index 000000000..519a74bcd --- /dev/null +++ b/scripts/dw/commands/logs.ts @@ -0,0 +1,17 @@ +import { resolveAgentEntryOrFatal } from "../helpers/registry.ts"; +import { tmuxSessionName, ensureTmuxInstalled, tmuxSessionExists } from "../helpers/tmux.ts"; + +export function cmdLogs(): void { + const entry = resolveAgentEntryOrFatal("logs"); + const name = tmuxSessionName(entry.worktreeNum); + ensureTmuxInstalled(); + if (!tmuxSessionExists(name)) { + console.log(`(no tmux session for ${name})`); + process.exit(0); + } + const proc = Bun.spawn( + ["tmux", "capture-pane", "-t", name, "-p", "-S", "-2000"], + { stdout: "inherit", stderr: "inherit" }, + ); + proc.exited.then((code) => process.exit(code ?? 0)); +} diff --git a/scripts/dw/commands/reset.ts b/scripts/dw/commands/reset.ts new file mode 100644 index 000000000..133d0f5b2 --- /dev/null +++ b/scripts/dw/commands/reset.ts @@ -0,0 +1,39 @@ +import { log, fatal } from "../helpers/shell.ts"; +import { getCurrentWorktree } from "../helpers/git.ts"; +import { loadRegistry, saveRegistry } from "../helpers/registry.ts"; +import { deleteBranch } from "../helpers/neon.ts"; +import { tmuxSessionName, killTmuxSession } from "../helpers/tmux.ts"; +import { removeComposeStack } from "../helpers/compose.ts"; +import { removeEnvLocalFiles } from "../helpers/env-files.ts"; +import { setupAgentWorktree } from "../helpers/setup.ts"; +import { SHARED_DIR } from "../constants.ts"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import type { RegistryEntry } from "../types.ts"; + +export async function cmdReset(): Promise { + const cwd = getCurrentWorktree(); + const registry = loadRegistry(); + const entry = registry[cwd]; + if (!entry || entry.worktreeNum === 1) { + fatal("reset only valid in a registered agent worktree"); + } + killTmuxSession(tmuxSessionName(entry.worktreeNum)); + 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 }); + } + removeComposeStack(entry.worktreeNum); + removeEnvLocalFiles(); + 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); +} diff --git a/scripts/dw/commands/teardown.ts b/scripts/dw/commands/teardown.ts new file mode 100644 index 000000000..294369a73 --- /dev/null +++ b/scripts/dw/commands/teardown.ts @@ -0,0 +1,79 @@ +import { log, fatal } from "../helpers/shell.ts"; +import { getCurrentWorktree } from "../helpers/git.ts"; +import { + loadRegistry, + saveRegistry, + hasOtherActiveWorktrees, +} from "../helpers/registry.ts"; +import { deleteBranch } from "../helpers/neon.ts"; +import { unregisterPortlessAliases } from "../helpers/portless.ts"; +import { tmuxSessionName, killTmuxSession } from "../helpers/tmux.ts"; +import { removeComposeStack, removeAllAutumnComposeStacks } from "../helpers/compose.ts"; +import { removeEnvLocalFiles } from "../helpers/env-files.ts"; +import { stopEmulateAndPortless } from "../helpers/emulate.ts"; +import { SHARED_DIR } from "../constants.ts"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import type { Registry } from "../types.ts"; + +export async function cmdTeardown(opts: { all?: boolean }): Promise { + let registry = loadRegistry(); + + if (opts.all) { + for (const entry of Object.values(registry)) { + if (entry.worktreeNum === 1) continue; + if (entry.branchName) deleteBranch(entry.branchName); + unregisterPortlessAliases(entry.worktreeNum); + killTmuxSession(tmuxSessionName(entry.worktreeNum)); + if (entry.branchName) { + const localDir = join( + SHARED_DIR, + "drizzle-local", + entry.branchName, + ); + if (existsSync(localDir)) + rmSync(localDir, { recursive: true, force: true }); + } + } + removeAllAutumnComposeStacks(); + const next: Registry = {}; + for (const [p, e] of Object.entries(registry)) { + if (e.worktreeNum === 1) next[p] = e; + } + saveRegistry(next); + // Only the cwd's .env.local lives at PROJECT_ROOT; other worktrees own + // their own copy and aren't reachable from here. Acceptable trade-off. + removeEnvLocalFiles(); + stopEmulateAndPortless(); + log("teardown --all complete"); + return; + } + + const cwd = getCurrentWorktree(); + const entry = registry[cwd]; + if (!entry) { + log(`no registry entry for ${cwd}, nothing to teardown`); + return; + } + if (entry.worktreeNum === 1) { + fatal("refusing to teardown canonical worktree (worktreeNum=1)"); + } + if (entry.branchName) deleteBranch(entry.branchName); + unregisterPortlessAliases(entry.worktreeNum); + killTmuxSession(tmuxSessionName(entry.worktreeNum)); + removeComposeStack(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); + removeEnvLocalFiles(); + log(`tore down ${entry.branchName ?? "worktree " + entry.worktreeNum}`); + + if (!hasOtherActiveWorktrees(registry, cwd)) { + stopEmulateAndPortless(); + } else { + log("other agent worktrees still active; leaving emulate + portless running"); + } +} diff --git a/scripts/dw/constants.ts b/scripts/dw/constants.ts new file mode 100644 index 000000000..9956274fa --- /dev/null +++ b/scripts/dw/constants.ts @@ -0,0 +1,27 @@ +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const PROJECT_ROOT = resolve(SCRIPT_DIR, "../.."); +export const SHARED_DIR = join(PROJECT_ROOT, "shared"); + +export const REGISTRY_PATH = join(homedir(), ".autumn-worktrees.json"); +export const MAX_WORKTREE = 50; +export const BRANCH_NAME_RE = /^dw-wt-\d+-[a-f0-9]+$/; +export const INACTIVITY_MS = 7 * 24 * 60 * 60 * 1000; + +export const NEON_PROJECT_ID = "weathered-morning-43833874"; +export const NEON_TEMPLATE_BRANCH = "dw-template"; +export const NEON_PARENT_BRANCH = "production"; + +export const EMULATE_PID_FILE = join(homedir(), ".autumn-emulate.pid"); +export const EMULATE_HEALTH_URL = + "https://google.emulate.localhost/.well-known/openid-configuration"; +export const START_EMULATE_SH = join(SCRIPT_DIR, "../setup/start-emulate.sh"); + +export const ENV_LOCAL_TARGETS = [ + "server/.env.local", + "vite/.env.local", + "apps/checkout/.env.local", +] as const; diff --git a/scripts/dw/helpers/compose.ts b/scripts/dw/helpers/compose.ts new file mode 100644 index 000000000..f90bb2784 --- /dev/null +++ b/scripts/dw/helpers/compose.ts @@ -0,0 +1,98 @@ +import { join } from "node:path"; +import { sh, log } from "./shell.ts"; +import { composeProjectName, dragonflyPortFor, elasticMqPortFor } from "./ports.ts"; +import { SCRIPT_DIR } from "../constants.ts"; + +const composeFilePath = join(SCRIPT_DIR, "../setup/dw.compose.yml"); + +export function dockerComposeAvailable(): boolean { + const res = sh("docker", ["compose", "version"]); + return res.code === 0; +} + +export function ensureComposeStack(worktreeNum: number): void { + if (worktreeNum === 1) return; + if (!dockerComposeAvailable()) { + log("docker compose not available; skipping infra stack"); + return; + } + + const project = composeProjectName(worktreeNum); + const dragonflyPort = String(dragonflyPortFor(worktreeNum)); + const elasticMqPort = String(elasticMqPortFor(worktreeNum)); + + const env = { + ...(process.env as Record), + COMPOSE_PROJECT_NAME: project, + DRAGONFLY_PORT: dragonflyPort, + ELASTICMQ_PORT: elasticMqPort, + }; + + const up = sh( + "docker", + ["compose", "-f", composeFilePath, "-p", project, "up", "-d"], + { env }, + ); + if (up.code === 0) { + log( + `compose stack ${project} up (dragonfly :${dragonflyPort}, elasticmq :${elasticMqPort})`, + ); + } else { + console.error( + `[dw] failed to start compose stack ${project}: ${up.stderr}`, + ); + } +} + +export function removeComposeStack(worktreeNum: number): void { + if (worktreeNum === 1) return; + const project = composeProjectName(worktreeNum); + const down = sh("docker", [ + "compose", + "-f", + composeFilePath, + "-p", + project, + "down", + "-v", + ]); + if (down.code === 0) { + log(`removed compose stack ${project}`); + } +} + +export function removeAllAutumnComposeStacks(): void { + if (!dockerComposeAvailable()) return; + const ls = sh("docker", [ + "compose", + "ls", + "--filter", + "name=autumn-wt-", + "--format", + "json", + ]); + if (ls.code !== 0 || !ls.stdout) return; + try { + const projects = JSON.parse(ls.stdout) as { Name: string }[]; + for (const p of projects) { + const down = sh("docker", [ + "compose", + "-f", + composeFilePath, + "-p", + p.Name, + "down", + "-v", + ]); + if (down.code === 0) { + log(`removed compose stack ${p.Name}`); + } else { + console.error( + `[dw] failed to remove compose stack ${p.Name}: ${down.stderr}`, + ); + } + } + } catch { + /* JSON parse failed, ignore */ + } +} diff --git a/scripts/dw/helpers/emulate.ts b/scripts/dw/helpers/emulate.ts new file mode 100644 index 000000000..d28793a11 --- /dev/null +++ b/scripts/dw/helpers/emulate.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { sh, log } from "./shell.ts"; +import { + EMULATE_PID_FILE, + EMULATE_HEALTH_URL, + START_EMULATE_SH, +} from "../constants.ts"; + +function emulateReachable(): boolean { + const res = sh("curl", [ + "-sf", + "-o", + "/dev/null", + "--max-time", + "1", + EMULATE_HEALTH_URL, + ]); + return res.code === 0; +} + +export 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}`, + ); + } +} + +export 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; +} + +export 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; +} + +export 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"); +} diff --git a/scripts/dw/helpers/env-files.ts b/scripts/dw/helpers/env-files.ts new file mode 100644 index 000000000..12b89f69e --- /dev/null +++ b/scripts/dw/helpers/env-files.ts @@ -0,0 +1,127 @@ +import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { log } from "./shell.ts"; +import { forceSslVerifyFull } from "./url.ts"; +import { aliasesFor, dragonflyPortFor, elasticMqPortFor } from "./ports.ts"; +import { PROJECT_ROOT, ENV_LOCAL_TARGETS } from "../constants.ts"; +import type { RegistryEntry } from "../types.ts"; + +// Simple KEY=VALUE parse (no quoting/multiline). Sufficient for .env.local +// files we own end-to-end; preserves blank lines and comments untouched. +export function parseEnvFile(contents: string): { keys: string[]; values: Record; raw: string[] } { + const raw = contents.split(/\r?\n/); + const values: Record = {}; + const keys: string[] = []; + for (const line of raw) { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!m) continue; + const [, k, v] = m; + values[k] = v; + keys.push(k); + } + return { keys, values, raw }; +} + +export function mergeEnvFile(existing: string | null, managed: Record): string { + if (!existing) { + return Object.entries(managed) + .map(([k, v]) => `${k}=${v}`) + .join("\n") + "\n"; + } + const parsed = parseEnvFile(existing); + const managedKeys = new Set(Object.keys(managed)); + const outLines: string[] = []; + const seen = new Set(); + for (const line of parsed.raw) { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/); + if (m && managedKeys.has(m[1])) { + outLines.push(`${m[1]}=${managed[m[1]]}`); + seen.add(m[1]); + } else { + outLines.push(line); + } + } + for (const [k, v] of Object.entries(managed)) { + if (!seen.has(k)) outLines.push(`${k}=${v}`); + } + // Strip trailing empty lines, then re-add a single newline. + while (outLines.length > 0 && outLines[outLines.length - 1] === "") { + outLines.pop(); + } + return outLines.join("\n") + "\n"; +} + +export function writeEnvLocalFiles(entry: RegistryEntry): void { + const { worktreeNum, databaseUrl } = entry; + if (!databaseUrl) { + log("writeEnvLocalFiles: entry missing databaseUrl, skipping"); + return; + } + const aliases = aliasesFor(worktreeNum); + const serverPort = 8080 + (worktreeNum - 1) * 100; + const portlessCa = join(homedir(), ".portless", "ca.pem"); + + const dbUrl = forceSslVerifyFull(databaseUrl); + const serverEnv: Record = { + DATABASE_URL: dbUrl, + DATABASE_CRITICAL_URL: dbUrl, + BETTER_AUTH_URL: aliases.apiUrl, + CLIENT_URL: aliases.viteUrl, + EMULATE_GOOGLE_URL: "https://google.emulate.localhost", + AUTUMN_TEST_BASE_URL: `http://localhost:${serverPort}`, + AUTUMN_TEST_VITE_URL: aliases.viteUrl, + STRIPE_WEBHOOK_SKIP_VERIFY: "true", + }; + if (worktreeNum > 1) { + const dragonflyPort = dragonflyPortFor(worktreeNum); + const elasticMqPort = elasticMqPortFor(worktreeNum); + const redisUrl = `redis://localhost:${dragonflyPort}`; + serverEnv.REDIS_URL = redisUrl; + serverEnv.CACHE_URL = redisUrl; + serverEnv.CACHE_V2_DRAGONFLY_URL = redisUrl; + serverEnv.SQS_QUEUE_URL_V2 = `http://localhost:${elasticMqPort}/000000000000/autumn.fifo`; + serverEnv.TRACK_SQS_QUEUE_URL = `http://localhost:${elasticMqPort}/000000000000/autumn-track.fifo`; + serverEnv.AWS_ACCESS_KEY_ID = "test"; + serverEnv.AWS_SECRET_ACCESS_KEY = "test"; + } + if (existsSync(portlessCa)) { + serverEnv.NODE_EXTRA_CA_CERTS = portlessCa; + } + + const viteEnv: Record = { + VITE_BACKEND_URL: aliases.apiUrl, + VITE_FRONTEND_URL: aliases.viteUrl, + }; + + const checkoutEnv: Record = { + VITE_BACKEND_URL: aliases.apiUrl, + }; + + const writeOne = (relPath: string, managed: Record) => { + const abs = join(PROJECT_ROOT, relPath); + const dir = dirname(abs); + if (!existsSync(dir)) { + log(`writeEnvLocalFiles: skipping ${relPath} (dir ${dir} missing)`); + return; + } + const existing = existsSync(abs) ? readFileSync(abs, "utf-8") : null; + const merged = mergeEnvFile(existing, managed); + writeFileSync(abs, merged); + }; + + writeOne("server/.env.local", serverEnv); + writeOne("vite/.env.local", viteEnv); + writeOne("apps/checkout/.env.local", checkoutEnv); + log(`wrote .env.local for ${ENV_LOCAL_TARGETS.length} workspace(s)`); +} + +export function removeEnvLocalFiles(): void { + for (const rel of ENV_LOCAL_TARGETS) { + const abs = join(PROJECT_ROOT, rel); + if (existsSync(abs)) { + rmSync(abs, { force: true }); + log(`removed ${rel}`); + } + } +} diff --git a/scripts/dw/helpers/git.ts b/scripts/dw/helpers/git.ts new file mode 100644 index 000000000..407324b04 --- /dev/null +++ b/scripts/dw/helpers/git.ts @@ -0,0 +1,26 @@ +import { sh, fatal } from "./shell.ts"; +import { PROJECT_ROOT } from "../constants.ts"; + +export function getWorktreeList(): string[] { + const res = sh("git", ["worktree", "list", "--porcelain"], { + cwd: PROJECT_ROOT, + }); + if (res.code !== 0) return []; + return res.stdout + .split("\n") + .filter((l) => l.startsWith("worktree ")) + .map((l) => l.slice("worktree ".length).trim()); +} + +export function getCanonicalWorktree(): string { + const list = getWorktreeList(); + return list[0] ?? PROJECT_ROOT; +} + +export function getCurrentWorktree(): string { + const res = sh("git", ["rev-parse", "--show-toplevel"], { + cwd: PROJECT_ROOT, + }); + if (res.code !== 0) fatal("not inside a git worktree"); + return res.stdout; +} diff --git a/scripts/dw/helpers/migration.ts b/scripts/dw/helpers/migration.ts new file mode 100644 index 000000000..721d0a59b --- /dev/null +++ b/scripts/dw/helpers/migration.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { sh, fatal, log } from "./shell.ts"; +import { SHARED_DIR, PROJECT_ROOT } from "../constants.ts"; + +export 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")); +} + +export 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; +} + +export function generateAndApplyMigration( + branchName: string, + databaseUrl: string, +): void { + const outDir = join(SHARED_DIR, "drizzle-local", branchName); + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + + const drizzleConfigPath = writeTempDrizzleConfig(outDir); + try { + log(`generating initial migration for ${branchName}`); + const gen = sh( + "bunx", + ["drizzle-kit", "generate", "--config", drizzleConfigPath], + { + 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 = 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); + } +} + +export function loadDbFunctions(branchName: string, databaseUrl: string): void { + log(`loading DB functions into ${branchName}`); + const sqlDir = join( + PROJECT_ROOT, + "server", + "src", + "internal", + "balances", + "utils", + "sql", + ); + 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}`, + ); + } + } +} diff --git a/scripts/dw/helpers/neon.ts b/scripts/dw/helpers/neon.ts new file mode 100644 index 000000000..8e1517d8e --- /dev/null +++ b/scripts/dw/helpers/neon.ts @@ -0,0 +1,118 @@ +import { sh, fatal, log } from "./shell.ts"; +import { + NEON_PROJECT_ID, + NEON_TEMPLATE_BRANCH, + NEON_PARENT_BRANCH, + BRANCH_NAME_RE, +} from "../constants.ts"; +import type { NeonBranch } from "../types.ts"; + +function neon(args: string[]): { stdout: string; stderr: string; code: number } { + return sh("neon", args); +} + +export function listBranches(): NeonBranch[] { + const res = neon([ + "branches", + "list", + "--project-id", + NEON_PROJECT_ID, + "--output", + "json", + ]); + if (res.code !== 0) { + 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}`); + } +} + +export function findBranchByName(name: string): NeonBranch | undefined { + return listBranches().find((b) => b.name === name); +} + +export 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(`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}`); + } +} + +export 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}`); + } +} + +export 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(); +} + +export 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)`); +} diff --git a/scripts/dw/helpers/portless.ts b/scripts/dw/helpers/portless.ts new file mode 100644 index 000000000..67725f9a2 --- /dev/null +++ b/scripts/dw/helpers/portless.ts @@ -0,0 +1,32 @@ +import { sh, log } from "./shell.ts"; +import { aliasesFor } from "./ports.ts"; +import type { WorktreeAliases } from "../types.ts"; + +export 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}`, + ); + return aliases; +} + +export function unregisterPortlessAliases(worktreeNum: number): void { + for (const name of [`wt${worktreeNum}-api`, `wt${worktreeNum}`]) { + sh("portless", ["alias", "--remove", name]); + } +} diff --git a/scripts/dw/helpers/ports.ts b/scripts/dw/helpers/ports.ts new file mode 100644 index 000000000..79da5d4b4 --- /dev/null +++ b/scripts/dw/helpers/ports.ts @@ -0,0 +1,41 @@ +import { sh, log } from "./shell.ts"; +import type { WorktreeAliases } from "../types.ts"; + +export function dragonflyPortFor(worktreeNum: number): number { + return 6379 + (worktreeNum - 1) * 100; +} + +export function elasticMqPortFor(worktreeNum: number): number { + return 9324 + (worktreeNum - 1) * 100; +} + +export function composeProjectName(worktreeNum: number): string { + return `autumn-wt-${worktreeNum}`; +} + +export 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}`, + }; +} + +export function killOwnPorts(worktreeNum: number): void { + const offset = (worktreeNum - 1) * 100; + const ports = [8080 + offset, 3000 + offset, 3001 + offset]; + if (process.platform === "win32") return; + const lsof = sh("lsof", ports.flatMap((p) => ["-ti", `:${p}`])); + const pids = lsof.stdout.split("\n").filter(Boolean); + for (const pid of pids) { + try { + process.kill(Number(pid), "SIGKILL"); + } catch {} + } + if (pids.length > 0) { + log(`killed ${pids.length} process(es) on ports ${ports.join(", ")}`); + } +} diff --git a/scripts/dw/helpers/registry.ts b/scripts/dw/helpers/registry.ts new file mode 100644 index 000000000..64831373b --- /dev/null +++ b/scripts/dw/helpers/registry.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { log, fatal } from "./shell.ts"; +import { getWorktreeList, getCurrentWorktree } from "./git.ts"; +import { deleteBranch } from "./neon.ts"; +import { + REGISTRY_PATH, + MAX_WORKTREE, + BRANCH_NAME_RE, + INACTIVITY_MS, + SHARED_DIR, +} from "../constants.ts"; +import type { Registry, RegistryEntry } from "../types.ts"; + +export function shortHash(input: string): string { + return createHash("sha1").update(input).digest("hex").slice(0, 6); +} + +export function loadRegistry(): Registry { + if (!existsSync(REGISTRY_PATH)) return {}; + try { + return JSON.parse(readFileSync(REGISTRY_PATH, "utf-8")); + } catch { + log(`registry at ${REGISTRY_PATH} unreadable, resetting`); + return {}; + } +} + +export function saveRegistry(reg: Registry): void { + writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2)); +} + +export function allocateWorktreeNumber( + path: string, + registry: Registry, + canonical: string, +): number { + if (path === canonical) return 1; + const used = new Set( + Object.values(registry).map((e) => e.worktreeNum), + ); + used.add(1); + const preferred = (parseInt(shortHash(path), 16) % (MAX_WORKTREE - 1)) + 2; + for (let i = 0; i < MAX_WORKTREE; i++) { + const candidate = ((preferred - 2 + i) % (MAX_WORKTREE - 1)) + 2; + if (!used.has(candidate)) return candidate; + } + fatal(`no free worktree slot under ${MAX_WORKTREE}`); +} + +export function deriveBranchName(path: string, worktreeNum: number): string { + return `dw-wt-${worktreeNum}-${shortHash(path)}`; +} + +export 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; + } + } + + 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; +} + +export function hasOtherActiveWorktrees( + registry: Registry, + currentPath: string, +): boolean { + return Object.entries(registry).some( + ([p, e]) => p !== currentPath && e.worktreeNum > 1, + ); +} + +export function resolveAgentEntryOrFatal(action: string): RegistryEntry { + const cwd = getCurrentWorktree(); + const registry = loadRegistry(); + const entry = registry[cwd]; + if (!entry || entry.worktreeNum === 1) { + fatal(`${action} only valid in a registered agent worktree`); + } + return entry; +} diff --git a/scripts/dw/helpers/setup.ts b/scripts/dw/helpers/setup.ts new file mode 100644 index 000000000..e848223a5 --- /dev/null +++ b/scripts/dw/helpers/setup.ts @@ -0,0 +1,79 @@ +import { log, fatal, shInherit } from "./shell.ts"; +import { + ensureTemplateBranch, + createBranch, + connectionString, + findBranchByName, +} from "./neon.ts"; +import { generateAndApplyMigration, loadDbFunctions } from "./migration.ts"; +import { loadRegistry, saveRegistry } from "./registry.ts"; +import { PROJECT_ROOT, NEON_TEMPLATE_BRANCH } from "../constants.ts"; +import type { RegistryEntry } from "../types.ts"; + +export async function setupAgentWorktree( + entry: RegistryEntry, + registry: Record, +): 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; +} + +// Auto-seed unit test org into the per-worktree Neon branch. +// setup-test is idempotent so we always invoke it on dw default; +// failures are non-fatal (log + continue) since downstream dev +// might still be useful without the seed. +export async function autoSetupTestOrg(entry: RegistryEntry): Promise { + if (!entry.databaseUrl) { + log("autoSetupTestOrg: no databaseUrl on entry, skipping"); + return; + } + log(`seeding unit test org in ${entry.branchName ?? "worktree"}`); + const code = shInherit( + "bun", + ["scripts/setup/setup-test.ts", "--yes"], + { + cwd: PROJECT_ROOT, + env: { + ...(process.env as Record), + DATABASE_URL: entry.databaseUrl, + DATABASE_CRITICAL_URL: entry.databaseUrl, + }, + }, + ); + if (code !== 0) { + console.error(`[dw] setup-test exited with code ${code}; continuing`); + } +} diff --git a/scripts/dw/helpers/shell.ts b/scripts/dw/helpers/shell.ts new file mode 100644 index 000000000..abaa9c1fe --- /dev/null +++ b/scripts/dw/helpers/shell.ts @@ -0,0 +1,44 @@ +import Bun from "bun"; + +export function log(msg: string): void { + console.log(`[dw] ${msg}`); +} + +export function fatal(msg: string): never { + console.error(`[dw] ${msg}`); + process.exit(1); +} + +export function sh( + cmd: string, + args: string[], + opts: { cwd?: string; env?: Record; stdin?: string } = {}, +): { stdout: string; stderr: string; code: number } { + const proc = Bun.spawnSync([cmd, ...args], { + cwd: opts.cwd, + env: opts.env ?? (process.env as Record), + stdin: opts.stdin ? new TextEncoder().encode(opts.stdin) : undefined, + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: new TextDecoder().decode(proc.stdout).trim(), + stderr: new TextDecoder().decode(proc.stderr).trim(), + code: proc.exitCode ?? 1, + }; +} + +// Like sh() but streams stdio so the caller can watch progress in real time. +export 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; +} diff --git a/scripts/dw/helpers/start.ts b/scripts/dw/helpers/start.ts new file mode 100644 index 000000000..b40c33765 --- /dev/null +++ b/scripts/dw/helpers/start.ts @@ -0,0 +1,79 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { log, fatal } from "./shell.ts"; +import { registerPortlessAliases } from "./portless.ts"; +import { rewriteDbEnv } from "./url.ts"; +import { aliasesFor, killOwnPorts } from "./ports.ts"; +import { tmuxSessionName, spawnDevInTmux } from "./tmux.ts"; +import { PROJECT_ROOT } from "../constants.ts"; +import type { RegistryEntry } from "../types.ts"; + +export function buildDevEnvAndArgs(entry: RegistryEntry): { + env: Record; + args: string[]; +} { + const { worktreeNum, 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; + } + + const args = [ + "bun", + "scripts/dev.ts", + "--worktree", + String(worktreeNum), + ...process.argv.slice(3), + ]; + return { env, args }; +} + +export function startDev(entry: RegistryEntry): never { + const { worktreeNum, branchName } = entry; + const { env, args } = buildDevEnvAndArgs(entry); + + // Agent worktrees (N > 1) in a non-TTY invocation: wrap in detached tmux + // so the calling agent doesn't block. Canonical (N=1) stays inline always. + // Node/Bun sets isTTY to true when stdout is a TTY and undefined otherwise. + const useTmux = worktreeNum > 1 && !process.stdout.isTTY; + if (useTmux) { + log( + `starting dev in tmux (worktree=${worktreeNum}${branchName ? `, branch=${branchName}` : ""}, non-TTY)`, + ); + spawnDevInTmux(tmuxSessionName(worktreeNum), env, args, PROJECT_ROOT); + process.exit(0); + } + + log( + `starting dev (worktree=${worktreeNum}${branchName ? `, branch=${branchName}` : ""})`, + ); + const proc = Bun.spawn(args, { + cwd: PROJECT_ROOT, + env, + stdout: "inherit", + stderr: "inherit", + }); + + const forward = (sig: NodeJS.Signals) => () => proc.kill(sig); + process.on("SIGINT", forward("SIGINT")); + process.on("SIGTERM", forward("SIGTERM")); + + proc.exited.then((code) => process.exit(code ?? 0)); + return undefined as never; +} diff --git a/scripts/dw/helpers/tmux.ts b/scripts/dw/helpers/tmux.ts new file mode 100644 index 000000000..cf2e45bc8 --- /dev/null +++ b/scripts/dw/helpers/tmux.ts @@ -0,0 +1,76 @@ +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, + 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')`); +} diff --git a/scripts/dw/helpers/url.ts b/scripts/dw/helpers/url.ts new file mode 100644 index 000000000..e1baebea5 --- /dev/null +++ b/scripts/dw/helpers/url.ts @@ -0,0 +1,22 @@ +export function forceSslVerifyFull(url: string): string { + try { + const u = new URL(url); + u.searchParams.set("sslmode", "verify-full"); + return u.toString(); + } catch { + return url; + } +} + +export function rewriteDbEnv( + env: Record, + branchUrl: string, +): Record { + const out = { ...env }; + const dbUrl = forceSslVerifyFull(branchUrl); + out.DATABASE_URL = dbUrl; + out.DATABASE_CRITICAL_URL = dbUrl; + // Replica URL stays unset for agent branches (read from primary). + delete out.DATABASE_REPLICA_URL; + return out; +} diff --git a/scripts/dw/index.ts b/scripts/dw/index.ts new file mode 100644 index 000000000..cba4c6739 --- /dev/null +++ b/scripts/dw/index.ts @@ -0,0 +1,38 @@ +import { fatal } from "./helpers/shell.ts"; +import { cmdDefault } from "./commands/default.ts"; +import { cmdTeardown } from "./commands/teardown.ts"; +import { cmdList } from "./commands/list.ts"; +import { cmdReset } from "./commands/reset.ts"; +import { cmdLogs } from "./commands/logs.ts"; +import { cmdAttach } from "./commands/attach.ts"; + +async function main(): Promise { + const sub = process.argv[2]; + if (!sub || sub.startsWith("--")) { + await cmdDefault(); + return; + } + switch (sub) { + case "teardown": + await cmdTeardown({ all: process.argv.includes("--all") }); + break; + case "list": + cmdList(); + break; + case "reset": + await cmdReset(); + break; + case "logs": + cmdLogs(); + break; + case "attach": + cmdAttach(); + break; + default: + fatal( + `unknown subcommand: ${sub} (use: teardown | list | reset | logs | attach)`, + ); + } +} + +await main(); diff --git a/scripts/dw/types.ts b/scripts/dw/types.ts new file mode 100644 index 000000000..d7714887e --- /dev/null +++ b/scripts/dw/types.ts @@ -0,0 +1,24 @@ +export type RegistryEntry = { + path: string; + worktreeNum: number; + createdAt: number; + branchId?: string; + branchName?: string; + databaseUrl?: string; + lastUsedAt?: number; +}; + +export type Registry = Record; + +export type NeonBranch = { + id: string; + name: string; + created_at?: string; +}; + +export type WorktreeAliases = { + apiHost: string; + apiUrl: string; + viteHost: string; + viteUrl: string; +}; diff --git a/scripts/preload-env.ts b/scripts/preload-env.ts index 2a5fc5c7a..98d24093d 100644 --- a/scripts/preload-env.ts +++ b/scripts/preload-env.ts @@ -1,5 +1,39 @@ // Preload script - runs BEFORE main script imports are evaluated -// This allows local .env to override Infisical secrets +// This allows local .env to override Infisical secrets. +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadLocalEnv } from "@server/utils/envUtils.js"; loadLocalEnv(); + +// Worktree-aware: `bun dw` writes per-worktree `.env.local` files to each +// workspace dir. infisical run + server/.env stop short of these, so things +// like AUTUMN_TEST_BASE_URL and DATABASE_URL never get the worktree value. +// Loading here covers `bun t`, `bun cm`, `bun setup-test`, and any direct +// `bun test` invocation from the repo root. Missing files = no-op (canonical +// repo has none of these). +const __preloadRoot = resolve( + fileURLToPath(new URL(".", import.meta.url)), + "..", +); +for (const rel of [ + "server/.env.local", + "vite/.env.local", + "apps/checkout/.env.local", +]) { + const abs = join(__preloadRoot, rel); + if (!existsSync(abs)) continue; + const contents = readFileSync(abs, "utf-8"); + let loadedCount = 0; + for (const line of contents.split(/\r?\n/)) { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!m) continue; + // Worktree-local overrides win over infisical + server/.env. + process.env[m[1]] = m[2]; + loadedCount++; + } + if (loadedCount > 0) { + console.log(`[preload] loaded ${rel} — AUTUMN_TEST_BASE_URL=${process.env.AUTUMN_TEST_BASE_URL || "(unset)"}`); + } +} diff --git a/scripts/setup/dw.compose.yml b/scripts/setup/dw.compose.yml new file mode 100644 index 000000000..e291eeab5 --- /dev/null +++ b/scripts/setup/dw.compose.yml @@ -0,0 +1,23 @@ +# Per-worktree infra stack for `bun dw`. +# Project name is set per-worktree via COMPOSE_PROJECT_NAME (e.g. autumn-wt-8). +# Ports are substituted from env vars (DRAGONFLY_PORT, ELASTICMQ_PORT). + +services: + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + container_name: ${COMPOSE_PROJECT_NAME}-dragonfly + ports: + - "${DRAGONFLY_PORT}:6379" + ulimits: + memlock: -1 + restart: unless-stopped + + elasticmq: + image: softwaremill/elasticmq-native:latest + container_name: ${COMPOSE_PROJECT_NAME}-elasticmq + ports: + - "${ELASTICMQ_PORT}:9324" + volumes: + - ./elasticmq.conf:/opt/elasticmq.conf:ro + command: ["-Dconfig.file=/opt/elasticmq.conf"] + restart: unless-stopped diff --git a/scripts/setup/elasticmq.conf b/scripts/setup/elasticmq.conf new file mode 100644 index 000000000..087eea779 --- /dev/null +++ b/scripts/setup/elasticmq.conf @@ -0,0 +1,28 @@ +include classpath("application.conf") + +node-address { + protocol = http + host = "*" + port = 9324 + context-path = "" +} + +rest-sqs { + enabled = true + bind-port = 9324 + bind-hostname = "0.0.0.0" + sqs-limits = strict +} + +generate-node-address = false + +queues { + "autumn.fifo" { + fifo = true + contentBasedDeduplication = true + } + "autumn-track.fifo" { + fifo = true + contentBasedDeduplication = true + } +} diff --git a/scripts/setup/setup-test.ts b/scripts/setup/setup-test.ts index 354d75a91..3368eddfa 100644 --- a/scripts/setup/setup-test.ts +++ b/scripts/setup/setup-test.ts @@ -6,96 +6,69 @@ import { TEST_ORG_CONFIG, } from "../setupTestUtils/createTestOrg.js"; -async function showPreparationChecklist() { - console.log( - chalk.magentaBright( - "\n================ Autumn Test Setup ================\n", - ), - ); - console.log( - chalk.cyan( - "This script will set up a test organization for development.\n", - ), - ); - console.log( - chalk.yellowBright( - "Before you begin, ensure the following are configured in your environment:\n", - ), - ); +// Worktree .env.local loading happens in scripts/preload-env.ts (auto-run by +// Bun via bunfig.toml's `preload`). DATABASE_URL flips to the worktree branch +// before this module's top-level statements execute. - console.log(chalk.whiteBright("1. Stripe Sandbox Environment Variables")); - console.log(chalk.gray(" Required in your .env file:")); - console.log(chalk.gray(" → STRIPE_SANDBOX_CLIENT_ID")); - console.log(chalk.gray(" → STRIPE_SANDBOX_SECRET_KEY")); - console.log(chalk.gray(" → STRIPE_SANDBOX_WEBHOOK_SECRET\n")); +function maskDatabaseUrl(url: string | undefined): string { + if (!url) return "(unset)"; + try { + const u = new URL(url); + const host = u.host; + const db = u.pathname.replace(/^\//, ""); + return `${u.protocol}//***@${host}/${db}`; + } catch { + return "(unparseable)"; + } +} - console.log(chalk.whiteBright("2. Stripe Webhook URL")); - console.log( - chalk.gray( - " → A tunnel URL (e.g., ngrok) pointing to localhost:8080 for webhooks\n", - ), - ); - - console.log(chalk.whiteBright("3. Cache URL")); - console.log(chalk.gray(" → Redis on your machine\n")); - - // Prompt user to continue +async function maybeConfirm(yes: boolean): Promise { + if (yes) return true; + if (!process.stdin.isTTY) return true; + const target = maskDatabaseUrl(process.env.DATABASE_URL); const { ready } = await inquirer.prompt([ { type: "confirm", name: "ready", message: chalk.cyan( - "Have you configured all the above? Ready to create test org?", + `About to seed '${TEST_ORG_CONFIG.slug}' into DATABASE_URL=${target}. Continue?`, ), default: true, }, ]); - - if (!ready) { - console.log( - chalk.yellow( - "\nSetup cancelled. Run the script again when you're ready!\n", - ), - ); - process.exit(0); - } + return Boolean(ready); } async function main() { - // Show preparation checklist - await showPreparationChecklist(); + const yes = process.argv.includes("--yes"); + console.log( + chalk.magentaBright( + `\n================ Autumn setup-test ================\n`, + ), + ); + console.log( + chalk.cyan(`Target: ${maskDatabaseUrl(process.env.DATABASE_URL)}\n`), + ); + + const proceed = await maybeConfirm(yes); + if (!proceed) { + console.log(chalk.yellow("Cancelled.")); + process.exit(0); + } try { - // Import db from server const { db } = await import("@server/db/initDrizzle.js"); - - // Create test organization in database and get API key const autumnSecretKey = await createTestOrg({ db }); - console.log( - chalk.magentaBright( - "\n================ Setup Complete! ================\n", - ), - ); - console.log(chalk.greenBright("🎉 Test organization setup complete! 🎉\n")); - console.log(chalk.cyan("Test Organization Details:")); - console.log(chalk.whiteBright(` Organization: ${TEST_ORG_CONFIG.slug}`)); - console.log(chalk.whiteBright(` ID: ${TEST_ORG_CONFIG.id}`)); - console.log(chalk.whiteBright(` Secret Key: ${autumnSecretKey}\n`)); - - console.log(chalk.cyan("Next steps:")); - console.log( - chalk.whiteBright("1. Start your tunnel (e.g., ngrok http 8080)"), - ); - console.log(chalk.whiteBright("2. Start your development server")); - console.log( - chalk.whiteBright("3. Run tests with your new test organization!\n"), - ); - + console.log(chalk.greenBright("\n✅ setup-test complete")); + console.log(chalk.cyan("Org:")); + console.log(chalk.whiteBright(` slug: ${TEST_ORG_CONFIG.slug}`)); + console.log(chalk.whiteBright(` id: ${TEST_ORG_CONFIG.id}`)); + console.log(chalk.whiteBright(` key: ${autumnSecretKey}\n`)); process.exit(0); } catch (error) { console.error( - chalk.red("\n❌ Setup failed:"), + chalk.red("\n❌ setup-test failed:"), error instanceof Error ? error.message : error, ); process.exit(1); diff --git a/scripts/setup/writeAgentEnv.ts b/scripts/setup/writeAgentEnv.ts index c19ee8047..8a09837fb 100644 --- a/scripts/setup/writeAgentEnv.ts +++ b/scripts/setup/writeAgentEnv.ts @@ -1,3 +1,6 @@ +// NOTE: For worktree-based agent flows, bun dw writes .env.local directly via +// writeEnvLocalFiles in scripts/dw.ts. This file is retained for the legacy +// agent-bootstrap.sh path only. import { randomBytes } from "node:crypto"; import { copyFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; diff --git a/scripts/setupTestUtils/createTestOrg.ts b/scripts/setupTestUtils/createTestOrg.ts index a30ff74ad..30af8ec5c 100644 --- a/scripts/setupTestUtils/createTestOrg.ts +++ b/scripts/setupTestUtils/createTestOrg.ts @@ -1,14 +1,21 @@ import { AppEnv, + invitation, member, type OrgConfig, organizations, user, } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; -import { createHardcodedKey } from "@server/internal/dev/api-keys/apiKeyUtils.js"; +import { + createHardcodedKey, + createKey, +} from "@server/internal/dev/api-keys/apiKeyUtils.js"; import chalk from "chalk"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; +import { clearOrgDbOnly } from "@tests/utils/setup/clearOrg.js"; +import { setupOrg } from "@tests/utils/setup/setupOrg.js"; +import { ensureDefaultStripeAccount } from "./ensureDefaultStripeAccount.js"; const TEST_ORG_CONFIG = { id: "org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt", @@ -18,6 +25,22 @@ const TEST_ORG_CONFIG = { created_at: 1738583937426, }; +// Synthetic inviter pinned to the test org; satisfies invitation.inviter_id +// NOT-NULL FK without needing a real human user in a fresh worktree branch. +const TEST_INVITER_USER = { + id: "user_setup_test_inviter", + name: "Setup Test Inviter", + email: "setup-test-inviter@autumn.test", +}; + +const TEAM_INVITE_EMAILS = [ + "ayush@useautumn.com", + "jy@useautumn.com", + "tanvir@useautumn.com", + "charlie@useautumn.com", + "owen@useautumn.com", +]; + /** * Creates a test organization in the database and generates an API key */ @@ -33,11 +56,6 @@ export async function createTestOrg({ ); const TEST_API_KEY = process.env.UNIT_TEST_AUTUMN_SECRET_KEY; - if (!TEST_API_KEY) { - throw new Error( - "UNIT_TEST_AUTUMN_SECRET_KEY is not set (is infisical running?)", - ); - } // Check if org already exists const existingOrg = await db.query.organizations.findFirst({ @@ -51,27 +69,52 @@ export async function createTestOrg({ ), ); - // Create API key for existing org (will skip if already exists) - const { key, alreadyExists } = await createHardcodedKey({ + await seedTeamInvites({ db }); + await ensureDefaultStripeAccount({ db, orgId: TEST_ORG_CONFIG.id }); + await clearOrgDbOnly({ db, orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox }); + await setupOrg({ orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox }); + + if (TEST_API_KEY) { + const { key, alreadyExists } = await createHardcodedKey({ + db, + env: AppEnv.Sandbox, + name: "Unit Test Key", + orgId: TEST_ORG_CONFIG.id, + hardcodedKey: TEST_API_KEY, + meta: { + createdBy: "setup-test-script", + createdAt: new Date().toISOString(), + }, + }); + console.log( + chalk.greenBright( + alreadyExists + ? "✅ API key already exists in database" + : "✅ Created API key for existing organization", + ), + ); + return key; + } + + // No hardcoded key in env; generate a fresh one for the existing org. + const generated = await createKey({ db, env: AppEnv.Sandbox, name: "Unit Test Key", orgId: TEST_ORG_CONFIG.id, - hardcodedKey: TEST_API_KEY, + prefix: "am_sk_test_", meta: { createdBy: "setup-test-script", createdAt: new Date().toISOString(), + autogenerated: true, }, }); - - if (alreadyExists) { - console.log(chalk.greenBright("✅ API key already exists in database")); - } else { - console.log( - chalk.greenBright("✅ Created API key for existing organization"), - ); - } - return key; + console.log( + chalk.greenBright( + "✅ Generated fresh API key (UNIT_TEST_AUTUMN_SECRET_KEY unset)", + ), + ); + return generated; } // Create the test organization @@ -95,55 +138,135 @@ export async function createTestOrg({ ), ); - // Get first 5 users from database and create memberships - const users = await db.select().from(user).limit(5); - - if (users.length > 0) { - const { generateId } = await import("@server/utils/genUtils.js"); - - const memberships = users.map((u) => ({ - id: generateId("mem"), - organizationId: TEST_ORG_CONFIG.id, - userId: u.id, - role: "owner", - createdAt: new Date(), - })); - - await db.insert(member).values(memberships); - - console.log( - chalk.greenBright( - `✅ Created ${memberships.length} membership(s) for test organization`, - ), - ); - } else { - console.log( - chalk.yellowBright( - "⚠ No users found in database. Skipping membership creation.", - ), - ); - } + await seedTeamInvites({ db }); + await ensureDefaultStripeAccount({ db, orgId: TEST_ORG_CONFIG.id }); + await clearOrgDbOnly({ db, orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox }); + await setupOrg({ orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox }); // Create API key for the new org - const { key, alreadyExists } = await createHardcodedKey({ + if (TEST_API_KEY) { + const { key, alreadyExists } = await createHardcodedKey({ + db, + env: AppEnv.Sandbox, + name: "Unit Test Key", + orgId: TEST_ORG_CONFIG.id, + hardcodedKey: TEST_API_KEY, + meta: { + createdBy: "setup-test-script", + createdAt: new Date().toISOString(), + }, + }); + console.log( + chalk.greenBright( + alreadyExists + ? "✅ API key already exists in database" + : "✅ Created API key for test organization", + ), + ); + return key; + } + + const generated = await createKey({ db, env: AppEnv.Sandbox, name: "Unit Test Key", orgId: TEST_ORG_CONFIG.id, - hardcodedKey: TEST_API_KEY, + prefix: "am_sk_test_", meta: { createdBy: "setup-test-script", createdAt: new Date().toISOString(), + autogenerated: true, }, }); - - if (alreadyExists) { - console.log(chalk.greenBright("✅ API key already exists in database")); - } else { - console.log(chalk.greenBright("✅ Created API key for test organization")); - } - - return key; + console.log( + chalk.greenBright( + "✅ Generated fresh API key (UNIT_TEST_AUTUMN_SECRET_KEY unset)", + ), + ); + return generated; } export { TEST_ORG_CONFIG }; + +// Per-email: insert a member row if a user already exists, otherwise insert +// an invitation row. Both paths skip if a matching member/invitation already +// targets this org+email — safe to re-run. Note: better-auth does NOT auto- +// accept invitations on sign-in. Invitees must visit /accept?id= (or +// equivalent UI) after signing in to finish joining the org. +async function seedTeamInvites({ db }: { db: DrizzleCli }): Promise { + const { generateId } = await import("@server/utils/genUtils.js"); + + // Ensure a synthetic inviter exists so invitation.inviter_id FK resolves. + await db + .insert(user) + .values({ + id: TEST_INVITER_USER.id, + name: TEST_INVITER_USER.name, + email: TEST_INVITER_USER.email, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing(); + + const existingUsers = await db + .select() + .from(user) + .where(inArray(user.email, TEAM_INVITE_EMAILS)); + const userByEmail = new Map(existingUsers.map((u) => [u.email, u])); + + const existingMembers = await db + .select() + .from(member) + .where(eq(member.organizationId, TEST_ORG_CONFIG.id)); + const membershipUserIds = new Set(existingMembers.map((m) => m.userId)); + + const existingInvitesForOrg = await db + .select() + .from(invitation) + .where( + and( + eq(invitation.organizationId, TEST_ORG_CONFIG.id), + inArray(invitation.email, TEAM_INVITE_EMAILS), + ), + ); + const inviteEmails = new Set(existingInvitesForOrg.map((i) => i.email)); + + let membersCreated = 0; + let invitesCreated = 0; + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + for (const email of TEAM_INVITE_EMAILS) { + const existingUser = userByEmail.get(email); + if (existingUser) { + if (membershipUserIds.has(existingUser.id)) continue; + await db.insert(member).values({ + id: generateId("mem"), + organizationId: TEST_ORG_CONFIG.id, + userId: existingUser.id, + role: "owner", + createdAt: new Date(), + }); + membersCreated++; + } else { + if (inviteEmails.has(email)) continue; + await db.insert(invitation).values({ + id: generateId("inv"), + organizationId: TEST_ORG_CONFIG.id, + email, + role: "owner", + status: "pending", + createdAt: new Date(), + expiresAt, + inviterId: TEST_INVITER_USER.id, + }); + invitesCreated++; + } + } + + console.log( + chalk.greenBright( + `✅ Team seed: ${membersCreated} member(s) + ${invitesCreated} invitation(s) for ${TEAM_INVITE_EMAILS.length} email(s)`, + ), + ); +} diff --git a/scripts/setupTestUtils/ensureDefaultStripeAccount.ts b/scripts/setupTestUtils/ensureDefaultStripeAccount.ts new file mode 100644 index 000000000..837083e81 --- /dev/null +++ b/scripts/setupTestUtils/ensureDefaultStripeAccount.ts @@ -0,0 +1,59 @@ +import chalk from "chalk"; +import type { DrizzleCli } from "@server/db/initDrizzle.js"; +import { OrgService } from "@server/internal/orgs/OrgService.js"; +import { createConnectAccount } from "@server/internal/orgs/orgUtils/createConnectAccount.js"; + +const DUMMY_USER = { + id: "setup-test-stripe-user", + email: "setup-test@autumn.test", + name: "Setup Test User", +}; + +/** + * Idempotently ensure the test org has a default Stripe Connect sandbox + * account. Creates one only if `test_stripe_connect.default_account_id` + * is missing. + */ +export async function ensureDefaultStripeAccount({ + db, + orgId, +}: { + db: DrizzleCli; + orgId: string; +}): Promise { + const org = await OrgService.get({ db, orgId }); + + const existingAccountId = org.test_stripe_connect?.default_account_id; + if (existingAccountId) { + console.log( + chalk.yellowBright( + `Stripe default account already connected (${existingAccountId}). Skipping.`, + ), + ); + return; + } + + console.log(chalk.blue(" 🔄 Creating default Stripe sandbox account...")); + + const newAccount = await createConnectAccount({ + org: org as any, + user: DUMMY_USER as any, + }); + + await OrgService.update({ + db, + orgId, + updates: { + test_stripe_connect: { + ...org.test_stripe_connect, + default_account_id: newAccount.id, + }, + }, + }); + + console.log( + chalk.greenBright( + ` ✅ Created default Stripe sandbox account: ${newAccount.id}`, + ), + ); +} diff --git a/scripts/testScripts/testDispatcher.ts b/scripts/testScripts/testDispatcher.ts index a4de606c7..f69cc7078 100644 --- a/scripts/testScripts/testDispatcher.ts +++ b/scripts/testScripts/testDispatcher.ts @@ -23,6 +23,9 @@ const TESTS_DIR = join(PROJECT_ROOT, testRunConfig.testsBaseDir); const LEGACY_SCRIPTS_DIR = join(PROJECT_ROOT, testRunConfig.legacyScriptsDir); const RUNNER_SCRIPT = join(PROJECT_ROOT, "scripts/testScripts/runTestsV2.tsx"); +// Worktree .env.local loading happens in scripts/preload-env.ts, which Bun +// auto-runs via bunfig.toml `preload` for every `bun` and `bun test` invocation. + /** Recursively find a file under baseDir whose relative path ends with the given suffix. */ async function findFileByPath({ baseDir, diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index d1e207352..4c7c10063 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -14,10 +14,12 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true, "paths": { "@server/*": ["../server/src/*"], - "@shared/*": ["../shared/*"] + "@shared/*": ["../shared/*"], + "@tests/*": ["../server/tests/*"] } }, "include": ["**/*.ts", "**/*.js"], diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index efd8157c3..1eb9bac82 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -108,8 +108,11 @@ export class AutumnInt { this.headers["org-config"] = JSON.stringify(orgConfig); } + const envBase = process.env.AUTUMN_TEST_BASE_URL; + const envBaseUrl = envBase ? `${envBase.replace(/\/$/, "")}/v1` : null; this.baseUrl = baseUrl || + envBaseUrl || (liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1"); if (skipCacheDeletion) { diff --git a/server/src/external/autumn/autumnRpcCli.ts b/server/src/external/autumn/autumnRpcCli.ts index c92207de8..4055f7f7c 100644 --- a/server/src/external/autumn/autumnRpcCli.ts +++ b/server/src/external/autumn/autumnRpcCli.ts @@ -42,8 +42,11 @@ export class AutumnRpcCli { this.headers["org-config"] = JSON.stringify(orgConfig); } + const envBase = process.env.AUTUMN_TEST_BASE_URL; + const envBaseUrl = envBase ? `${envBase.replace(/\/$/, "")}/v1` : null; this.baseUrl = baseUrl || + envBaseUrl || (liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1"); } diff --git a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts index 57ff0913c..ffc5f8a67 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts @@ -51,19 +51,36 @@ export const stripeConnectSeederMiddleware = async ( const rawBody = await c.req.text(); const signature = c.req.header("stripe-signature") || ""; + const skipVerify = + process.env.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && + process.env.NODE_ENV !== "production"; + let event: Stripe.Event; - try { - event = await masterStripe.webhooks.constructEventAsync( - rawBody, - signature, - webhookSecret, + if (skipVerify) { + logger.warn( + "[Stripe] SKIPPING webhook signature verification — non-prod only", ); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - if (process.env.NODE_ENV !== "development") { - logger.warn(`Webhook verification error: ${message}`); + try { + event = JSON.parse(rawBody) as Stripe.Event; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Webhook body parse error (skip-verify): ${message}`); + return c.json({ error: message }, 400); + } + } else { + try { + event = await masterStripe.webhooks.constructEventAsync( + rawBody, + signature, + webhookSecret, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (process.env.NODE_ENV !== "development") { + logger.warn(`Webhook verification error: ${message}`); + } + return c.json({ error: message }, 400); } - return c.json({ error: message }, 400); } // const event = (await c.req.json()) as Stripe.Event; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts index 3611d09ab..271bf2fe5 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts @@ -53,20 +53,39 @@ export const stripeLegacySeederMiddleware = async ( const rawBody = await c.req.text(); const signature = c.req.header("stripe-signature") || ""; + const skipVerify = + process.env.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && + process.env.NODE_ENV !== "production"; + let event: Stripe.Event; - try { - const webhookSecret = getStripeWebhookSecret(org, env); - event = await Stripe.webhooks.constructEventAsync( - rawBody, - signature, - webhookSecret, - ); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); + if (skipVerify) { logger.warn( - `Stripe legacy webhook signature verification failed: ${message}`, + "[Stripe] SKIPPING webhook signature verification — non-prod only", ); - return c.json({ error: `Webhook Error: ${message}` }, 400); + try { + event = JSON.parse(rawBody) as Stripe.Event; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.warn( + `Stripe legacy webhook body parse error (skip-verify): ${message}`, + ); + return c.json({ error: `Webhook Error: ${message}` }, 400); + } + } else { + try { + const webhookSecret = getStripeWebhookSecret(org, env); + event = await Stripe.webhooks.constructEventAsync( + rawBody, + signature, + webhookSecret, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.warn( + `Stripe legacy webhook signature verification failed: ${message}`, + ); + return c.json({ error: `Webhook Error: ${message}` }, 400); + } } // Step 4: Set up context diff --git a/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts b/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts index deaf6c15b..16d17b862 100644 --- a/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts +++ b/server/tests/integration/billing/utils/checkout/autumnCheckoutUtils.ts @@ -7,7 +7,8 @@ import type { PreviewCheckoutResponse, } from "@autumn/shared"; -const CHECKOUT_BASE_URL = "http://localhost:8080"; +const CHECKOUT_BASE_URL = + process.env.AUTUMN_TEST_BASE_URL ?? "http://localhost:8080"; const CHECKOUT_TIMEOUT_MS = 15000; export const fetchAutumnCheckout = async ({ diff --git a/server/tests/scenarios/checkout/confirm/confirm-free-product-scenario.test.ts b/server/tests/scenarios/checkout/confirm/confirm-free-product-scenario.test.ts index 77b3e4cd9..a7b11a767 100644 --- a/server/tests/scenarios/checkout/confirm/confirm-free-product-scenario.test.ts +++ b/server/tests/scenarios/checkout/confirm/confirm-free-product-scenario.test.ts @@ -65,7 +65,7 @@ test( // 3. Confirm the checkout - should succeed without payment const confirmResponse = await axios.post( - `http://localhost:8080/checkouts/${checkoutId}/confirm`, + `${process.env.AUTUMN_TEST_BASE_URL ?? "http://localhost:8080"}/checkouts/${checkoutId}/confirm`, {}, { timeout: 10000 }, ); diff --git a/server/tests/scenarios/checkout/confirm/confirm-paid-with-pm-scenario.test.ts b/server/tests/scenarios/checkout/confirm/confirm-paid-with-pm-scenario.test.ts index a36598124..75a221043 100644 --- a/server/tests/scenarios/checkout/confirm/confirm-paid-with-pm-scenario.test.ts +++ b/server/tests/scenarios/checkout/confirm/confirm-paid-with-pm-scenario.test.ts @@ -66,7 +66,7 @@ test( // 3. Confirm the checkout - should create invoice and charge PM const confirmResponse = await axios.post( - `http://localhost:8080/checkouts/${checkoutId}/confirm`, + `${process.env.AUTUMN_TEST_BASE_URL ?? "http://localhost:8080"}/checkouts/${checkoutId}/confirm`, {}, { timeout: 15000 }, ); diff --git a/server/tests/scenarios/checkout/invalid-checkout/invalidCheckoutUtils.ts b/server/tests/scenarios/checkout/invalid-checkout/invalidCheckoutUtils.ts index 77f1d1e31..4df1ae04b 100644 --- a/server/tests/scenarios/checkout/invalid-checkout/invalidCheckoutUtils.ts +++ b/server/tests/scenarios/checkout/invalid-checkout/invalidCheckoutUtils.ts @@ -7,7 +7,8 @@ import { import { deleteCheckoutCache } from "@/internal/checkouts/actions/cache"; import { checkoutRepo } from "@/internal/checkouts/repos/checkoutRepo"; -const CHECKOUT_BASE_URL = "http://localhost:8080"; +const CHECKOUT_BASE_URL = + process.env.AUTUMN_TEST_BASE_URL ?? "http://localhost:8080"; export const createAutumnCheckout = async ({ autumnV1, diff --git a/server/tests/unit/corsOrigins.test.ts b/server/tests/unit/corsOrigins.test.ts index 23b237998..2089ec10b 100644 --- a/server/tests/unit/corsOrigins.test.ts +++ b/server/tests/unit/corsOrigins.test.ts @@ -51,6 +51,19 @@ describe("isAllowedOrigin", () => { ); }); + test("allows *.localhost subdomains (portless aliases)", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("https://wt8.localhost")).toBe( + "https://wt8.localhost", + ); + expect(isAllowedOrigin("https://wt17-api.localhost")).toBe( + "https://wt17-api.localhost", + ); + expect(isAllowedOrigin("https://google.emulate.localhost")).toBe( + "https://google.emulate.localhost", + ); + }); + test("rejects external origins", () => { process.env.NODE_ENV = "development"; expect(isAllowedOrigin("https://evil.com")).toBeUndefined(); @@ -62,5 +75,13 @@ describe("isAllowedOrigin", () => { expect(isAllowedOrigin("http://localhost:3000/evil")).toBeUndefined(); expect(isAllowedOrigin("http://localhost:3000?x=1")).toBeUndefined(); }); + + test("rejects look-alike domains posing as localhost", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("https://localhost.evil.com")).toBeUndefined(); + expect( + isAllowedOrigin("https://wt8.localhost.evil.com"), + ).toBeUndefined(); + }); }); }); diff --git a/server/tests/utils/setup/clearOrg.ts b/server/tests/utils/setup/clearOrg.ts index 93da0fa9c..c2e8eb800 100644 --- a/server/tests/utils/setup/clearOrg.ts +++ b/server/tests/utils/setup/clearOrg.ts @@ -1,5 +1,6 @@ import { AppEnv } from "@autumn/shared"; import { initDrizzle } from "@/db/initDrizzle.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { CusService } from "@/internal/customers/CusService.js"; import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; @@ -10,12 +11,33 @@ import { rewardRepo } from "@/internal/rewards/repos/index.js"; import { CacheManager } from "@/utils/cacheUtils/CacheManager.js"; import { CacheType } from "@/utils/cacheUtils/CacheType.js"; +/** + * DB-only org cleanup — no HTTP calls, no connection lifecycle. + * Safe to call from setup scripts that already hold a db handle. + */ +export const clearOrgDbOnly = async ({ + db, + orgId, + env, +}: { + db: DrizzleCli; + orgId: string; + env: AppEnv; +}) => { + await CusService.deleteByOrgId({ db, orgId, env }); + await ProductService.deleteByOrgId({ db, orgId, env }); + await rewardRepo.deleteByOrgId({ db, orgId, env }); + await FeatureService.deleteByOrgId({ db, orgId, env }); +}; + export const clearOrg = async ({ orgSlug, env, + skipStripeReset, }: { orgSlug: string; - env?: AppEnv; + env: AppEnv; + skipStripeReset?: boolean; }) => { if (env !== AppEnv.Sandbox) { console.error("Cannot clear non-sandbox orgs"); @@ -56,35 +78,24 @@ export const clearOrg = async ({ const orgId = org.id; - // Reset default account using the new internal endpoint - // This will delete and recreate the Stripe account, which automatically removes all Stripe resources - console.log(" 🔄 Resetting default account..."); - try { - const data = await autumn.organization.resetDefaultAccount(); - console.log( - " ✅ Reset default account, new account:", - data?.new_account_id, - ); - } catch (error: any) { - console.error(" ❌ Failed to reset default account:", error.message); - // Continue anyway as this is not critical + if (!skipStripeReset) { + // Reset default account using the new internal endpoint + // This will delete and recreate the Stripe account, which automatically removes all Stripe resources + console.log(" 🔄 Resetting default account..."); + try { + const data = await autumn.organization.resetDefaultAccount(); + console.log( + " ✅ Reset default account, new account:", + data?.new_account_id, + ); + } catch (error: any) { + console.error(" ❌ Failed to reset default account:", error.message); + // Continue anyway as this is not critical + } } - // Delete all customers from our database - await CusService.deleteByOrgId({ db, orgId, env }); - console.log(" ✅ Deleted customers"); - - // Delete all products from our database - await ProductService.deleteByOrgId({ db, orgId, env }); - console.log(" ✅ Deleted products"); - - // Delete all rewards from our database - await rewardRepo.deleteByOrgId({ db, orgId, env }); - console.log(" ✅ Deleted rewards"); - - // Delete all features from our database - await FeatureService.deleteByOrgId({ db, orgId, env }); - console.log(" ✅ Deleted features"); + await clearOrgDbOnly({ db, orgId, env }); + console.log(" ✅ Deleted customers, products, rewards, and features"); console.log(`✅ Cleared org ${orgSlug} (${env})`); diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index 80fe339da..07b3b7383 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -1098,7 +1098,7 @@ export async function initScenario({ "[TEST] Creating sub-org:", slug, " | Impersonate via: ", - `http://localhost:3000/impersonate-redirect?org_id=${ctx.org.id}`, + `${process.env.AUTUMN_TEST_VITE_URL ?? "http://localhost:3000"}/impersonate-redirect?org_id=${ctx.org.id}`, ); }