#!/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= Override the default bind address (127.0.0.1)."); console.log(" DB_PORT= Override the default host port."); console.log(" DB_PASSWORD= Override the default password (123456)."); console.log(""); console.log("Next steps: make db-verify DB=@, make db-down DB=@"); 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}@ (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 @, 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; } }