Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ Options:
--file <path> Path to SQL file (for branch exec)
--export Output 'export SOW_URL=...' (branch create)
--env-file <path> 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 <name> 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 <name> Create an isolated database branch
branch list List all branches
Expand All @@ -64,9 +67,14 @@ Commands:
analyze <url> 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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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]) {
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/commands/branch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/commands/env.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
8 changes: 8 additions & 0 deletions packages/cli/src/commands/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -150,6 +152,12 @@ export async function runCommand(
case "mcp":
await runMcp(flags);
break;
case "sandbox":
await runSandbox(connectionString, flags as Parameters<typeof runSandbox>[1], log);
break;
case "env":
await runEnv(subcommand, branchName, flags as Parameters<typeof runEnv>[2]);
break;
default:
log({ type: "error", message: `Unknown command: ${command}. Run sow --help to see available commands.` });
process.exit(1);
Expand Down
192 changes: 192 additions & 0 deletions packages/cli/src/commands/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createBranch>>;

let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;

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<ReturnType<typeof listBranches>>);

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));
});
});
Loading
Loading