From af39df01790b88dfe7ef0f8c3c3d78a001555131 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 6 Apr 2026 01:27:55 -0700 Subject: [PATCH 1/4] feat(cli): extract env-patch module with backup + revert Adds packages/cli/src/env-patch.ts: a generic, dependency-free env file patcher that merges keys, preserves comments and unrelated vars, writes a .sow.bak before mutating, and supports interactive prompt + revert. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/env-patch.test.ts | 205 ++++++++++++++++++++++++++ packages/cli/src/env-patch.ts | 223 +++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 packages/cli/src/env-patch.test.ts create mode 100644 packages/cli/src/env-patch.ts diff --git a/packages/cli/src/env-patch.test.ts b/packages/cli/src/env-patch.test.ts new file mode 100644 index 0000000..d945333 --- /dev/null +++ b/packages/cli/src/env-patch.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { patchEnvFile, revertEnvFile, __setPromptImpl } from "./env-patch.js"; + +let dir: string; +let envPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sow-envpatch-")); + envPath = join(dir, ".env.local"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("patchEnvFile", () => { + it("creates the file if missing and writes vars (no backup)", async () => { + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "postgresql://localhost/db", SOW_BRANCH: "sandbox" }, + prompt: false, + backup: true, + }); + expect(res.patched).toBe(true); + expect(res.backupPath).toBeUndefined(); + expect(res.keysChanged.sort()).toEqual(["DATABASE_URL", "SOW_BRANCH"]); + const c = readFileSync(envPath, "utf-8"); + expect(c).toContain("DATABASE_URL=postgresql://localhost/db"); + expect(c).toContain("SOW_BRANCH=sandbox"); + expect(existsSync(envPath + ".sow.bak")).toBe(false); + }); + + it("merges into existing file, preserves unrelated vars, writes backup", async () => { + writeFileSync(envPath, "LEVEL=info\nPORT=3000\n", "utf-8"); + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "postgresql://x" }, + prompt: false, + backup: true, + }); + expect(res.patched).toBe(true); + expect(res.backupPath).toBe(envPath + ".sow.bak"); + const c = readFileSync(envPath, "utf-8"); + expect(c).toContain("LEVEL=info"); + expect(c).toContain("PORT=3000"); + expect(c).toContain("DATABASE_URL=postgresql://x"); + const bak = readFileSync(envPath + ".sow.bak", "utf-8"); + expect(bak).toBe("LEVEL=info\nPORT=3000\n"); + }); + + it("overwrites existing key in place; diff shows the changed line", async () => { + writeFileSync( + envPath, + "LEVEL=info\nDATABASE_URL=postgresql://old\nPORT=3000\n", + "utf-8", + ); + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "postgresql://new" }, + prompt: false, + backup: false, + }); + expect(res.patched).toBe(true); + expect(res.keysChanged).toEqual(["DATABASE_URL"]); + expect(res.diff).toContain("- DATABASE_URL=postgresql://old"); + expect(res.diff).toContain("+ DATABASE_URL=postgresql://new"); + const c = readFileSync(envPath, "utf-8"); + expect(c).toBe("LEVEL=info\nDATABASE_URL=postgresql://new\nPORT=3000\n"); + }); + + it("empty vars => no-op", async () => { + writeFileSync(envPath, "A=1\n", "utf-8"); + const res = await patchEnvFile({ + path: envPath, + vars: {}, + prompt: false, + backup: true, + }); + expect(res.patched).toBe(false); + expect(readFileSync(envPath, "utf-8")).toBe("A=1\n"); + }); + + it("no-op if all vars already match", async () => { + writeFileSync(envPath, "DATABASE_URL=postgresql://same\n", "utf-8"); + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "postgresql://same" }, + prompt: false, + backup: true, + }); + expect(res.patched).toBe(false); + expect(existsSync(envPath + ".sow.bak")).toBe(false); + }); + + it("prompt: true and user declines => no writes, no backup", async () => { + writeFileSync(envPath, "A=1\n", "utf-8"); + __setPromptImpl(async () => false); + + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "postgresql://x" }, + prompt: true, + backup: true, + }); + expect(res.patched).toBe(false); + expect(readFileSync(envPath, "utf-8")).toBe("A=1\n"); + expect(existsSync(envPath + ".sow.bak")).toBe(false); + }); + + it("prompt: true and user accepts => writes", async () => { + writeFileSync(envPath, "A=1\n", "utf-8"); + __setPromptImpl(async () => true); + + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "postgresql://x" }, + prompt: true, + backup: true, + }); + expect(res.patched).toBe(true); + expect(readFileSync(envPath, "utf-8")).toContain("DATABASE_URL=postgresql://x"); + }); + + it("does NOT overwrite an existing .sow.bak; warns and continues", async () => { + writeFileSync(envPath, "DATABASE_URL=old\n", "utf-8"); + writeFileSync(envPath + ".sow.bak", "ORIGINAL=1\n", "utf-8"); + const warn = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + const res = await patchEnvFile({ + path: envPath, + vars: { DATABASE_URL: "new" }, + prompt: false, + backup: true, + }); + + expect(res.patched).toBe(true); + expect(res.backupPath).toBeUndefined(); + expect(readFileSync(envPath + ".sow.bak", "utf-8")).toBe("ORIGINAL=1\n"); + expect(warn.mock.calls.some((c) => String(c[0]).includes("already exists"))).toBe(true); + }); + + it("quotes values containing spaces", async () => { + const res = await patchEnvFile({ + path: envPath, + vars: { GREETING: "hello world" }, + prompt: false, + backup: false, + }); + expect(res.patched).toBe(true); + expect(readFileSync(envPath, "utf-8")).toContain('GREETING="hello world"'); + }); + + it("preserves comments and blank lines", async () => { + writeFileSync( + envPath, + "# top comment\n\nA=1\n# inline\nB=2\n", + "utf-8", + ); + const res = await patchEnvFile({ + path: envPath, + vars: { B: "22" }, + prompt: false, + backup: false, + }); + expect(res.patched).toBe(true); + const c = readFileSync(envPath, "utf-8"); + expect(c).toBe("# top comment\n\nA=1\n# inline\nB=22\n"); + }); + + it("handles existing quoted value comparison correctly", async () => { + writeFileSync(envPath, 'A="hello world"\n', "utf-8"); + const res = await patchEnvFile({ + path: envPath, + vars: { A: "hello world" }, + prompt: false, + backup: true, + }); + expect(res.patched).toBe(false); + }); +}); + +describe("revertEnvFile", () => { + it("restores from backup and deletes the backup", async () => { + writeFileSync(envPath, "DATABASE_URL=new\n", "utf-8"); + writeFileSync(envPath + ".sow.bak", "DATABASE_URL=original\n", "utf-8"); + await revertEnvFile(envPath); + expect(readFileSync(envPath, "utf-8")).toBe("DATABASE_URL=original\n"); + expect(existsSync(envPath + ".sow.bak")).toBe(false); + }); + + it("errors clearly if no backup exists", async () => { + writeFileSync(envPath, "X=1\n", "utf-8"); + await expect(revertEnvFile(envPath)).rejects.toThrow(/No backup/); + }); +}); diff --git a/packages/cli/src/env-patch.ts b/packages/cli/src/env-patch.ts new file mode 100644 index 0000000..536e8bf --- /dev/null +++ b/packages/cli/src/env-patch.ts @@ -0,0 +1,223 @@ +import { + readFileSync, + writeFileSync, + existsSync, + unlinkSync, +} from "node:fs"; +import { createInterface } from "node:readline"; + +export interface EnvPatchOptions { + /** Path to the env file. Created if missing. */ + path: string; + /** Key-value pairs to set or update. Existing keys are overwritten, others preserved. */ + vars: Record; + /** If true, prompt interactively before writing. If false, write unconditionally. */ + prompt: boolean; + /** If true, write {path}.sow.bak containing the pre-patch contents before writing. */ + backup: boolean; +} + +export interface EnvPatchResult { + /** true if changes were written to disk. */ + patched: boolean; + /** Path to the backup file, if one was created. */ + backupPath?: string; + /** The unified diff of the change, rendered as text. */ + diff: string; + /** The keys that were actually added or modified. */ + keysChanged: string[]; +} + +interface ParsedLine { + raw: string; + key?: string; + /** unquoted value for comparison */ + value?: string; +} + +const KEY_RE = /^([A-Z_][A-Z0-9_]*)=(.*)$/; + +function parseLine(line: string): ParsedLine { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + return { raw: line }; + } + const m = line.match(KEY_RE); + if (!m) return { raw: line }; + const key = m[1]; + // Strip surrounding quotes for comparison purposes only + let value = m[2]; + if ( + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + const quote = value[0]; + value = value.slice(1, -1); + if (quote === '"') { + value = value.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } + } + return { raw: line, key, value }; +} + +function quoteIfNeeded(value: string): string { + if (/[\s="']/.test(value)) { + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; + } + return value; +} + +function formatLine(key: string, value: string): string { + return `${key}=${quoteIfNeeded(value)}`; +} + +function buildDiff( + oldLines: string[], + newLines: string[], + changedKeys: Set, +): string { + // Hand-rolled "show changed lines with surrounding context" — minimal. + // We render: matching lines as " line", removed as "- line", added as "+ line". + const out: string[] = []; + + // Build a map of old lines by key for lookup + const oldByKey = new Map(); + for (const l of oldLines) { + const p = parseLine(l); + if (p.key) oldByKey.set(p.key, l); + } + + // For each line in the new file, decide how to render + for (const nl of newLines) { + const p = parseLine(nl); + if (p.key && changedKeys.has(p.key)) { + const oldLine = oldByKey.get(p.key); + if (oldLine !== undefined) { + out.push(`- ${oldLine}`); + out.push(`+ ${nl}`); + } else { + out.push(`+ ${nl}`); + } + } else { + out.push(` ${nl}`); + } + } + + // Note: keys that exist only in vars but not in newLines shouldn't happen, + // because we always append unknown keys. Sanity-only. + return out.join("\n"); +} + +let promptImpl: (question: string) => Promise = (question) => + new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + rl.question(question, (answer) => { + rl.close(); + const a = answer.trim().toLowerCase(); + resolve(a === "y" || a === "yes"); + }); + }); + +/** Test hook: override the interactive prompt. */ +export function __setPromptImpl(impl: (question: string) => Promise): void { + promptImpl = impl; +} + +async function promptYesNo(question: string): Promise { + return promptImpl(question); +} + +export async function patchEnvFile( + options: EnvPatchOptions, +): Promise { + const { path, vars, prompt, backup } = options; + + if (Object.keys(vars).length === 0) { + return { patched: false, diff: "", keysChanged: [] }; + } + + const fileExists = existsSync(path); + const original = fileExists ? readFileSync(path, "utf-8") : ""; + // Preserve final newline awareness + const hadTrailingNewline = original.endsWith("\n"); + const oldLines = original === "" ? [] : original.replace(/\n$/, "").split("\n"); + + // Determine which keys actually change + const keysChanged: string[] = []; + const newLines: string[] = []; + const handledKeys = new Set(); + + for (const line of oldLines) { + const parsed = parseLine(line); + if (parsed.key && parsed.key in vars) { + const newVal = vars[parsed.key]; + handledKeys.add(parsed.key); + if (parsed.value !== newVal) { + keysChanged.push(parsed.key); + newLines.push(formatLine(parsed.key, newVal)); + } else { + newLines.push(line); + } + } else { + newLines.push(line); + } + } + + // Append keys not yet present, preserving insertion order from `vars` + for (const [k, v] of Object.entries(vars)) { + if (!handledKeys.has(k)) { + keysChanged.push(k); + newLines.push(formatLine(k, v)); + } + } + + if (keysChanged.length === 0) { + return { patched: false, diff: "", keysChanged: [] }; + } + + const changedSet = new Set(keysChanged); + const diff = buildDiff(oldLines, newLines, changedSet); + + if (prompt) { + process.stderr.write(`${diff}\n\n`); + const ok = await promptYesNo(`Apply these changes to ${path}? [y/N] `); + if (!ok) { + return { patched: false, diff, keysChanged }; + } + } + + // Backup if file exists and at least one key will change + let backupPath: string | undefined; + if (backup && fileExists) { + const candidate = `${path}.sow.bak`; + if (existsSync(candidate)) { + process.stderr.write( + ` ⚠ Backup ${candidate} already exists; not overwriting.\n`, + ); + } else { + writeFileSync(candidate, original, "utf-8"); + backupPath = candidate; + } + } + + const output = newLines.join("\n") + (hadTrailingNewline || !fileExists ? "\n" : ""); + writeFileSync(path, output, "utf-8"); + + return { patched: true, backupPath, diff, keysChanged }; +} + +export async function revertEnvFile(envPath: string): Promise { + const backupPath = `${envPath}.sow.bak`; + if (!existsSync(backupPath)) { + throw new Error( + `No backup found at ${backupPath}. Nothing to revert.`, + ); + } + const contents = readFileSync(backupPath, "utf-8"); + writeFileSync(envPath, contents, "utf-8"); + unlinkSync(backupPath); +} From a4a7d855c12229d622c97b44f912147de01fa76b Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 6 Apr 2026 01:28:02 -0700 Subject: [PATCH 2/4] refactor(cli): use env-patch in branch create --env-file Replaces the clobber-or-append logic with patchEnvFile, which always merges and writes a backup. The --append flag becomes a no-op with a deprecation notice on stderr. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/branch.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/branch.ts b/packages/cli/src/commands/branch.ts index 6a21301..8b41ca4 100644 --- a/packages/cli/src/commands/branch.ts +++ b/packages/cli/src/commands/branch.ts @@ -1,4 +1,5 @@ -import { readFileSync, writeFileSync, appendFileSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { patchEnvFile } from "../env-patch.js"; import { formatBytes, timeAgo } from "../utils.js"; import { type ProgressEvent, @@ -86,12 +87,20 @@ export async function runBranch( if (flags.export) { console.log(`export SOW_URL=${branch.connectionString}`); } else if (flags.envFile) { - const envContent = `DATABASE_URL=${branch.connectionString}\nSOW_BRANCH=${branch.name}\n`; if (flags.append) { - appendFileSync(flags.envFile as string, envContent, "utf-8"); - } else { - writeFileSync(flags.envFile as string, envContent, "utf-8"); + console.error( + " ⚠ --append is deprecated; --env-file now always merges and preserves unrelated keys.", + ); } + await patchEnvFile({ + path: flags.envFile as string, + vars: { + DATABASE_URL: branch.connectionString, + SOW_BRANCH: branch.name, + }, + prompt: false, + backup: true, + }); if (isJSON) { console.log(JSON.stringify(branch)); } else if (isQuiet) { From f7e9c82930055787d16c8cfd1686cf3e5d9000ed Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 6 Apr 2026 01:28:10 -0700 Subject: [PATCH 3/4] feat(cli): add sow sandbox command (flagship zero-config flow) sow sandbox is a thin composition over detectConnection, createConnector, createBranch, and patchEnvFile. It auto-detects the source Postgres, samples it, spins up a local Docker branch, prints DATABASE_URL, and prompts to patch .env.local. Adds --yes / --no-env-file flags. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli.ts | 14 +- packages/cli/src/commands/runner.ts | 8 + packages/cli/src/commands/sandbox.test.ts | 192 ++++++++++++++++++++++ packages/cli/src/commands/sandbox.ts | 185 +++++++++++++++++++++ 4 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/sandbox.test.ts create mode 100644 packages/cli/src/commands/sandbox.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e71e74a..9df0792 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -34,13 +34,16 @@ Options: --file Path to SQL file (for branch exec) --export Output 'export SOW_URL=...' (branch create) --env-file Write DATABASE_URL to this file - --append Append to env file instead of overwriting + --no-env-file Skip env file patching (sandbox) + -y, --yes Skip interactive confirmation prompts + --append Deprecated; --env-file now always merges --agent Agent to configure MCP for --setup Interactive MCP setup --local Use local binary path for MCP config -h, --help Show help Commands: + sandbox [url] Zero-config: detect DB, sample, branch, patch .env.local connect [url] Connect to production DB and create a snapshot branch create Create an isolated database branch branch list List all branches @@ -64,9 +67,14 @@ Commands: analyze Analyze database schema, stats, and PII doctor Check setup and diagnose issues mcp Configure MCP server for coding agents + env revert [path] Restore .env.local from a sow backup Examples: +- Zero-config sandbox (detects your DB, patches .env.local) + + $ sow sandbox + - Auto-detect and connect (reads .env, Prisma, Docker Compose, etc.) $ sow connect @@ -109,6 +117,8 @@ Examples: file: { type: "string" }, export: { type: "boolean", default: false }, envFile: { type: "string" }, + noEnvFile: { type: "boolean", default: false }, + yes: { type: "boolean", shortFlag: "y", default: false }, append: { type: "boolean", default: false }, agent: { type: "string" }, setup: { type: "boolean", default: false }, @@ -142,7 +152,7 @@ if (!command) { let connectionString: string | undefined; let branchName: string | undefined; - if (command === "branch" || command === "connector") { + if (command === "branch" || command === "connector" || command === "env") { subcommand = rest[0]; branchName = rest[1]; if (rest[2]) { diff --git a/packages/cli/src/commands/runner.ts b/packages/cli/src/commands/runner.ts index 046e849..680680a 100644 --- a/packages/cli/src/commands/runner.ts +++ b/packages/cli/src/commands/runner.ts @@ -14,6 +14,8 @@ import { import { runConnect, tryConnect, offerDockerStart, promptWithProviderGuidance, resolveConnectionViaDetectionResult } from "./connect.js"; import { runBranch } from "./branch.js"; import { runConnectorCmd } from "./connector.js"; +import { runSandbox } from "./sandbox.js"; +import { runEnv } from "./env.js"; function emitJSON(event: ProgressEvent): void { console.log(JSON.stringify(event)); @@ -150,6 +152,12 @@ export async function runCommand( case "mcp": await runMcp(flags); break; + case "sandbox": + await runSandbox(connectionString, flags as Parameters[1], log); + break; + case "env": + await runEnv(subcommand, branchName, flags as Parameters[2]); + break; default: log({ type: "error", message: `Unknown command: ${command}. Run sow --help to see available commands.` }); process.exit(1); diff --git a/packages/cli/src/commands/sandbox.test.ts b/packages/cli/src/commands/sandbox.test.ts new file mode 100644 index 0000000..ca251fe --- /dev/null +++ b/packages/cli/src/commands/sandbox.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@sowdb/core", () => ({ + detectConnection: vi.fn(), + createConnector: vi.fn(), + createBranch: vi.fn(), + listConnectors: vi.fn(), + listBranches: vi.fn(), + getBranchInfo: vi.fn(), +})); + +vi.mock("../env-patch.js", () => ({ + patchEnvFile: vi.fn(), +})); + +import { + detectConnection, + createConnector, + createBranch, + listConnectors, + listBranches, + getBranchInfo, +} from "@sowdb/core"; +import { patchEnvFile } from "../env-patch.js"; +import { runSandbox } from "./sandbox.js"; + +const mDetect = vi.mocked(detectConnection); +const mCreateConn = vi.mocked(createConnector); +const mCreateBranch = vi.mocked(createBranch); +const mListConn = vi.mocked(listConnectors); +const mListBranches = vi.mocked(listBranches); +const mGetBranch = vi.mocked(getBranchInfo); +const mPatch = vi.mocked(patchEnvFile); + +const noopLog = () => {}; + +const fakeBranch = { + name: "sandbox", + port: 54330, + connectionString: "postgresql://localhost:54330/sandbox", + connector: "main", + status: "running", + provider: "postgres", + createdAt: new Date().toISOString(), +} as unknown as Awaited>; + +let exitSpy: ReturnType; +let logSpy: ReturnType; +let errSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`__exit__${code}`); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + mListConn.mockReturnValue([]); + mListBranches.mockResolvedValue([]); + mCreateConn.mockResolvedValue({ + name: "main", + tables: 5, + rows: 100, + piiColumnsDetected: 0, + sizeBytes: 1234, + snapshotPath: "/tmp/snap", + }); + mCreateBranch.mockResolvedValue(fakeBranch); + mGetBranch.mockResolvedValue(fakeBranch); + mPatch.mockResolvedValue({ + patched: true, + diff: "", + keysChanged: ["DATABASE_URL", "SOW_BRANCH"], + }); +}); + +afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); +}); + +describe("runSandbox", () => { + it("single candidate => detect, sample, branch, patch", async () => { + mDetect.mockReturnValue({ + connections: [ + { + source: "env", + sourceFile: ".env", + connectionString: "postgresql://prod/db", + confidence: "high", + }, + ], + providers: [], + hints: [], + warnings: [], + }); + + await runSandbox(undefined, { yes: true }, noopLog); + + expect(mDetect).toHaveBeenCalled(); + expect(mCreateConn).toHaveBeenCalledWith( + "postgresql://prod/db", + expect.any(Object), + ); + expect(mCreateBranch).toHaveBeenCalledWith("sandbox", "main", expect.any(Object)); + expect(mPatch).toHaveBeenCalledWith( + expect.objectContaining({ + path: ".env.local", + vars: expect.objectContaining({ DATABASE_URL: fakeBranch.connectionString }), + backup: true, + }), + ); + }); + + it("no candidates => clear error and exit 1", async () => { + mDetect.mockReturnValue({ + connections: [], + providers: [], + hints: [], + warnings: [], + }); + + await expect(runSandbox(undefined, { yes: true }, noopLog)).rejects.toThrow( + "__exit__1", + ); + expect(mCreateConn).not.toHaveBeenCalled(); + }); + + it("multiple candidates in --json mode => errors out", async () => { + mDetect.mockReturnValue({ + connections: [ + { source: "a", sourceFile: ".env", connectionString: "postgresql://a", confidence: "high" }, + { source: "b", sourceFile: ".env", connectionString: "postgresql://b", confidence: "high" }, + ], + providers: [], + hints: [], + warnings: [], + }); + + await expect( + runSandbox(undefined, { json: true }, noopLog), + ).rejects.toThrow("__exit__1"); + expect(mCreateConn).not.toHaveBeenCalled(); + }); + + it("multiple candidates in quiet mode => picks first (non-TTY safe)", async () => { + mDetect.mockReturnValue({ + connections: [ + { source: "a", sourceFile: ".env", connectionString: "postgresql://a", confidence: "high" }, + { source: "b", sourceFile: ".env", connectionString: "postgresql://b", confidence: "high" }, + ], + providers: [], + hints: [], + warnings: [], + }); + await runSandbox(undefined, { quiet: true, yes: true }, noopLog); + expect(mCreateConn).toHaveBeenCalledWith("postgresql://a", expect.any(Object)); + }); + + it("existing sandbox branch => does not re-create, prints info", async () => { + mListBranches.mockResolvedValue([fakeBranch] as unknown as Awaited>); + + await runSandbox("postgresql://prod/db", { yes: true }, noopLog); + + expect(mCreateBranch).not.toHaveBeenCalled(); + expect(mGetBranch).toHaveBeenCalledWith("sandbox"); + expect(mPatch).toHaveBeenCalled(); + }); + + it("--no-env-file => skips env patching", async () => { + await runSandbox("postgresql://prod/db", { yes: true, noEnvFile: true }, noopLog); + expect(mPatch).not.toHaveBeenCalled(); + }); + + it("--yes => sets prompt:false in patchEnvFile", async () => { + await runSandbox("postgresql://prod/db", { yes: true }, noopLog); + expect(mPatch).toHaveBeenCalledWith( + expect.objectContaining({ prompt: false, backup: true }), + ); + }); + + it("connector already exists => reuses it (no createConnector)", async () => { + mListConn.mockReturnValue([ + { name: "existing", tables: 1, rows: 1, sizeBytes: 1, createdAt: "" }, + ]); + await runSandbox("postgresql://prod/db", { yes: true }, noopLog); + expect(mCreateConn).not.toHaveBeenCalled(); + expect(mCreateBranch).toHaveBeenCalledWith("sandbox", "existing", expect.any(Object)); + }); +}); diff --git a/packages/cli/src/commands/sandbox.ts b/packages/cli/src/commands/sandbox.ts new file mode 100644 index 0000000..c7cfbeb --- /dev/null +++ b/packages/cli/src/commands/sandbox.ts @@ -0,0 +1,185 @@ +import { createInterface } from "node:readline"; +import { + detectConnection, + createConnector, + createBranch, + listConnectors, + listBranches, + getBranchInfo, + type DetectedConnection, + type ProgressEvent, +} from "@sowdb/core"; +import { patchEnvFile } from "../env-patch.js"; +import { printError } from "./runner.js"; + +interface SandboxFlags { + json?: boolean; + quiet?: boolean; + yes?: boolean; + noEnvFile?: boolean; + envFile?: string; + name?: string; + maxRows?: number; + seed?: number; + noSanitize?: boolean; + full?: boolean; +} + +async function pickIndex(max: number): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + rl.question(`Select [1-${max}]: `, (answer) => { + rl.close(); + const n = parseInt(answer.trim(), 10); + if (Number.isFinite(n) && n >= 1 && n <= max) resolve(n - 1); + else resolve(-1); + }); + }); +} + +function fmtConn(c: DetectedConnection): string { + const where = c.envVar ? `${c.envVar} (${c.sourceFile})` : `${c.source} (${c.sourceFile})`; + return `${where} — ${c.connectionString}`; +} + +export async function runSandbox( + positionalUrl: string | undefined, + flags: SandboxFlags, + _log: (event: ProgressEvent) => void, +): Promise { + const isJSON = !!flags.json; + const isQuiet = !!flags.quiet; + const branchName = flags.name || "sandbox"; + + try { + // 1. Resolve source URL + let sourceUrl = positionalUrl; + if (!sourceUrl) { + const detection = detectConnection(process.cwd()); + if (detection.connections.length === 0) { + const msg = + "no Postgres connection detected. Pass a URL explicitly: `sow sandbox `"; + if (isJSON) console.log(JSON.stringify({ type: "error", message: msg })); + else printError(msg); + process.exit(1); + } else if (detection.connections.length === 1) { + sourceUrl = detection.connections[0].connectionString; + } else { + if (isJSON) { + console.log( + JSON.stringify({ + type: "error", + message: + "multiple candidates detected, run `sow detect` to list them and pass the chosen URL explicitly.", + }), + ); + process.exit(1); + } + if (isQuiet || !process.stdin.isTTY) { + sourceUrl = detection.connections[0].connectionString; + } else { + console.error(` Found ${detection.connections.length} possible sources:\n`); + detection.connections.forEach((c, i) => { + console.error(` ${i + 1}. ${fmtConn(c)}`); + }); + console.error(); + const idx = await pickIndex(detection.connections.length); + if (idx < 0) { + printError("invalid selection"); + process.exit(1); + } + sourceUrl = detection.connections[idx].connectionString; + } + } + } + + // 2. Connector — reuse if one already exists, else create + const existingConnectors = listConnectors(); + let connectorName: string; + if (existingConnectors.length > 0) { + connectorName = existingConnectors[0].name; + if (!isJSON && !isQuiet) { + console.error(` ✓ Reusing connector "${connectorName}"`); + } + } else { + if (!isJSON && !isQuiet) { + console.error(" Sampling source database..."); + } + const result = await createConnector(sourceUrl!, { + maxRowsPerTable: flags.maxRows, + seed: flags.seed, + noSanitize: flags.noSanitize, + full: flags.full, + }); + connectorName = result.name; + } + + // 3. Branch — reuse if one with the same name already exists + const branches = await listBranches(); + const existing = branches.find((b) => b.name === branchName); + let branch; + if (existing) { + branch = await getBranchInfo(branchName); + if (!isJSON && !isQuiet) { + console.error(` ✓ Sandbox already running at :${branch.port}`); + } + } else { + if (!isJSON && !isQuiet) { + console.error(" Spinning up local branch..."); + } + branch = await createBranch(branchName, connectorName, {}); + } + + // 4. Patch env file unless disabled + let envPatched = false; + let envPath: string | undefined; + if (!flags.noEnvFile) { + envPath = flags.envFile || ".env.local"; + const result = await patchEnvFile({ + path: envPath, + vars: { + DATABASE_URL: branch.connectionString, + SOW_BRANCH: branch.name, + }, + prompt: !flags.yes && !isJSON && !isQuiet, + backup: true, + }); + envPatched = result.patched; + } + + // 5. Output + if (isJSON) { + console.log( + JSON.stringify({ + type: "result", + branch, + envPatched, + envPath, + }), + ); + } else if (isQuiet) { + console.log(branch.connectionString); + } else { + console.log(); + console.log(` ✓ Sandbox ready at :${branch.port}.`); + console.log(` DATABASE_URL=${branch.connectionString}`); + if (envPatched && envPath) { + console.log(` ✓ Patched ${envPath}`); + } + console.log(); + console.log(` Run your app with \`npm run dev\` or any command that reads DATABASE_URL.`); + } + } catch (err) { + if (!isJSON && !isQuiet) { + printError(err instanceof Error ? err.message : String(err)); + } else if (isJSON) { + console.log( + JSON.stringify({ + type: "error", + message: err instanceof Error ? err.message : String(err), + }), + ); + } + process.exit(1); + } +} From 1afc2edd7688efee810b4e859c3e7e4ec577e1e2 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 6 Apr 2026 01:28:18 -0700 Subject: [PATCH 4/4] feat(cli): add sow env revert command Restores an env file from its .sow.bak backup written by patchEnvFile, giving users a one-command undo for sow sandbox / sow branch create --env-file. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/env.ts | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/cli/src/commands/env.ts diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts new file mode 100644 index 0000000..7ee2d71 --- /dev/null +++ b/packages/cli/src/commands/env.ts @@ -0,0 +1,40 @@ +import { revertEnvFile } from "../env-patch.js"; +import { printError } from "./runner.js"; + +interface EnvFlags { + json?: boolean; + quiet?: boolean; +} + +export async function runEnv( + subcommand: string | undefined, + positional: string | undefined, + flags: EnvFlags, +): Promise { + const isJSON = !!flags.json; + const isQuiet = !!flags.quiet; + + if (subcommand !== "revert") { + const msg = "Usage: sow env revert [path]"; + if (isJSON) console.log(JSON.stringify({ type: "error", message: msg })); + else printError(msg); + process.exit(1); + } + + const path = positional || ".env.local"; + try { + await revertEnvFile(path); + if (isJSON) { + console.log(JSON.stringify({ type: "result", reverted: path })); + } else if (isQuiet) { + console.log(`reverted: ${path}`); + } else { + console.log(` ✓ Reverted ${path} from backup`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (isJSON) console.log(JSON.stringify({ type: "error", message: msg })); + else printError(msg); + process.exit(1); + } +}