#!/usr/bin/env node import { readFile } from "node:fs/promises"; import { buildSchemaContext, createBackend, DIRECT_QUERY_TYPES, BRIDGE_REQUIRED_TYPES, evaluateSqlSafety, formatSchemaContext, getDbxDiagnostics, isMainModule, postBridge, sqlSafetyFromEnv, type Backend, type DbxDiagnostics, type SqlSafetyOptions, } from "@dbx-app/node-core"; import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "./cli-format.js"; export interface CliResult { exitCode: number; stdout: string; stderr: string; } interface RunOptions { backend?: Backend; env?: NodeJS.ProcessEnv; diagnostics?: () => Promise; } interface ParsedFlags { args: string[]; json: boolean; format: "table" | "json" | "csv"; schema?: string; database?: string; tables?: string[]; maxTables?: number; maxRows?: number; timeoutMs?: number; file?: string; allowWrites: boolean; allowDangerous: boolean; help: boolean; version: boolean; } class CliError extends Error { constructor( readonly code: string, message: string, ) { super(message); } } export async function runCli(argv: string[], options: RunOptions = {}): Promise { const env = options.env ?? process.env; try { const flags = parseFlags(argv); const args = flags.args; if (flags.version) { return ok(`${await packageVersion()}\n`); } if (args.length === 0 || flags.help || args[0] === "help") { return ok(`${usage()}\n`); } const backend = options.backend ?? (await createBackend(env)); if (args[0] === "doctor") { ensureArgCount(args, 1, "dbx doctor"); const diagnostics = await (options.diagnostics ?? getDbxDiagnostics)(); if (flags.format === "json") return okJson(diagnostics); if (flags.format === "csv") { return ok( csvTable(["check", "value"], [ { check: "appDataDir", value: diagnostics.appDataDir }, { check: "dbPath", value: diagnostics.dbPath }, { check: "dbPathExists", value: diagnostics.dbPathExists }, { check: "connectionsTableExists", value: diagnostics.connectionsTableExists }, { check: "connectionRowCount", value: diagnostics.connectionRowCount }, { check: "loadConnectionsOk", value: diagnostics.loadConnectionsOk }, { check: "loadedConnectionCount", value: diagnostics.loadedConnectionCount }, { check: "loadConnectionsError", value: diagnostics.loadConnectionsError ?? "" }, { check: "loadConnectionsHint", value: diagnostics.loadConnectionsHint ?? "" }, { check: "bridgePortFile", value: diagnostics.bridgePortFile }, { check: "bridgePortFileExists", value: diagnostics.bridgePortFileExists }, { check: "bridgeUrl", value: diagnostics.bridgeUrl ?? "" }, ]), ); } return ok(formatDoctor(diagnostics)); } if (args[0] === "capabilities") { ensureArgCount(args, 1, "dbx capabilities"); const payload = { directQueryTypes: [...DIRECT_QUERY_TYPES], bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES], }; if (flags.format === "json") return okJson(payload); if (flags.format === "csv") { return ok( csvTable( ["mode", "type"], [ ...payload.directQueryTypes.map((type) => ({ mode: "direct", type })), ...payload.bridgeRequiredTypes.map((type) => ({ mode: "bridge", type })), ], ), ); } return ok(`${mdTable(["Mode", "Types"], [["Direct", payload.directQueryTypes.join(", ")], ["Requires DBX Desktop", payload.bridgeRequiredTypes.join(", ")]])}\n`); } if (args[0] === "connections" && args[1] === "list") { ensureArgCount(args, 2, "dbx connections list"); const connections = (await backend.loadConnections()).map(connectionSummary); if (flags.format === "json") return okJson({ connections }); if (flags.format === "csv") return ok(csvTable(["name", "type", "host", "port", "database"], connections)); return ok( `${mdTable( ["Name", "Type", "Host", "Port", "Database"], connections.map((c) => [c.name, c.type, c.host, String(c.port), c.database ?? ""]), )}\n`, ); } if (args[0] === "schema" && args[1] === "list") { ensureArgCount(args, 3, "dbx schema list"); const connectionName = required(args[2], "Connection name is required."); const config = await findConnectionOrThrow(backend, connectionName); const tables = await backend.listTables(config, flags.schema); if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, tables }); if (flags.format === "csv") return ok(csvTable(["name", "type"], tables)); return ok(`${mdTable(["Table", "Type"], tables.map((t) => [t.name, t.type]))}\n`); } if (args[0] === "schema" && args[1] === "describe") { ensureArgCount(args, 4, "dbx schema describe"); const connectionName = required(args[2], "Connection name is required."); const table = required(args[3], "Table name is required."); const config = await findConnectionOrThrow(backend, connectionName); const columns = await backend.describeTable(config, table, flags.schema); if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, table, columns }); if (flags.format === "csv") { return ok(csvTable(["name", "data_type", "is_nullable", "is_primary_key", "column_default", "comment"], columns)); } return ok( `${mdTable( ["Column", "Type", "Nullable", "Default", "Comment"], columns.map((c) => [ c.is_primary_key ? `${c.name} (PK)` : c.name, c.data_type, c.is_nullable ? "YES" : "NO", c.column_default ?? "", c.comment ?? "", ]), )}\n`, ); } if (args[0] === "query") { const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === (flags.file ? 1 : 2); ensureArgCount(args, usesDefaultConnection ? (flags.file ? 1 : 2) : flags.file ? 2 : 3, "dbx query"); const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required."); if (flags.file && args[2]) { throw new CliError("INVALID_ARGUMENT", "Provide SQL either inline or with --file, not both."); } const sqlArg = usesDefaultConnection ? args[1] : args[2]; const sql = flags.file ? await readFile(flags.file, "utf-8") : required(sqlArg, "SQL string or --file is required."); const envSafety = sqlSafetyFromEnv(env); if (flags.allowDangerous && !flags.allowWrites && !envSafety.allowWrites) { throw new CliError("INVALID_OPTION", "--allow-dangerous-sql requires --allow-writes."); } const safetyOptions: SqlSafetyOptions = { allowWrites: flags.allowWrites || envSafety.allowWrites, allowDangerous: flags.allowDangerous || envSafety.allowDangerous, }; const safety = evaluateSqlSafety(sql, safetyOptions); if (!safety.allowed) return fail("SQL_BLOCKED", safety.reason ?? "SQL blocked.", flags.json); const config = await findConnectionOrThrow(backend, connectionName); const result = await backend.executeQuery(config, sql, { maxRows: flags.maxRows, timeoutMs: flags.timeoutMs }); if (flags.format === "json") { return okJson({ connection: connectionName, columns: result.columns, rows: result.rows, row_count: result.row_count }); } if (flags.format === "csv") return ok(csvTable(result.columns, result.rows)); if (result.columns.length === 0) return ok(`Query executed. ${result.row_count} row(s) affected.\n`); return ok( `${mdTable( result.columns, result.rows.map((row) => result.columns.map((column) => formatCell(row[column]))), )}\n\n${result.row_count} row(s)\n`, ); } if (args[0] === "context") { const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === 1; ensureArgCount(args, usesDefaultConnection ? 1 : 2, "dbx context"); const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required."); const config = await findConnectionOrThrow(backend, connectionName); const context = await buildSchemaContext(backend, config, { schema: flags.schema, tables: flags.tables, maxTables: flags.maxTables, }); if (flags.format === "json") return okJson(context); if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx context."); return ok(`${formatSchemaContext(context)}\n`); } if (args[0] === "open") { ensureArgCount(args, 3, "dbx open"); const connectionName = required(args[1], "Connection name is required."); const table = required(args[2], "Table name is required."); const response = await postBridge("/open-table", { connection_name: connectionName, table, schema: flags.schema, database: flags.database, }); if (!response.ok) { return fail("DBX_NOT_RUNNING", response.text || "DBX is not running. Please start DBX first.", flags.json); } if (flags.format === "json") return okJson({ opened: true, connection: connectionName, table, schema: flags.schema, database: flags.database }); if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx open."); return ok(`Opened ${table} in DBX\n`); } return fail("USAGE", usage(), flags.json); } catch (error) { const message = error instanceof Error ? error.message : String(error); const code = error instanceof CliError ? error.code : typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : "ERROR"; const wantsJson = argv.includes("--json"); return fail(code, message, wantsJson); } } function parseFlags(argv: string[]): ParsedFlags { const args: string[] = []; const flags: ParsedFlags = { args, json: false, format: "table", allowWrites: false, allowDangerous: false, help: false, version: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--") { args.push(...argv.slice(i + 1)); break; } if (arg === "--json") { flags.json = true; flags.format = "json"; } else if (arg === "--format") flags.format = parseFormat(readOptionValue(argv, ++i, "--format")); else if (arg === "--help" || arg === "-h") flags.help = true; else if (arg === "--version" || arg === "-V") flags.version = true; else if (arg === "--schema") flags.schema = readOptionValue(argv, ++i, "--schema"); else if (arg === "--database") flags.database = readOptionValue(argv, ++i, "--database"); else if (arg === "--tables") flags.tables = splitCsv(readOptionValue(argv, ++i, "--tables")); else if (arg === "--max-tables") flags.maxTables = parsePositiveInt(readOptionValue(argv, ++i, "--max-tables"), "--max-tables"); else if (arg === "--limit") flags.maxRows = parsePositiveInt(readOptionValue(argv, ++i, "--limit"), "--limit"); else if (arg === "--timeout") flags.timeoutMs = parseDurationMs(readOptionValue(argv, ++i, "--timeout"), "--timeout"); else if (arg === "--file") flags.file = readOptionValue(argv, ++i, "--file"); else if (arg === "--allow-writes") flags.allowWrites = true; else if (arg === "--allow-dangerous-sql") flags.allowDangerous = true; else if (arg.startsWith("-")) throw new CliError("UNKNOWN_OPTION", `Unknown option: ${arg}`); else args.push(arg); } return flags; } function parseFormat(value: string): "table" | "json" | "csv" { if (value === "table" || value === "json" || value === "csv") return value; throw new CliError("INVALID_OPTION", "--format must be one of: table, json, csv."); } function ensureArgCount(args: string[], count: number, command: string): void { if (args.length !== count) { throw new CliError("INVALID_ARGUMENT", `${command} expects ${count - 1} argument(s); received ${args.length - 1}.`); } } function readOptionValue(argv: string[], index: number, option: string): string { const value = argv[index]; if (!value || value.startsWith("-")) { throw new CliError("INVALID_OPTION", `${option} requires a value.`); } return value; } function parsePositiveInt(value: string, option: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) { throw new CliError("INVALID_OPTION", `${option} must be a positive integer.`); } return parsed; } function parseDurationMs(value: string, option: string): number { const match = value.match(/^(\d+)(ms|s|m)?$/); if (!match) { throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`); } const amount = Number(match[1]); if (!Number.isInteger(amount) || amount < 1) { throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`); } const unit = match[2] ?? "ms"; if (unit === "ms") return amount; if (unit === "s") return amount * 1000; return amount * 60_000; } function splitCsv(value: string | undefined): string[] { return (value ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean); } async function findConnectionOrThrow(backend: Backend, name: string) { const config = await backend.findConnection(name); if (!config) throw new CliError("CONNECTION_NOT_FOUND", `Connection "${name}" not found.`); return config; } function required(value: string | undefined, message: string): string { if (!value) throw new Error(message); return value; } function ok(stdout: string): CliResult { return { exitCode: 0, stdout, stderr: "" }; } function okJson(payload: unknown): CliResult { return ok(`${JSON.stringify(payload, null, 2)}\n`); } function fail(code: string, message: string, json: boolean): CliResult { const text = json ? `${JSON.stringify(errorPayload(code, message), null, 2)}\n` : `${formatErrorMessage(code, message)}\n`; return { exitCode: 1, stdout: "", stderr: text }; } function usage(): string { return [ "Usage:", " dbx doctor [--json]", " dbx capabilities [--json]", " dbx connections list [--json]", " dbx schema list [--schema name] [--json]", " dbx schema describe [--schema name] [--json]", " dbx query [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]", " dbx context [--schema name] [--tables a,b] [--max-tables n] [--json]", " dbx open
[--schema name] [--database name] [--json]", ].join("\n"); } function formatDoctor(diagnostics: DbxDiagnostics): string { const rows = [ ["App data directory", diagnostics.appDataDir], ["DBX database", diagnostics.dbPathExists ? `found (${diagnostics.dbPath})` : `missing (${diagnostics.dbPath})`], ["Connections table", diagnostics.connectionsTableExists ? `${diagnostics.connectionRowCount} row(s)` : "missing"], [ "Connection loading", diagnostics.loadConnectionsOk ? `ok (${diagnostics.loadedConnectionCount} loaded)` : `failed (${diagnostics.loadConnectionsError ?? "unknown error"})`, ], ...(diagnostics.loadConnectionsHint ? [["Connection fix", diagnostics.loadConnectionsHint]] : []), ["Desktop bridge", diagnostics.bridgePortFileExists ? `available (${diagnostics.bridgeUrl ?? diagnostics.bridgePortFile})` : "not running"], ["Direct query types", diagnostics.directQueryTypes.join(", ")], ["Bridge-required types", diagnostics.bridgeRequiredTypes.join(", ")], ]; return `${mdTable(["Check", "Value"], rows)}\n`; } async function packageVersion(): Promise { const packageJson = await readFile(new URL("../package.json", import.meta.url), "utf-8"); const parsed = JSON.parse(packageJson) as { version?: string }; return parsed.version ?? "0.0.0"; } async function main() { const result = await runCli(process.argv.slice(2)); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.exitCode; } if (isMainModule(import.meta.url, process.argv[1])) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }