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