dbx/packages/cli/src/cli.ts

411 lines
17 KiB
JavaScript

#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { buildSchemaContext, createBackend, DIRECT_QUERY_TYPES, BRIDGE_REQUIRED_TYPES, evaluateSqlSafety, formatSchemaContext, getDbxDiagnostics, isMainModule, postBridge, supportsHashLineComments, 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;
backendFactory?: (env?: NodeJS.ProcessEnv) => Promise<Backend>;
env?: NodeJS.ProcessEnv;
diagnostics?: () => Promise<DbxDiagnostics>;
}
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<CliResult> {
const env = options.env ?? process.env;
let ownedBackend: Backend | undefined;
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 backendFactory = options.backendFactory ?? createBackend;
const backend = options.backend ?? (ownedBackend = await backendFactory(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 config = await findConnectionOrThrow(backend, connectionName);
const envSafety = sqlSafetyFromCliEnv(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,
hashLineComments: supportsHashLineComments(config.db_type),
};
const safety = evaluateSqlSafety(sql, safetyOptions);
if (!safety.allowed) return fail("SQL_BLOCKED", safety.reason ?? "SQL blocked.", flags.json);
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);
} finally {
await ownedBackend?.close?.().catch(() => {});
}
}
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 parseBooleanEnv(value: string | undefined): boolean {
if (value === undefined) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true";
}
function sqlSafetyFromCliEnv(env: NodeJS.ProcessEnv): Required<Pick<SqlSafetyOptions, "allowWrites" | "allowDangerous">> {
return {
allowWrites: parseBooleanEnv(env.DBX_MCP_ALLOW_WRITES),
allowDangerous: parseBooleanEnv(env.DBX_MCP_ALLOW_DANGEROUS_SQL),
};
}
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 <connection> [--schema name] [--json]",
" dbx schema describe <connection> <table> [--schema name] [--json]",
" dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]",
" dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]",
" dbx open <connection> <table> [--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<string> {
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;
});
}