Files
dbx/scripts/database-env.mjs

594 lines
27 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const DEFAULT_RECIPES_ROOT = join(REPO_ROOT, "deploy", "database");
const DEFAULT_MAKEFILE_PATH = join(REPO_ROOT, "Makefile");
const DEFAULT_PASSWORD = "123456";
const DEFAULT_DATABASE = "dbx";
const DEFAULT_HOST_PORT_RANGES = {
mysql: [10100, 10199],
mariadb: [10200, 10299],
postgresql: [10300, 10399],
mongodb: [10400, 10499],
redis: [10500, 10599],
clickhouse: [10600, 10699],
etcd: [10700, 10799],
zookeeper: [10800, 10899],
consul: [10900, 10999],
nacos: [11000, 11099],
rnacos: [11100, 11199],
qdrant: [11200, 11299],
kafka: [11300, 11399],
pulsar: [11400, 11499],
elasticsearch: [11500, 11599],
};
const DBX_DEEP_LINK_TYPES = {
clickhouse: "clickhouse",
consul: "consul",
elasticsearch: "elasticsearch",
etcd: "etcd",
mariadb: "mariadb",
mongodb: "mongodb",
mysql: "mysql",
postgresql: "postgres",
qdrant: "qdrant",
redis: "redis",
rnacos: "r-nacos",
zookeeper: "zookeeper",
};
export function discoverRecipes(root = DEFAULT_RECIPES_ROOT) {
if (!existsSync(root)) return [];
const recipes = [];
for (const database of readdirSync(root, { withFileTypes: true })) {
if (!database.isDirectory()) continue;
const databaseDir = join(root, database.name);
for (const version of readdirSync(databaseDir, { withFileTypes: true })) {
if (!version.isDirectory()) continue;
const recipePath = join(databaseDir, version.name, "recipe.json");
if (!existsSync(recipePath)) continue;
const recipe = JSON.parse(readFileSync(recipePath, "utf8"));
recipes.push({ ...recipe, directory: dirname(recipePath), recipePath });
}
}
return recipes.sort((a, b) => `${a.database}/${a.displayVersion}`.localeCompare(`${b.database}/${b.displayVersion}`, "en"));
}
export function recipeSelector(recipe) {
return `${recipe.database}@${recipe.displayVersion}`;
}
export function formatTable(headers, rows) {
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length)));
const renderRow = (row) =>
row
.map((value, index) => {
const text = String(value ?? "");
return index === row.length - 1 ? text : text.padEnd(widths[index]);
})
.join(" ");
return [headers, ...rows].map(renderRow).join("\n");
}
export function formatBorderedTable(headers, rows, groupByFirstColumn = false) {
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length)));
const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
const renderRow = (row) => `| ${row.map((value, index) => String(value ?? "").padEnd(widths[index])).join(" | ")} |`;
const body = [];
for (const [index, row] of rows.entries()) {
body.push(renderRow(row));
if (groupByFirstColumn && index < rows.length - 1 && rows[index + 1][0] !== "") body.push(border);
}
return [border, renderRow(headers), border, ...body, border].join("\n");
}
export function discoverMakeTargets(makefilePath = DEFAULT_MAKEFILE_PATH) {
if (!existsSync(makefilePath)) return [];
const source = readFileSync(makefilePath, "utf8").replace(/\\\r?\n/g, " ");
const targets = [];
for (const line of source.split(/\r?\n/)) {
const match = line.match(/^\.PHONY:\s*(.+)$/);
if (match) targets.push(...match[1].trim().split(/\s+/));
}
return [...new Set(targets)];
}
export function platformForArchitecture(architecture = process.arch) {
if (architecture === "x64") return "linux/amd64";
if (architecture === "arm64") return "linux/arm64";
return null;
}
export function architectureWarning(recipe, architecture = process.arch) {
const platform = platformForArchitecture(architecture);
if (!platform || recipe.platforms?.includes(platform)) return null;
return `${recipe.image} does not support ${platform}. Docker must use ${recipe.platforms.join(", ")} emulation and startup may be slower.`;
}
function printQuickStart(recipes) {
console.log("Database test environments");
console.log("Copy one command to start an environment:");
console.log("");
for (const recipe of recipes) console.log(` make db DB=${recipeSelector(recipe)}`);
console.log("");
console.log("Optional parameters:");
console.log(" DB_BIND_ADDRESS=<ip> Override the default bind address (127.0.0.1).");
console.log(" DB_PORT=<port> Override the default host port.");
console.log(" DB_PASSWORD=<password> Override the default password (123456).");
console.log("");
console.log("Next steps: make db-verify DB=<product>@<version>, make db-down DB=<product>@<version>");
console.log("Tab completion: make db-completion");
}
function printCompletionSetup() {
console.log("Bash: source deploy/database/completion/dbx-make.bash");
console.log("Zsh: autoload -Uz compinit && compinit && source deploy/database/completion/_dbx-make.zsh");
console.log("PowerShell: . .\\deploy\\database\\completion\\Dbx.Make.ps1");
}
export function resolveRecipe(recipes, database, version) {
if (!database) throw new Error("DB is required. Run `make db-list` to see available environments.");
const matches = recipes.filter((item) => item.database === database && (!version || item.displayVersion === version || item.version === version));
if (matches.length === 0) throw new Error(`No database environment found for ${database}${version ? ` ${version}` : ""}.`);
if (matches.length > 1) throw new Error(`${database} has multiple versions (${matches.map((item) => item.displayVersion).join(", ")}). Use DB=${database}@<version> (or DB_VERSION).`);
return matches[0];
}
export function parseDatabaseSelection(selection, explicitVersion) {
if (!selection) return { database: selection, version: explicitVersion };
const selectorIndex = selection.lastIndexOf("@");
if (selectorIndex === -1) return { database: selection, version: explicitVersion };
const database = selection.slice(0, selectorIndex);
const selectorVersion = selection.slice(selectorIndex + 1);
if (!database || !selectorVersion) throw new Error("DB must use the format <product>@<version>, for example DB=mysql@8.4.");
if (explicitVersion && explicitVersion !== selectorVersion) {
throw new Error(`DB version ${selectorVersion} conflicts with DB_VERSION=${explicitVersion}.`);
}
return { database, version: explicitVersion || selectorVersion };
}
export function assertResetConfirmed(value) {
if (value !== "1") throw new Error("Reset deletes the environment volume. Re-run with CONFIRM=1.");
}
export function expectedContainerName(recipe) {
return `dbx-${recipe.database}-${recipe.displayVersion}`.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-");
}
export function defaultHostPortMappings(compose) {
const mappings = [];
const pattern = /^\s*-\s*["']?\$\{DB_BIND_ADDRESS:-127\.0\.0\.1\}:\$\{([A-Z][A-Z0-9_]*):-([0-9]+)\}:([0-9]+)(?:\/(tcp|udp))?["']?\s*$/;
for (const line of compose.split(/\r?\n/)) {
const match = line.match(pattern);
if (!match) continue;
mappings.push({ environment: match[1], hostPort: Number(match[2]), containerPort: Number(match[3]), protocol: match[4] || "tcp" });
}
return mappings;
}
export function recipePortMappings(recipe) {
const composePath = join(recipe.directory, "compose.yaml");
if (!existsSync(composePath)) return "";
const mappings = defaultHostPortMappings(readFileSync(composePath, "utf8")).sort((left, right) => left.hostPort - right.hostPort || left.containerPort - right.containerPort || left.protocol.localeCompare(right.protocol));
return mappings
.map((mapping) => {
const sharesPortWithAnotherProtocol = mappings.some((candidate) => candidate !== mapping && candidate.containerPort === mapping.containerPort && candidate.hostPort === mapping.hostPort && candidate.protocol !== mapping.protocol);
const sourcePort = sharesPortWithAnotherProtocol ? `${mapping.containerPort}/${mapping.protocol}` : mapping.containerPort;
return `${sourcePort} -> ${mapping.hostPort}`;
})
.join(", ");
}
export function databaseListRows(recipes) {
let previousDatabase = null;
return recipes.map((recipe) => {
const database = recipe.database === previousDatabase ? "" : recipe.name;
previousDatabase = recipe.database;
return [database, recipe.displayVersion, recipePortMappings(recipe), recipe.image, recipe.platforms.join(",")];
});
}
function validateHostPorts(recipe) {
const errors = [];
const hostPorts = recipe.hostPorts;
if (!hostPorts || typeof hostPorts !== "object" || Array.isArray(hostPorts)) return ["hostPorts must be a non-empty object"];
const entries = Object.entries(hostPorts);
if (entries.length === 0) return ["hostPorts must be a non-empty object"];
const [start, end] = DEFAULT_HOST_PORT_RANGES[recipe.database] ?? [];
if (!start) {
errors.push(`database ${recipe.database} has no allocated default host port range`);
}
for (const [environment, port] of entries) {
if (!/^[A-Z][A-Z0-9_]*$/.test(environment)) errors.push(`hostPorts key ${environment} must be an environment variable name`);
if (!Number.isInteger(port) || port < start || port > end) {
errors.push(`hostPorts.${environment} must be an integer in the ${start}-${end} range`);
}
}
if (new Set(entries.map(([, port]) => port)).size !== entries.length) errors.push("hostPorts must not reuse a port within a recipe");
if (recipe.connection?.port !== hostPorts.DB_PORT) errors.push("connection.port must match hostPorts.DB_PORT");
return errors;
}
export function validateHostPortAllocation(recipes) {
const errors = [];
const owners = new Map();
for (const recipe of recipes) {
for (const [environment, port] of Object.entries(recipe.hostPorts ?? {})) {
if (!Number.isInteger(port)) continue;
const owner = `${recipeSelector(recipe)} (${environment})`;
const existing = owners.get(port);
if (existing) errors.push(`host port ${port} is allocated to both ${existing} and ${owner}`);
else owners.set(port, owner);
}
}
return errors;
}
export function validateRecipe(recipe) {
const errors = [];
for (const field of ["database", "version", "displayVersion", "service", "connection", "hostPorts", "smoke", "shell", "defaultPort"]) {
if (recipe[field] === undefined) errors.push(`missing ${field}`);
}
if (!recipe.image || !/^[^\s]+:[^\s]+$/.test(recipe.image)) errors.push("image must be a pinned image reference");
if (recipe.connection?.host !== "127.0.0.1") errors.push("connection.host must be 127.0.0.1");
if (!Number.isInteger(recipe.defaultPort) || recipe.defaultPort < 1 || recipe.defaultPort > 65534) {
errors.push("defaultPort must be an integer between 1 and 65534");
}
errors.push(...validateHostPorts(recipe));
if (recipe.connection?.authentication === "none") {
if (recipe.connection.password !== undefined) errors.push("unauthenticated recipes must not declare connection.password");
} else if (recipe.connection?.password !== DEFAULT_PASSWORD) errors.push(`connection.password must be ${DEFAULT_PASSWORD}`);
if (recipe.database === "redis" ? recipe.connection?.database !== 0 : recipe.connection?.database !== DEFAULT_DATABASE) {
errors.push(`connection.database must be ${recipe.database === "redis" ? "0 for Redis" : DEFAULT_DATABASE}`);
}
if (!Array.isArray(recipe.platforms) || recipe.platforms.length === 0) errors.push("platforms must not be empty");
else if (recipe.platforms.some((platform) => !["linux/amd64", "linux/arm64"].includes(platform))) {
errors.push("platforms contains an unsupported platform");
}
if (basename(recipe.directory) !== recipe.displayVersion || basename(dirname(recipe.directory)) !== recipe.database) {
errors.push("recipe directory must match database/displayVersion");
}
const composePath = join(recipe.directory, "compose.yaml");
if (!existsSync(composePath)) errors.push("missing compose.yaml");
else {
const compose = readFileSync(composePath, "utf8");
if (!compose.includes(`container_name: ${expectedContainerName(recipe)}`)) {
errors.push(`compose.yaml container_name must be ${expectedContainerName(recipe)}`);
}
if (/image:\s*[^\n]*:latest(?:\s|$)/.test(compose)) errors.push("compose.yaml must not use latest");
if (!/healthcheck\s*:/.test(compose)) errors.push("compose.yaml must define a healthcheck");
if (!/^volumes\s*:/m.test(compose)) errors.push("compose.yaml must define a named volume");
if (!compose.includes(`image: ${recipe.image}`)) errors.push("compose.yaml image must match recipe.image");
if (recipe.platforms?.length === 1 && !compose.includes(`platform: ${recipe.platforms[0]}`)) {
errors.push(`single-platform image must set compose platform to ${recipe.platforms[0]}`);
}
if (!allPortMappingsDefaultToLoopback(compose)) {
errors.push("compose.yaml ports must default every mapping to 127.0.0.1 via DB_BIND_ADDRESS");
}
if (!serviceHasNamedVolume(compose, recipe.service)) errors.push("target service must mount a named volume");
const portMappings = defaultHostPortMappings(compose);
const mappedPorts = new Map();
for (const mapping of portMappings) {
const previousPort = mappedPorts.get(mapping.environment);
if (previousPort !== undefined && previousPort !== mapping.hostPort) {
errors.push(`compose.yaml uses conflicting defaults for ${mapping.environment}`);
}
mappedPorts.set(mapping.environment, mapping.hostPort);
}
const configuredPorts = Object.entries(recipe.hostPorts ?? {});
if (mappedPorts.size !== configuredPorts.length || configuredPorts.some(([environment, port]) => mappedPorts.get(environment) !== port)) {
errors.push("compose.yaml port defaults must match recipe hostPorts");
}
}
const initDirectory = join(recipe.directory, "init");
if (!existsSync(initDirectory) || readdirSync(initDirectory).length === 0) errors.push("init directory must contain a file");
if (!Array.isArray(recipe.smoke?.steps) || recipe.smoke.steps.length === 0) errors.push("smoke.steps must not be empty");
for (const step of recipe.smoke?.steps ?? []) {
if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push("every smoke step needs a name and command");
if (typeof step.expect !== "string" || step.expect.length === 0) errors.push("every smoke step needs an expected output");
}
if (!Array.isArray(recipe.shell) || recipe.shell.length === 0) errors.push("shell must be a non-empty command array");
if (recipe.bootstrap !== undefined) {
if (!recipe.bootstrap || typeof recipe.bootstrap !== "object") errors.push("bootstrap must be an object");
if (!recipe.bootstrap?.check || !Array.isArray(recipe.bootstrap.check.command) || recipe.bootstrap.check.command.length === 0 || typeof recipe.bootstrap.check.expect !== "string" || recipe.bootstrap.check.expect.length === 0) {
errors.push("bootstrap.check needs a command and expected output");
}
if (!Array.isArray(recipe.bootstrap?.steps) || recipe.bootstrap.steps.length === 0) errors.push("bootstrap.steps must not be empty");
for (const step of recipe.bootstrap?.steps ?? []) {
if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push("every bootstrap step needs a name and command");
if (typeof step.expect !== "string" || step.expect.length === 0) errors.push("every bootstrap step needs an expected output");
}
}
return errors;
}
function indentation(line) {
return line.length - line.trimStart().length;
}
function serviceBlock(compose, service) {
const lines = compose.split(/\r?\n/);
const start = lines.findIndex((line) => new RegExp(`^\\s{2}${service.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*$`).test(line));
if (start === -1) return [];
const block = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim() && indentation(line) <= 2) break;
block.push(line);
}
return block;
}
export function allPortMappingsDefaultToLoopback(compose) {
const lines = compose.split(/\r?\n/);
let portsIndent = null;
let foundMapping = false;
for (const line of lines) {
const trimmed = line.trim();
const lineIndent = indentation(line);
if (/^ports\s*:/.test(trimmed)) {
portsIndent = lineIndent;
const inlineMappings = trimmed.match(/['\"]([^'\"]+)['\"]/g) ?? [];
if (inlineMappings.length > 0) {
foundMapping = true;
if (inlineMappings.some((mapping) => !mapping.slice(1, -1).startsWith("${DB_BIND_ADDRESS:-127.0.0.1}:"))) return false;
}
continue;
}
if (portsIndent === null) continue;
if (trimmed && lineIndent <= portsIndent) {
portsIndent = null;
continue;
}
if (!trimmed.startsWith("-")) continue;
foundMapping = true;
const mapping = trimmed
.slice(1)
.trim()
.replace(/^['\"]|['\"]$/g, "");
if (!mapping.startsWith("${DB_BIND_ADDRESS:-127.0.0.1}:")) return false;
}
return foundMapping;
}
export function serviceHasNamedVolume(compose, service) {
const block = serviceBlock(compose, service);
let volumesIndent = null;
for (const line of block) {
const trimmed = line.trim();
const lineIndent = indentation(line);
if (/^volumes\s*:/.test(trimmed)) {
volumesIndent = lineIndent;
continue;
}
if (volumesIndent === null) continue;
if (trimmed && lineIndent <= volumesIndent) {
volumesIndent = null;
continue;
}
if (!trimmed.startsWith("-")) continue;
const mount = trimmed
.slice(1)
.trim()
.replace(/^['\"]|['\"]$/g, "");
if (/^[A-Za-z0-9][A-Za-z0-9_.-]*:/.test(mount) && !mount.startsWith("./") && !mount.startsWith("../")) return true;
}
return false;
}
function composeArgs(recipe, ...args) {
const project = (process.env.DB_PROJECT || `dbx-${basename(REPO_ROOT)}-${recipe.database}-${recipe.displayVersion}`).toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
return ["compose", "--project-name", project, "--file", join(recipe.directory, "compose.yaml"), ...args];
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { cwd: options.cwd ?? REPO_ROOT, env: process.env, encoding: "utf8", stdio: options.capture ? "pipe" : "inherit" });
if (result.error) throw result.error;
if (result.status !== 0) {
const detail = options.capture ? `\n${result.stderr || result.stdout}` : "";
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}.${detail}`);
}
return options.capture ? `${result.stdout ?? ""}${result.stderr ?? ""}` : "";
}
function runCompose(recipe, args, options) {
return run("docker", composeArgs(recipe, ...args), options);
}
function tryRunCompose(recipe, args) {
const result = spawnSync("docker", composeArgs(recipe, ...args), { cwd: REPO_ROOT, env: process.env, encoding: "utf8", stdio: "pipe" });
if (result.error) throw result.error;
return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
}
export function expandSmokeCommand(command, recipe, environment = process.env) {
const values = {
DB_PASSWORD: environment.DB_PASSWORD || recipe.connection.password,
DB_PORT: environment.DB_PORT || String(recipe.connection.port),
};
return command.map((value) => value.replace(/\$\{(DB_PASSWORD|DB_PORT)\}/g, (_, name) => values[name]));
}
export function dbxConnectionDeepLink(recipe, environment = process.env) {
const type = recipe.deepLinkType || DBX_DEEP_LINK_TYPES[recipe.database];
if (!type) return null;
const { connection } = recipe;
const params = new URLSearchParams({
type,
name: `${recipe.name} ${recipe.version} (local)`,
host: connection.host,
port: environment.DB_PORT || String(connection.port),
database: String(connection.database),
});
if (connection.username) params.set("user", connection.username);
if (connection.password) params.set("password", environment.DB_PASSWORD || connection.password);
if (connection.urlParams) params.set("url_params", connection.urlParams);
return `dbx://connection/new?${params}`;
}
function ensureBootstrap(recipe) {
if (!recipe.bootstrap) return;
// Keep one-shot setup synchronous after Compose health checks so --wait cannot return before credentials exist.
const checkCommand = expandSmokeCommand(recipe.bootstrap.check.command, recipe);
const check = tryRunCompose(recipe, ["exec", "-T", recipe.service, ...checkCommand]);
if (check.ok && check.output.includes(recipe.bootstrap.check.expect)) return;
for (const step of recipe.bootstrap.steps) {
const command = expandSmokeCommand(step.command, recipe);
const output = runCompose(recipe, ["exec", "-T", recipe.service, ...command], { capture: true });
if (!output.includes(step.expect)) throw new Error(`Bootstrap check did not contain expected text: ${step.expect}\n${output}`);
console.log(`OK ${step.name}`);
}
}
function printConnection(recipe) {
const connection = { ...recipe.connection };
if (process.env.DB_PORT) connection.port = Number(process.env.DB_PORT);
if (process.env.DB_PASSWORD) connection.password = process.env.DB_PASSWORD;
console.log(`${recipe.name} (${recipe.version})`);
for (const [key, value] of Object.entries(connection)) console.log(`${key}: ${value}`);
const deepLink = dbxConnectionDeepLink(recipe);
if (deepLink) console.log(`DBX connection link: ${deepLink}`);
else console.log(`DBX connection link: unavailable (DBX has no compatible ${recipe.name} connection type)`);
if (recipe.notes) console.log(`notes: ${recipe.notes}`);
const warning = architectureWarning(recipe);
if (warning) console.warn(`warning: ${warning}`);
}
function checkRecipes(recipes) {
let failed = false;
const allocationErrors = validateHostPortAllocation(recipes);
if (allocationErrors.length > 0) {
failed = true;
for (const error of allocationErrors) console.error(`FAIL host port allocation: ${error}`);
}
for (const recipe of recipes) {
const errors = validateRecipe(recipe);
if (errors.length) {
failed = true;
console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: ${errors.join("; ")}`);
continue;
}
const result = spawnSync("docker", composeArgs(recipe, "config", "--format", "json"), {
encoding: "utf8",
env: { ...process.env, DB_BIND_ADDRESS: "" },
});
if (result.error?.code === "ENOENT") {
console.log(`OK ${recipe.database}/${recipe.displayVersion} (static checks; Docker unavailable)`);
} else if (result.status !== 0) {
failed = true;
console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: docker compose config failed\n${result.stderr}`);
} else {
try {
validateRenderedCompose(recipe, JSON.parse(result.stdout));
console.log(`OK ${recipe.database}/${recipe.displayVersion}`);
} catch (error) {
failed = true;
console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: ${error.message}`);
}
}
}
if (failed) throw new Error("Database environment checks failed.");
}
export function validateRenderedCompose(recipe, rendered) {
const service = rendered.services?.[recipe.service];
if (!service) throw new Error("rendered Compose config is missing the target service");
if (service.container_name !== expectedContainerName(recipe)) {
throw new Error(`rendered target service container_name must be ${expectedContainerName(recipe)}`);
}
if (!service.healthcheck?.test) throw new Error("rendered target service is missing a healthcheck");
const ports = service.ports ?? [];
if (ports.length === 0 || ports.some((port) => typeof port !== "object" || port.host_ip !== "127.0.0.1")) {
throw new Error("rendered target service has a port mapping that does not default to 127.0.0.1");
}
const volumes = service.volumes ?? [];
if (!volumes.some((volume) => typeof volume === "object" && volume.type === "volume")) {
throw new Error("rendered target service does not mount a named volume");
}
}
export function main(argv = process.argv.slice(2)) {
const [command = "list", positionalDatabase, positionalVersion] = argv.filter((argument) => argument && argument !== "--");
const recipes = discoverRecipes();
if (command === "list") {
console.log(formatBorderedTable(["DATABASE", "VERSION", "PORTS (CONTAINER -> HOST)", "IMAGE", "PLATFORMS"], databaseListRows(recipes), true));
return;
}
if (command === "quick-start") return printQuickStart(recipes);
if (command === "completion") return printCompletionSetup();
if (command === "selectors") {
for (const recipe of recipes) console.log(recipeSelector(recipe));
return;
}
if (command === "make-targets") {
for (const target of discoverMakeTargets()) console.log(target);
return;
}
if (command === "check") return checkRecipes(recipes);
const { database, version } = parseDatabaseSelection(process.env.DB || positionalDatabase, process.env.DB_VERSION || positionalVersion);
if (command === "start" && !database) return printQuickStart(recipes);
const recipe = resolveRecipe(recipes, database, version);
switch (command) {
case "info":
printConnection(recipe);
break;
case "start":
case "up":
runCompose(recipe, ["up", "-d", "--wait"]);
ensureBootstrap(recipe);
printConnection(recipe);
break;
case "status":
runCompose(recipe, ["ps"]);
break;
case "logs":
runCompose(recipe, process.env.FOLLOW === "1" ? ["logs", "--follow"] : ["logs"]);
break;
case "shell":
runCompose(recipe, ["exec", recipe.service, ...expandSmokeCommand(recipe.shell, recipe)]);
break;
case "verify":
runCompose(recipe, ["up", "-d", "--wait"]);
ensureBootstrap(recipe);
for (const step of recipe.smoke.steps) {
const command = expandSmokeCommand(step.command, recipe);
const output = runCompose(recipe, ["exec", "-T", recipe.service, ...command], { capture: true });
if (step.expect && !output.includes(step.expect)) throw new Error(`Smoke check did not contain expected text: ${step.expect}\n${output}`);
console.log(`OK ${step.name}`);
}
break;
case "down":
runCompose(recipe, ["down", "--remove-orphans"]);
break;
case "reset":
assertResetConfirmed(process.env.CONFIRM);
runCompose(recipe, ["down", "--volumes", "--remove-orphans"]);
break;
default:
throw new Error(`Unknown command: ${command}`);
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
main();
} catch (error) {
console.error(`Error: ${error.message}`);
process.exitCode = 1;
}
}