From 4bcf61816246c9a11fa7d930ae602cd5df3fe091 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 18 Jul 2026 22:29:29 +0800 Subject: [PATCH] feat(cli): migrate CLI to native Rust binary --- .github/workflows/mcp-release.yml | 200 +++- Cargo.lock | 15 +- Cargo.toml | 2 +- apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 6 + apps/desktop/src/lib/backend/tauri.ts | 6 + .../src/lib/mongo/mongoShellCommand.ts | 14 + apps/desktop/src/stores/queryStore.ts | 5 +- crates/dbx-cli/Cargo.toml | 20 + crates/dbx-cli/src/main.rs | 1043 +++++++++++++++++ crates/dbx-core/Cargo.toml | 1 + crates/dbx-core/src/lib.rs | 1 + .../mongo.rs => dbx-core/src/mongo_shell.rs} | 198 +++- crates/dbx-mcp/Cargo.toml | 1 - crates/dbx-mcp/src/backend.rs | 368 ++++-- crates/dbx-mcp/src/lib.rs | 2 +- crates/dbx-mcp/src/server.rs | 49 +- crates/dbx-mcp/tests/local.rs | 2 +- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/mongo.rs | 12 + docs/content/docs/cli.cn.mdx | 12 +- docs/content/docs/cli.mdx | 12 +- package.json | 2 + packages/app-tests/mongoShellCommand.test.ts | 16 + packages/app-tests/queryStore.test.ts | 24 +- packages/cli-darwin-arm64/package.json | 11 + packages/cli-darwin-x64/package.json | 11 + packages/cli-linux-arm64-gnu/package.json | 12 + packages/cli-linux-x64-gnu/package.json | 12 + packages/cli-win32-arm64/package.json | 11 + packages/cli-win32-x64/package.json | 11 + packages/cli/README.md | 14 +- packages/cli/bin/dbx.js | 44 + packages/cli/package.json | 28 +- packages/cli/src/cli-format.ts | 62 - packages/cli/src/cli.ts | 410 ------- packages/cli/tests/bin.test.ts | 53 - packages/cli/tests/cli-format.test.ts | 66 -- packages/cli/tests/cli.test.ts | 429 ------- packages/cli/tsconfig.json | 14 - packages/cli/vitest.config.ts | 7 - pnpm-lock.yaml | 137 ++- scripts/benchmark-cli.mjs | 79 ++ scripts/release.mjs | 10 +- scripts/verify-cli-migration.mjs | 184 +++ src-tauri/src/commands/mongo_cmd.rs | 5 + src-tauri/src/lib.rs | 1 + 47 files changed, 2369 insertions(+), 1255 deletions(-) create mode 100644 crates/dbx-cli/Cargo.toml create mode 100644 crates/dbx-cli/src/main.rs rename crates/{dbx-mcp/src/mongo.rs => dbx-core/src/mongo_shell.rs} (70%) create mode 100644 packages/cli-darwin-arm64/package.json create mode 100644 packages/cli-darwin-x64/package.json create mode 100644 packages/cli-linux-arm64-gnu/package.json create mode 100644 packages/cli-linux-x64-gnu/package.json create mode 100644 packages/cli-win32-arm64/package.json create mode 100644 packages/cli-win32-x64/package.json create mode 100755 packages/cli/bin/dbx.js delete mode 100644 packages/cli/src/cli-format.ts delete mode 100644 packages/cli/src/cli.ts delete mode 100644 packages/cli/tests/bin.test.ts delete mode 100644 packages/cli/tests/cli-format.test.ts delete mode 100644 packages/cli/tests/cli.test.ts delete mode 100644 packages/cli/tsconfig.json delete mode 100644 packages/cli/vitest.config.ts create mode 100644 scripts/benchmark-cli.mjs create mode 100644 scripts/verify-cli-migration.mjs diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index c87d7ed85b..b6c2aef38d 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -77,6 +77,12 @@ jobs: "packages/mongo-shell/package.json", "packages/node-core/package.json", "packages/cli/package.json", + "packages/cli-darwin-arm64/package.json", + "packages/cli-darwin-x64/package.json", + "packages/cli-linux-arm64-gnu/package.json", + "packages/cli-linux-x64-gnu/package.json", + "packages/cli-win32-arm64/package.json", + "packages/cli-win32-x64/package.json", "packages/mcp-server/package.json", "packages/mcp-darwin-arm64/package.json", "packages/mcp-darwin-x64/package.json", @@ -99,6 +105,15 @@ jobs: } writeJson(mcpPackagePath, mcpPackage); + const cliPackagePath = "packages/cli/package.json"; + const cliPackage = readJson(cliPackagePath); + for (const dependency of Object.keys(cliPackage.optionalDependencies ?? {})) { + if (dependency.startsWith("@dbx-app/cli-")) { + cliPackage.optionalDependencies[dependency] = version; + } + } + writeJson(cliPackagePath, cliPackage); + const lockPath = "pnpm-lock.yaml"; let lockfile = fs.readFileSync(lockPath, "utf8"); for (const dependency of Object.keys(mcpPackage.optionalDependencies ?? {})) { @@ -106,6 +121,11 @@ jobs: const specifierPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+`); lockfile = lockfile.replace(specifierPattern, `$1${version}`); } + for (const dependency of Object.keys(cliPackage.optionalDependencies ?? {})) { + const escapedDependency = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const specifierPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+`); + lockfile = lockfile.replace(specifierPattern, `$1${version}`); + } fs.writeFileSync(lockPath, lockfile); const serverPath = "packages/mcp-server/server.json"; @@ -122,12 +142,16 @@ jobs: const cargo = fs.readFileSync(cargoPath, "utf8").replace(/^version = "[^"]+"/m, `version = "${version}"`); fs.writeFileSync(cargoPath, cargo); + const cliCargoPath = "crates/dbx-cli/Cargo.toml"; + const cliCargo = fs.readFileSync(cliCargoPath, "utf8").replace(/^version = "[^"]+"/m, `version = "${version}"`); + fs.writeFileSync(cliCargoPath, cliCargo); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=packages-v${version}\n`); NODE - name: Update Rust lockfile - run: cargo check -p dbx-mcp --no-default-features + run: cargo check -p dbx-mcp -p dbx-cli --no-default-features - name: Run package tests run: pnpm test:packages @@ -143,7 +167,7 @@ jobs: - name: Commit package release version run: | VERSION="${{ steps.version.outputs.version }}" - git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml packages/mongo-shell/package.json packages/node-core/package.json packages/cli/package.json packages/mcp-server/package.json packages/mcp-server/server.json packages/mcp-*/package.json + git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml crates/dbx-cli/Cargo.toml packages/mongo-shell/package.json packages/node-core/package.json packages/cli/package.json packages/cli-*/package.json packages/mcp-server/package.json packages/mcp-server/server.json packages/mcp-*/package.json if git diff --cached --quiet; then echo "Package versions already committed for ${VERSION}." else @@ -275,10 +299,129 @@ jobs: pnpm --filter @dbx-app/node-core publish --access public --provenance --no-git-checks echo "published_or_existing=true" >> "$GITHUB_OUTPUT" + publish-cli-platforms: + name: Publish ${{ matrix.package-name }} + needs: prepare + runs-on: ${{ matrix.runner }} + env: + CARGO_INCREMENTAL: "0" + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: aarch64-apple-darwin + package-dir: cli-darwin-arm64 + package-name: "@dbx-app/cli-darwin-arm64" + binary: dbx + - runner: macos-15-intel + target: x86_64-apple-darwin + package-dir: cli-darwin-x64 + package-name: "@dbx-app/cli-darwin-x64" + binary: dbx + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + package-dir: cli-linux-arm64-gnu + package-name: "@dbx-app/cli-linux-arm64-gnu" + binary: dbx + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + package-dir: cli-linux-x64-gnu + package-name: "@dbx-app/cli-linux-x64-gnu" + binary: dbx + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + package-dir: cli-win32-arm64 + package-name: "@dbx-app/cli-win32-arm64" + binary: dbx.exe + - runner: windows-2025 + target: x86_64-pc-windows-msvc + package-dir: cli-win32-x64 + package-name: "@dbx-app/cli-win32-x64" + binary: dbx.exe + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Setup sccache + uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + with: + version: "v0.10.0" + + - name: Configure S3 sccache + if: env.SCCACHE_GHA_ENABLED != 'true' + shell: bash + env: + CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }} + CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }} + CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }} + CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }} + CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }} + CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }} + run: | + { + echo "SCCACHE_BUCKET=${CACHE_BUCKET}" + echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}" + echo "SCCACHE_REGION=${CACHE_REGION}" + echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}" + echo "SCCACHE_S3_USE_SSL=true" + echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}" + echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}" + } >> "$GITHUB_ENV" + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: dbx-cli-${{ matrix.target }} + cache-targets: false + cache-on-failure: true + + - name: Install Linux native dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libfontconfig1-dev + + - name: Build Rust CLI binary + shell: bash + run: cargo build --release -p dbx-cli --no-default-features --target "${{ matrix.target }}" + + - name: Stage platform package + shell: bash + run: | + mkdir -p "packages/${{ matrix.package-dir }}/bin" + cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" + if [[ "${{ runner.os }}" != "Windows" ]]; then chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"; fi + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + + - name: Publish platform package + shell: bash + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then + echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping." + exit 0 + fi + npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance + publish-cli: name: Publish @dbx-app/cli runs-on: ubuntu-latest - needs: [prepare, publish-node-core] + needs: [prepare, publish-cli-platforms] steps: - uses: actions/checkout@v5 @@ -296,22 +439,35 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - name: Install native build dependencies - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build workspace dependencies - run: pnpm --filter @dbx-app/node-core build + run: pnpm install --frozen-lockfile --ignore-scripts - name: Publish package env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} VERSION: ${{ needs.prepare.outputs.version }} run: | + wait_for_package() { + package="$1" + for attempt in $(seq 1 18); do + if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then return 0; fi + echo "Waiting for ${package}@${VERSION} to become visible on npm (${attempt}/18)." + sleep 10 + done + return 1 + } + for package in \ + @dbx-app/cli-darwin-arm64 \ + @dbx-app/cli-darwin-x64 \ + @dbx-app/cli-linux-arm64-gnu \ + @dbx-app/cli-linux-x64-gnu \ + @dbx-app/cli-win32-arm64 \ + @dbx-app/cli-win32-x64; do + wait_for_package "${package}" || { + echo "${package}@${VERSION} is not available on npm; refusing to publish @dbx-app/cli." + exit 1 + } + done if npm view "@dbx-app/cli@${VERSION}" version >/dev/null 2>&1; then echo "@dbx-app/cli@${VERSION} already exists on npm; skipping." exit 0 @@ -648,30 +804,12 @@ jobs: sha256 "__CLI_SHA256__" license "Apache-2.0" - depends_on "node" - - on_linux do - depends_on "pkgconf" => :build - depends_on "libsecret" - end + depends_on "node" def install system "npm", "install", *std_npm_args bin.install_symlink libexec.glob("bin/*") - # Rebuild better-sqlite3 and keytar native bindings for the current platform. - # prebuild-install is blocked by the Homebrew sandbox during npm install, - # so we must rebuild them explicitly via node-gyp. - node_modules = libexec/"lib/node_modules/@dbx-app/cli/node_modules" - - cd node_modules/"better-sqlite3" do - system "npm", "run", "build-release" - end - - cd node_modules/"keytar" do - rm_r "prebuilds" if File.directory?("prebuilds") - system "npm", "run", "build" - end end test do diff --git a/Cargo.lock b/Cargo.lock index 296e5c26d0..48574d10e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1858,6 +1858,19 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "dbx-cli" +version = "0.4.34" +dependencies = [ + "async-trait", + "dbx-core", + "dbx-mcp", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "dbx-core" version = "0.1.0" @@ -1877,6 +1890,7 @@ dependencies = [ "font-kit", "futures", "iana-time-zone", + "json5", "jsonwebtoken", "log", "mongodb", @@ -1915,7 +1929,6 @@ version = "0.4.34" dependencies = [ "async-trait", "dbx-core", - "json5", "reqwest 0.12.28", "rmcp", "schemars 1.2.1", diff --git a/Cargo.toml b/Cargo.toml index d03a0dc0cb..3d96f4490c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp"] +members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"] [patch.crates-io] tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", branch = "master" } diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 5529f073d6..87f699beed 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -446,6 +446,7 @@ export const mongoDropDatabase = forward("mongoDropDatabase"); export const mongoDropCollection = forward("mongoDropCollection"); export const documentFindDocuments = forward("documentFindDocuments"); export const mongoFindDocuments = forward("mongoFindDocuments"); +export const mongoParseShellCommand = forward("mongoParseShellCommand"); export const mongoFindOne = forward("mongoFindOne"); export const mongoCountDocuments = forward("mongoCountDocuments"); export const mongoServerVersion = forward("mongoServerVersion"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index a04f6a97a3..46fa4af693 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -37,6 +37,7 @@ import type { SshConfigHostEntry, TunnelProfile, } from "@/types/database"; +import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand"; import type { CollectionInfo } from "@/types/database"; import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff"; import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities"; @@ -2071,6 +2072,11 @@ export async function mongoFindDocuments(connectionId: string, database: string, return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, executionId); } +export async function mongoParseShellCommand(source: string): Promise { + const raw = await post>("/api/mongo/parse-shell-command", { source }); + return normalizeRustMongoCommand(raw); +} + export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise { return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 614089e0fb..1fb972058e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand"; import type { ConnectionConfig, ConnectionTestResult, @@ -1858,6 +1859,11 @@ export async function mongoFindOne(connectionId: string, database: string, colle return invoke("mongo_find_one", { connectionId, database, collection, filter, projection, options, executionId }); } +export async function mongoParseShellCommand(source: string): Promise { + const raw = await invoke>("mongo_parse_shell_command", { source }); + return normalizeRustMongoCommand(raw); +} + export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise { return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId }); } diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 71283aa8fe..097d9672bf 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -90,6 +90,20 @@ export type MongoCommand = export type MongoWriteCommand = Extract; +export function normalizeRustMongoCommand(raw: Record): MongoCommand { + const command = Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== null)) as Record; + if (command.kind === "countDocuments") { + const { accurate, ...rest } = command; + return { ...rest, kind: "countDocuments", mode: accurate ? "accurate" : "legacy" } as MongoCommand; + } + if (command.kind === "dropIndexes") { + const { single, indexes, ...rest } = command; + if (single) return { ...rest, kind: "dropIndex", index: indexes } as MongoCommand; + return { ...rest, kind: "dropIndexes", ...(indexes ? { indexes } : {}) } as MongoCommand; + } + return command as MongoCommand; +} + export interface ParsedMongoCommand { text: string; command: MongoCommand; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 2bb665b42e..f44d368815 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2932,7 +2932,7 @@ export const useQueryStore = defineStore("query", () => { let mongoEditTarget: QueryTab["mongoEditTarget"] | undefined; for (const parsedCommand of mongoCommands) { - const mongoCommand = parsedCommand.command; + let mongoCommand = parsedCommand.command; const sourceStatement = parsedCommand.text; const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to }; const commandStartedAt = performance.now(); @@ -2944,6 +2944,9 @@ export const useQueryStore = defineStore("query", () => { return annotated; }; try { + // The frontend parser remains responsible for editor ranges, while + // dbx-core is authoritative for command semantics at execution time. + mongoCommand = await api.mongoParseShellCommand(sourceStatement); switch (mongoCommand.kind) { case "find": { queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); diff --git a/crates/dbx-cli/Cargo.toml b/crates/dbx-cli/Cargo.toml new file mode 100644 index 0000000000..1d5dbbd6f4 --- /dev/null +++ b/crates/dbx-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dbx-cli" +version = "0.4.34" +edition = "2021" +license = "Apache-2.0" + +[[bin]] +name = "dbx" +path = "src/main.rs" + +[dependencies] +dbx-core = { path = "../dbx-core", default-features = false } +dbx-mcp = { path = "../dbx-mcp" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } + +[dev-dependencies] +async-trait = "0.1" +tempfile = "3" diff --git a/crates/dbx-cli/src/main.rs b/crates/dbx-cli/src/main.rs new file mode 100644 index 0000000000..83d677917b --- /dev/null +++ b/crates/dbx-cli/src/main.rs @@ -0,0 +1,1043 @@ +use std::{env, path::PathBuf, process::ExitCode, sync::Arc}; + +use dbx_core::{ + models::connection::{ConnectionConfig, DatabaseType}, + production_safety::{is_production_database, targets_production_database}, + sql_risk::{classify_sql_risk_for_database, SqlRisk}, + types::{ColumnInfo, QueryResult, TableInfo}, +}; +use dbx_mcp::{ + mongo::{self, MongoSafetyError}, + DbxBackend, LocalBackend, WebBackend, +}; +use serde::Serialize; +use serde_json::{json, Map, Value}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const DIRECT_QUERY_TYPES: &[&str] = + &["postgres", "redshift", "mysql", "doris", "starrocks", "manticoresearch", "sqlite", "rqlite", "kwdb", "questdb"]; +const BRIDGE_REQUIRED_TYPES: &[&str] = &[ + "cloudflare-d1", + "redis", + "mongodb", + "duckdb", + "clickhouse", + "sqlserver", + "oracle", + "elasticsearch", + "qdrant", + "milvus", + "weaviate", + "chromadb", + "etcd", + "dameng", + "kingbase", + "highgo", + "vastbase", + "goldendb", + "databend", + "gaussdb", + "yashandb", + "databricks", + "saphana", + "teradata", + "vertica", + "firebird", + "exasol", + "opengauss", + "oceanbase-oracle", + "gbase", + "tdengine", + "iotdb", + "h2", + "snowflake", + "trino", + "prestosql", + "hive", + "spark", + "db2", + "informix", + "iris", + "neo4j", + "cassandra", + "bigquery", + "kylin", + "sundb", + "oscar", + "xugu", + "jdbc", + "access", + "influxdb", + "zookeeper", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OutputFormat { + Table, + Json, + Csv, +} + +#[derive(Debug)] +struct Flags { + args: Vec, + format: OutputFormat, + schema: Option, + database: Option, + tables: Vec, + max_tables: Option, + max_rows: Option, + timeout_ms: Option, + file: Option, + allow_writes: bool, + allow_dangerous: bool, + help: bool, + version: bool, +} + +#[derive(Debug)] +struct CliError { + code: &'static str, + message: String, +} + +impl CliError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { code, message: message.into() } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Diagnostics { + app_data_dir: String, + db_path: String, + db_path_exists: bool, + connections_table_exists: bool, + connection_row_count: usize, + load_connections_ok: bool, + loaded_connection_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + load_connections_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + load_connections_hint: Option, + bridge_port_file: String, + bridge_port_file_exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + bridge_url: Option, + direct_query_types: Vec<&'static str>, + bridge_required_types: Vec<&'static str>, +} + +#[tokio::main] +async fn main() -> ExitCode { + match run(env::args().skip(1).collect()).await { + Ok(output) => { + print!("{output}"); + ExitCode::SUCCESS + } + Err((error, json_output)) => { + if json_output { + eprintln!( + "{}", + serde_json::to_string_pretty(&json!({ "error": { "code": error.code, "message": error.message } })) + .unwrap() + ); + } else { + eprintln!("Error [{}]: {}", error.code, error.message); + } + ExitCode::FAILURE + } + } +} + +async fn run(argv: Vec) -> Result { + let wants_json = argv.iter().any(|arg| arg == "--json"); + let flags = parse_flags(&argv).map_err(|error| (error, wants_json))?; + let json_output = flags.format == OutputFormat::Json; + if flags.version { + return Ok(format!("{VERSION}\n")); + } + if flags.args.is_empty() || flags.help || flags.args.first().is_some_and(|arg| arg == "help") { + return Ok(format!("{}\n", usage())); + } + if flags.args[0] == "doctor" { + ensure_arg_count(&flags.args, 1, "dbx doctor").map_err(|error| (error, json_output))?; + let diagnostics = diagnostics().await; + return format_diagnostics(&diagnostics, flags.format).map_err(|error| (error, json_output)); + } + if flags.args[0] == "capabilities" { + ensure_arg_count(&flags.args, 1, "dbx capabilities").map_err(|error| (error, json_output))?; + return format_capabilities(flags.format).map_err(|error| (error, json_output)); + } + + let backend: Arc = if let Ok(base_url) = env::var("DBX_WEB_URL") { + Arc::new( + WebBackend::new(base_url, env::var("DBX_WEB_PASSWORD").unwrap_or_default()) + .map_err(|message| (CliError::new("CONNECTION_STORE_ERROR", message), json_output))?, + ) + } else { + let db_path = dbx_mcp::paths::storage_db_path() + .map_err(|message| (CliError::new("CONNECTION_STORE_ERROR", message), json_output))?; + Arc::new( + LocalBackend::open(&db_path) + .await + .map_err(|message| (CliError::new("CONNECTION_STORE_ERROR", message), json_output))?, + ) + }; + + let result = run_with_backend(backend.as_ref(), flags).await; + result.map_err(|error| (error, json_output)) +} + +async fn run_with_backend(backend: &dyn DbxBackend, flags: Flags) -> Result { + let args = &flags.args; + if args.first().is_some_and(|arg| arg == "connections") && args.get(1).is_some_and(|arg| arg == "list") { + ensure_arg_count(args, 2, "dbx connections list")?; + return format_connections(&backend.load_connections().await.map_err(store_error)?, flags.format); + } + if args.first().is_some_and(|arg| arg == "schema") && args.get(1).is_some_and(|arg| arg == "list") { + ensure_arg_count(args, 3, "dbx schema list")?; + let connection_name = required(args.get(2), "Connection name is required.")?; + let connection = find_connection(backend, connection_name).await?; + let database = selected_database(&connection, flags.database.as_deref()); + let schema = flags.schema.as_deref().unwrap_or(""); + let tables = backend.list_tables(&connection, &database, schema).await.map_err(command_error)?; + return format_tables(connection_name, flags.schema.as_deref(), &tables, flags.format); + } + if args.first().is_some_and(|arg| arg == "schema") && args.get(1).is_some_and(|arg| arg == "describe") { + ensure_arg_count(args, 4, "dbx schema describe")?; + let connection_name = required(args.get(2), "Connection name is required.")?; + let table = required(args.get(3), "Table name is required.")?; + let connection = find_connection(backend, connection_name).await?; + let database = selected_database(&connection, flags.database.as_deref()); + let schema = flags.schema.as_deref().unwrap_or(""); + let columns = backend.get_columns(&connection, &database, schema, table).await.map_err(command_error)?; + return format_columns(connection_name, flags.schema.as_deref(), table, &columns, flags.format); + } + if args.first().is_some_and(|arg| arg == "query") { + return run_query(backend, &flags).await; + } + if args.first().is_some_and(|arg| arg == "context") { + return run_context(backend, &flags).await; + } + if args.first().is_some_and(|arg| arg == "open") { + ensure_arg_count(args, 3, "dbx open")?; + let connection = required(args.get(1), "Connection name is required.")?; + let table = required(args.get(2), "Table name is required.")?; + if flags.format == OutputFormat::Csv { + return Err(CliError::new("INVALID_OPTION", "CSV format is not supported for dbx open.")); + } + backend + .bridge_request( + "/open-table", + optional_object([ + ("connection_name", Some(json!(connection))), + ("table", Some(json!(table))), + ("schema", flags.schema.clone().map(|value| json!(value))), + ("database", flags.database.clone().map(|value| json!(value))), + ]), + ) + .await + .map_err(|message| CliError::new("DBX_NOT_RUNNING", message))?; + if flags.format == OutputFormat::Json { + return json_string(&optional_object([ + ("opened", Some(json!(true))), + ("connection", Some(json!(connection))), + ("table", Some(json!(table))), + ("schema", flags.schema.clone().map(|value| json!(value))), + ("database", flags.database.clone().map(|value| json!(value))), + ])); + } + return Ok(format!("Opened {table} in DBX\n")); + } + Err(CliError::new("USAGE", usage())) +} + +async fn run_query(backend: &dyn DbxBackend, flags: &Flags) -> Result { + let args = &flags.args; + let default_connection = env::var("DBX_CONNECTION").ok().filter(|value| !value.is_empty()); + let uses_default = default_connection.is_some() && args.len() == if flags.file.is_some() { 1 } else { 2 }; + ensure_arg_count( + args, + if uses_default { + if flags.file.is_some() { + 1 + } else { + 2 + } + } else if flags.file.is_some() { + 2 + } else { + 3 + }, + "dbx query", + )?; + let connection_name = if uses_default { + default_connection.as_deref().unwrap() + } else { + required(args.get(1), "Connection name is required.")? + }; + if flags.file.is_some() && args.get(2).is_some() { + return Err(CliError::new("INVALID_ARGUMENT", "Provide SQL either inline or with --file, not both.")); + } + let sql = if let Some(file) = &flags.file { + tokio::fs::read_to_string(file).await.map_err(|error| CliError::new("ERROR", error.to_string()))? + } else { + required(args.get(if uses_default { 1 } else { 2 }), "SQL string or --file is required.")?.to_string() + }; + let connection = find_connection(backend, connection_name).await?; + let env_allow_writes = env_flag("DBX_MCP_ALLOW_WRITES"); + let env_allow_dangerous = env_flag("DBX_MCP_ALLOW_DANGEROUS_SQL"); + if flags.allow_dangerous && !flags.allow_writes && !env_allow_writes { + return Err(CliError::new("INVALID_OPTION", "--allow-dangerous-sql requires --allow-writes.")); + } + let allow_writes = flags.allow_writes || env_allow_writes; + let allow_dangerous = flags.allow_dangerous || env_allow_dangerous; + let database = selected_database(&connection, flags.database.as_deref()); + if connection.db_type == DatabaseType::Redis { + return Err(CliError::new( + "REDIS_COMMAND_REQUIRED", + "Redis connections do not accept SQL through dbx query. Use an MCP Redis command tool or DBX directly.", + )); + } + if connection.db_type == DatabaseType::MongoDb { + let command = mongo::parse(&sql).map_err(|message| CliError::new("QUERY_ERROR", message))?; + if let Err(error) = mongo::validate_safety( + &command, + allow_writes, + allow_dangerous, + is_production_database(&connection, &database), + ) { + return Err(match error { + MongoSafetyError::WritesDisabled => { + CliError::new("SQL_BLOCKED", "MongoDB write command is blocked. Pass --allow-writes to allow it.") + } + MongoSafetyError::EmptyFilter => CliError::new( + "SQL_BLOCKED", + "MongoDB update/delete commands require a non-empty filter unless --allow-dangerous-sql is set.", + ), + MongoSafetyError::Dangerous => CliError::new( + "SQL_BLOCKED", + "Dangerous MongoDB command is blocked. Pass --allow-dangerous-sql to allow it.", + ), + MongoSafetyError::ProductionWrite => { + CliError::new("SQL_BLOCKED", "Writes and DDL are blocked for production databases.") + } + }); + } + let mut result = + backend.execute_mongo_command(&connection, &database, &command).await.map_err(command_error)?; + truncate_query_result(&mut result, flags.max_rows); + return format_query(connection_name, &result, flags.format); + } + let risk = classify_sql_risk_for_database(&sql, connection.db_type) + .map_err(|message| CliError::new("SQL_BLOCKED", message))?; + if risk == SqlRisk::Transaction + || risk == SqlRisk::Write && !allow_writes + || risk == SqlRisk::Ddl && !allow_dangerous + { + return Err(CliError::new("SQL_BLOCKED", format!("{risk} statement is blocked."))); + } + if risk != SqlRisk::ReadOnly && targets_production_database(&connection, &database, &sql) { + return Err(CliError::new("SQL_BLOCKED", "Writes and DDL are blocked for production databases.")); + } + let timeout_secs = flags.timeout_ms.map(|value| value.div_ceil(1000)); + let result = backend + .execute_query(&connection, &database, &sql, flags.max_rows, timeout_secs) + .await + .map_err(command_error)?; + format_query(connection_name, &result, flags.format) +} + +fn truncate_query_result(result: &mut QueryResult, max_rows: Option) { + let Some(max_rows) = max_rows else { return }; + if result.rows.len() > max_rows { + result.rows.truncate(max_rows); + result.truncated = true; + } +} + +async fn run_context(backend: &dyn DbxBackend, flags: &Flags) -> Result { + let args = &flags.args; + let default_connection = env::var("DBX_CONNECTION").ok().filter(|value| !value.is_empty()); + let uses_default = default_connection.is_some() && args.len() == 1; + ensure_arg_count(args, if uses_default { 1 } else { 2 }, "dbx context")?; + if flags.format == OutputFormat::Csv { + return Err(CliError::new("INVALID_OPTION", "CSV format is not supported for dbx context.")); + } + let connection_name = if uses_default { + default_connection.as_deref().unwrap() + } else { + required(args.get(1), "Connection name is required.")? + }; + let connection = find_connection(backend, connection_name).await?; + let database = selected_database(&connection, flags.database.as_deref()); + let schema = flags.schema.as_deref().unwrap_or(""); + let all_tables = backend.list_tables(&connection, &database, schema).await.map_err(command_error)?; + let max_tables = flags.max_tables.unwrap_or(8).clamp(1, 20); + let requested = !flags.tables.is_empty(); + let selected: Vec = if !requested { + all_tables.iter().take(max_tables).cloned().collect() + } else { + all_tables + .iter() + .filter(|table| flags.tables.iter().any(|name| name.eq_ignore_ascii_case(&table.name))) + .cloned() + .collect() + }; + let truncated = selected.len() > max_tables || (!requested && all_tables.len() > max_tables); + let selected = selected.into_iter().take(max_tables).collect::>(); + let mut context_tables = Vec::new(); + for table in selected { + let columns = backend.get_columns(&connection, &database, schema, &table.name).await.map_err(command_error)?; + context_tables.push(json!({ "name": table.name, "type": table.table_type, "columns": columns })); + } + let payload = json!({ + "connection": connection_name, + "database": database, + "schema": schema, + "truncated": truncated, + "tables": context_tables, + }); + if flags.format == OutputFormat::Json { + return json_string(&payload); + } + let mut header = vec![format!("Connection: {connection_name}")]; + if !database.is_empty() { + header.push(format!("Database: {database}")); + } + if !schema.is_empty() { + header.push(format!("Schema: {schema}")); + } + let mut output = format!("{}\n", header.join("\n")); + for table in payload["tables"].as_array().unwrap() { + output.push_str(&format!( + "\n## {}\nType: {}\n", + table["name"].as_str().unwrap_or_default(), + table["type"].as_str().unwrap_or_default() + )); + for column in table["columns"].as_array().unwrap_or(&Vec::new()) { + output.push_str(&format!( + "- {} {} {}{}{}\n", + column["name"].as_str().unwrap_or_default(), + column["data_type"].as_str().unwrap_or_default(), + if column["is_nullable"].as_bool().unwrap_or(false) { "NULL" } else { "NOT NULL" }, + if column["is_primary_key"].as_bool().unwrap_or(false) { " PK" } else { "" }, + column["comment"].as_str().map(|comment| format!(" -- {comment}")).unwrap_or_default() + )); + } + } + if truncated { + output.push_str("\nNote: table list was truncated; request specific table names for more context.\n"); + } + Ok(output) +} + +async fn find_connection(backend: &dyn DbxBackend, name: &str) -> Result { + backend + .load_connections() + .await + .map_err(store_error)? + .into_iter() + .find(|connection| connection.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| CliError::new("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found."))) +} + +fn selected_database(connection: &ConnectionConfig, override_database: Option<&str>) -> String { + override_database.map(ToOwned::to_owned).or_else(|| connection.database.clone()).unwrap_or_default() +} + +fn parse_flags(argv: &[String]) -> Result { + let mut flags = Flags { + args: Vec::new(), + format: OutputFormat::Table, + schema: None, + database: None, + tables: Vec::new(), + max_tables: None, + max_rows: None, + timeout_ms: None, + file: None, + allow_writes: false, + allow_dangerous: false, + help: false, + version: false, + }; + let mut index = 0; + while index < argv.len() { + let arg = &argv[index]; + if arg == "--" { + flags.args.extend(argv[index + 1..].iter().cloned()); + break; + } + match arg.as_str() { + "--json" => flags.format = OutputFormat::Json, + "--format" => { + let value = option_value(argv, &mut index, "--format")?; + flags.format = match value.as_str() { + "table" => OutputFormat::Table, + "json" => OutputFormat::Json, + "csv" => OutputFormat::Csv, + _ => return Err(CliError::new("INVALID_OPTION", "--format must be one of: table, json, csv.")), + }; + } + "--help" | "-h" => flags.help = true, + "--version" | "-V" => flags.version = true, + "--schema" => flags.schema = Some(option_value(argv, &mut index, "--schema")?), + "--database" => flags.database = Some(option_value(argv, &mut index, "--database")?), + "--tables" => { + flags.tables = option_value(argv, &mut index, "--tables")? + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() + } + "--max-tables" => { + flags.max_tables = + Some(positive_usize(&option_value(argv, &mut index, "--max-tables")?, "--max-tables")?) + } + "--limit" => flags.max_rows = Some(positive_usize(&option_value(argv, &mut index, "--limit")?, "--limit")?), + "--timeout" => { + flags.timeout_ms = Some(duration_ms(&option_value(argv, &mut index, "--timeout")?, "--timeout")?) + } + "--file" => flags.file = Some(PathBuf::from(option_value(argv, &mut index, "--file")?)), + "--allow-writes" => flags.allow_writes = true, + "--allow-dangerous-sql" => flags.allow_dangerous = true, + value if value.starts_with('-') => { + return Err(CliError::new("UNKNOWN_OPTION", format!("Unknown option: {value}"))) + } + _ => flags.args.push(arg.clone()), + } + index += 1; + } + Ok(flags) +} + +fn option_value(argv: &[String], index: &mut usize, option: &'static str) -> Result { + *index += 1; + argv.get(*index) + .filter(|value| !value.starts_with('-')) + .cloned() + .ok_or_else(|| CliError::new("INVALID_OPTION", format!("{option} requires a value."))) +} + +fn positive_usize(value: &str, option: &'static str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| CliError::new("INVALID_OPTION", format!("{option} must be a positive integer."))) +} + +fn duration_ms(value: &str, option: &'static str) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { + (value, 1) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1000) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60_000) + } else { + (value, 1) + }; + number + .parse::() + .ok() + .filter(|amount| *amount > 0) + .and_then(|amount| amount.checked_mul(multiplier)) + .ok_or_else(|| { + CliError::new("INVALID_OPTION", format!("{option} must be a positive duration such as 500ms, 10s, or 1m.")) + }) +} + +fn ensure_arg_count(args: &[String], count: usize, command: &'static str) -> Result<(), CliError> { + if args.len() == count { + Ok(()) + } else { + Err(CliError::new( + "INVALID_ARGUMENT", + format!("{command} expects {} argument(s); received {}.", count - 1, args.len().saturating_sub(1)), + )) + } +} + +fn required<'a>(value: Option<&'a String>, message: &'static str) -> Result<&'a str, CliError> { + value.map(String::as_str).filter(|value| !value.is_empty()).ok_or_else(|| CliError::new("ERROR", message)) +} + +fn env_flag(name: &str) -> bool { + env::var(name).ok().is_some_and(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true")) +} + +fn store_error(message: String) -> CliError { + CliError::new("CONNECTION_STORE_ERROR", message) +} +fn command_error(message: String) -> CliError { + CliError::new("ERROR", message) +} + +fn db_type_name(db_type: DatabaseType) -> String { + serde_json::to_value(db_type) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| format!("{db_type:?}").to_ascii_lowercase()) +} + +fn format_connections(connections: &[ConnectionConfig], format: OutputFormat) -> Result { + let rows: Vec = connections + .iter() + .map(|connection| { + optional_object([ + ("name", Some(json!(connection.name))), + ("type", Some(json!(db_type_name(connection.db_type)))), + ("host", Some(json!(connection.host))), + ("port", Some(json!(connection.port))), + ("database", connection.database.clone().filter(|value| !value.is_empty()).map(|value| json!(value))), + ]) + }) + .collect(); + match format { + OutputFormat::Json => json_string(&json!({ "connections": rows })), + OutputFormat::Csv => Ok(csv_table(&["name", "type", "host", "port", "database"], &rows)), + OutputFormat::Table => Ok(format!( + "{}\n", + markdown_table( + &["Name", "Type", "Host", "Port", "Database"], + &rows, + &["name", "type", "host", "port", "database"] + ) + )), + } +} + +fn format_tables( + connection: &str, + schema: Option<&str>, + tables: &[TableInfo], + format: OutputFormat, +) -> Result { + let rows: Vec = tables + .iter() + .map(|table| optional_object([("name", Some(json!(table.name))), ("type", Some(json!(table.table_type)))])) + .collect(); + match format { + OutputFormat::Json => json_string(&optional_object([ + ("connection", Some(json!(connection))), + ("schema", schema.map(|value| json!(value))), + ("tables", Some(json!(rows))), + ])), + OutputFormat::Csv => Ok(csv_table(&["name", "type"], &rows)), + OutputFormat::Table => Ok(format!("{}\n", markdown_table(&["Table", "Type"], &rows, &["name", "type"]))), + } +} + +fn format_columns( + connection: &str, + schema: Option<&str>, + table: &str, + columns: &[ColumnInfo], + format: OutputFormat, +) -> Result { + if format == OutputFormat::Json { + return json_string(&optional_object([ + ("connection", Some(json!(connection))), + ("schema", schema.map(|value| json!(value))), + ("table", Some(json!(table))), + ("columns", Some(json!(columns))), + ])); + } + let rows: Vec = columns.iter().map(|column| json!({ "name": column.name, "data_type": column.data_type, "is_nullable": column.is_nullable, "is_primary_key": column.is_primary_key, "column_default": column.column_default, "comment": column.comment, "display_name": if column.is_primary_key { format!("{} (PK)", column.name) } else { column.name.clone() }, "nullable": if column.is_nullable { "YES" } else { "NO" } })).collect(); + if format == OutputFormat::Csv { + return Ok(csv_table( + &["name", "data_type", "is_nullable", "is_primary_key", "column_default", "comment"], + &rows, + )); + } + Ok(format!( + "{}\n", + markdown_table( + &["Column", "Type", "Nullable", "Default", "Comment"], + &rows, + &["display_name", "data_type", "nullable", "column_default", "comment"] + ) + )) +} + +fn format_query(connection: &str, result: &QueryResult, format: OutputFormat) -> Result { + let rows: Vec = result + .rows + .iter() + .map(|values| { + Value::Object(result.columns.iter().cloned().zip(values.iter().cloned()).collect::>()) + }) + .collect(); + let row_count = if result.columns.is_empty() { result.affected_rows } else { result.rows.len() as u64 }; + match format { + OutputFormat::Json => json_string( + &json!({ "connection": connection, "columns": result.columns, "rows": rows, "row_count": row_count }), + ), + OutputFormat::Csv => Ok(csv_table(&result.columns.iter().map(String::as_str).collect::>(), &rows)), + OutputFormat::Table if result.columns.is_empty() => { + Ok(format!("Query executed. {row_count} row(s) affected.\n")) + } + OutputFormat::Table => Ok(format!( + "{}\n\n{row_count} row(s)\n", + markdown_table( + &result.columns.iter().map(String::as_str).collect::>(), + &rows, + &result.columns.iter().map(String::as_str).collect::>() + ) + )), + } +} + +fn format_capabilities(format: OutputFormat) -> Result { + match format { + OutputFormat::Json => json_string( + &json!({ "directQueryTypes": DIRECT_QUERY_TYPES, "bridgeRequiredTypes": BRIDGE_REQUIRED_TYPES }), + ), + OutputFormat::Csv => { + let rows: Vec = DIRECT_QUERY_TYPES + .iter() + .map(|kind| json!({ "mode": "direct", "type": kind })) + .chain(BRIDGE_REQUIRED_TYPES.iter().map(|kind| json!({ "mode": "bridge", "type": kind }))) + .collect(); + Ok(csv_table(&["mode", "type"], &rows)) + } + OutputFormat::Table => { + let rows = vec![ + json!({ "mode": "Direct", "types": DIRECT_QUERY_TYPES.join(", ") }), + json!({ "mode": "Requires DBX Desktop", "types": BRIDGE_REQUIRED_TYPES.join(", ") }), + ]; + Ok(format!("{}\n", markdown_table(&["Mode", "Types"], &rows, &["mode", "types"]))) + } + } +} + +async fn diagnostics() -> Diagnostics { + let app_data_dir = dbx_mcp::paths::app_data_dir().unwrap_or_default(); + let db_path = app_data_dir.join(dbx_mcp::paths::STORAGE_DB_FILE_NAME); + let bridge_port_file = app_data_dir.join("mcp-bridge-port"); + let db_path_exists = db_path.exists(); + let bridge_port_file_exists = bridge_port_file.exists(); + let bridge_url = if bridge_port_file_exists { + tokio::fs::read_to_string(&bridge_port_file).await.ok().map(|port| format!("http://127.0.0.1:{}", port.trim())) + } else { + None + }; + let loaded = if db_path_exists { + match LocalBackend::open(&db_path).await { + Ok(backend) => backend.load_connections().await, + Err(error) => Err(error), + } + } else { + Err("DBX database does not exist.".to_string()) + }; + let (load_connections_ok, connections, error) = match loaded { + Ok(connections) => (true, connections, None), + Err(error) => (false, Vec::new(), Some(error)), + }; + Diagnostics { + app_data_dir: app_data_dir.display().to_string(), + db_path: db_path.display().to_string(), + db_path_exists, + connections_table_exists: load_connections_ok, + connection_row_count: connections.len(), + load_connections_ok, + loaded_connection_count: connections.len(), + load_connections_error: error, + load_connections_hint: None, + bridge_port_file: bridge_port_file.display().to_string(), + bridge_port_file_exists, + bridge_url, + direct_query_types: DIRECT_QUERY_TYPES.to_vec(), + bridge_required_types: BRIDGE_REQUIRED_TYPES.to_vec(), + } +} + +fn format_diagnostics(value: &Diagnostics, format: OutputFormat) -> Result { + if format == OutputFormat::Json { + return json_string(value); + } + let rows = vec![ + json!({ "check": "App data directory", "value": value.app_data_dir }), + json!({ "check": "DBX database", "value": if value.db_path_exists { format!("found ({})", value.db_path) } else { format!("missing ({})", value.db_path) } }), + json!({ "check": "Connections table", "value": if value.connections_table_exists { format!("{} row(s)", value.connection_row_count) } else { "missing".to_string() } }), + json!({ "check": "Connection loading", "value": if value.load_connections_ok { format!("ok ({} loaded)", value.loaded_connection_count) } else { format!("failed ({})", value.load_connections_error.as_deref().unwrap_or("unknown error")) } }), + json!({ "check": "Desktop bridge", "value": if value.bridge_port_file_exists { format!("available ({})", value.bridge_url.as_deref().unwrap_or(&value.bridge_port_file)) } else { "not running".to_string() } }), + json!({ "check": "Direct query types", "value": value.direct_query_types.join(", ") }), + json!({ "check": "Bridge-required types", "value": value.bridge_required_types.join(", ") }), + ]; + if format == OutputFormat::Csv { + return Ok(csv_table(&["check", "value"], &rows)); + } + Ok(format!("{}\n", markdown_table(&["Check", "Value"], &rows, &["check", "value"]))) +} + +fn json_string(value: &impl Serialize) -> Result { + serde_json::to_string_pretty(value) + .map(|value| format!("{value}\n")) + .map_err(|error| CliError::new("ERROR", error.to_string())) +} + +fn optional_object(fields: [(&str, Option); N]) -> Value { + let mut object = Map::new(); + for (key, value) in fields { + if let Some(value) = value { + object.insert(key.to_string(), value); + } + } + Value::Object(object) +} + +fn markdown_table(headers: &[&str], rows: &[Value], keys: &[&str]) -> String { + let mut output = format!("| {} |\n| {} |", headers.join(" | "), vec!["---"; headers.len()].join(" | ")); + for row in rows { + output.push_str(&format!( + "\n| {} |", + keys.iter().map(|key| format_cell(&row[*key])).collect::>().join(" | ") + )); + } + output +} + +fn csv_table(headers: &[&str], rows: &[Value]) -> String { + let mut output = format!("{}\n", headers.join(",")); + for row in rows { + output.push_str(&format!( + "{}\n", + headers.iter().map(|key| csv_cell(&format_cell(&row[*key]))).collect::>().join(",") + )); + } + output +} + +fn format_cell(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(value) => value.replace('|', "\\|").replace('\n', " "), + Value::Bool(value) => value.to_string(), + other => other.to_string(), + } +} + +fn csv_cell(value: &str) -> String { + if value.contains([',', '"', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn usage() -> &'static str { + "Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx connections list [--json]\n dbx schema list [--schema name] [--json]\n dbx schema describe [--schema name] [--json]\n dbx query [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx open
[--schema name] [--database name] [--json]" +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use dbx_core::{agent_events::ToolResult, agent_tools::AgentSqlPermissions, storage::Storage}; + use dbx_mcp::{backend::new_connection_config, mongo::MongoCommand}; + + struct MongoBackend { + connection: ConnectionConfig, + } + + impl MongoBackend { + fn new() -> Self { + Self { + connection: new_connection_config( + "mongo-test".to_string(), + "local-mongo".to_string(), + DatabaseType::MongoDb, + "127.0.0.1".to_string(), + 27017, + String::new(), + String::new(), + Some("test".to_string()), + false, + None, + ) + .unwrap(), + } + } + } + + #[async_trait] + impl DbxBackend for MongoBackend { + async fn load_connections(&self) -> Result, String> { + Ok(vec![self.connection.clone()]) + } + + async fn execute_agent_tool( + &self, + _connection: &ConnectionConfig, + _database: &str, + _tool_name: &str, + _arguments: Value, + _permissions: AgentSqlPermissions, + ) -> ToolResult { + panic!("Mongo CLI queries must not fall through to agent SQL execution") + } + + async fn execute_mongo_command( + &self, + _connection: &ConnectionConfig, + _database: &str, + command: &MongoCommand, + ) -> Result { + assert!(matches!(command, MongoCommand::Insert { collection, .. } if collection == "products")); + Ok(QueryResult { + columns: Vec::new(), + column_types: Vec::new(), + column_sortables: Vec::new(), + rows: Vec::new(), + affected_rows: 2, + execution_time_ms: 0, + truncated: false, + session_id: None, + has_more: false, + }) + } + + async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> { + Ok(()) + } + } + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_existing_json_and_query_flags() { + let flags = + parse_flags(&args(&["query", "local", "select 1", "--limit", "50", "--timeout", "10s", "--json"])).unwrap(); + assert_eq!(flags.args, args(&["query", "local", "select 1"])); + assert_eq!(flags.max_rows, Some(50)); + assert_eq!(flags.timeout_ms, Some(10_000)); + assert!(flags.format == OutputFormat::Json); + } + + #[test] + fn preserves_double_dash_sql() { + let flags = parse_flags(&args(&["query", "local", "--json", "--", "-- comment\nselect 1"])).unwrap(); + assert_eq!(flags.args, args(&["query", "local", "-- comment\nselect 1"])); + } + + #[test] + fn rejects_unknown_options_with_stable_code() { + let error = parse_flags(&args(&["connections", "list", "--wat"])).unwrap_err(); + assert_eq!(error.code, "UNKNOWN_OPTION"); + } + + #[test] + fn formats_csv_using_existing_escaping_rules() { + let rows = vec![json!({ "name": "alpha,beta", "value": "a\"b" })]; + assert_eq!(csv_table(&["name", "value"], &rows), "name,value\n\"alpha,beta\",\"a\"\"b\"\n"); + } + + #[test] + fn dangerous_sql_requires_explicit_permission() { + let risk = classify_sql_risk_for_database("drop table users", DatabaseType::Postgres).unwrap(); + assert_eq!(risk, SqlRisk::Ddl); + } + + #[tokio::test] + async fn routes_legacy_mongo_insert_through_shared_mongo_backend() { + let flags = parse_flags(&args(&[ + "query", + "local-mongo", + "db.products.insert([{name: 'first'}, {name: 'second'}])", + "--allow-writes", + "--json", + ])) + .unwrap(); + let output = run_with_backend(&MongoBackend::new(), flags).await.unwrap(); + let value: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value["connection"], "local-mongo"); + assert_eq!(value["row_count"], 2); + assert_eq!(value["columns"], json!([])); + } + + #[tokio::test] + async fn blocks_mongo_writes_without_explicit_permission() { + let flags = + parse_flags(&args(&["query", "local-mongo", "db.products.insertOne({name: 'demo'})", "--json"])).unwrap(); + let error = run_with_backend(&MongoBackend::new(), flags).await.unwrap_err(); + assert_eq!(error.code, "SQL_BLOCKED"); + } + + #[tokio::test] + #[ignore = "requires DBX_MCP_TEST_MONGO_HOST and DBX_MCP_TEST_MONGO_PASSWORD"] + async fn executes_legacy_mongo_insert_without_desktop_process() { + let host = env::var("DBX_MCP_TEST_MONGO_HOST").expect("MongoDB host"); + let port = env::var("DBX_MCP_TEST_MONGO_PORT") + .unwrap_or_else(|_| "27017".to_string()) + .parse::() + .expect("MongoDB port"); + let password = env::var("DBX_MCP_TEST_MONGO_PASSWORD").expect("MongoDB password"); + let directory = tempfile::tempdir().expect("temporary data directory"); + let db_path = directory.path().join("dbx.db"); + let storage = Storage::open(&db_path).await.expect("open storage"); + let mut connection = new_connection_config( + "mongo-cli-e2e".to_string(), + "mongo-cli-e2e".to_string(), + DatabaseType::MongoDb, + host, + port, + "root".to_string(), + password, + Some("dbx_mcp_test".to_string()), + false, + None, + ) + .unwrap(); + connection.url_params = Some("authSource=admin".to_string()); + storage.save_connections(&[connection]).await.expect("save connection"); + let backend = LocalBackend::open(&db_path).await.expect("open local backend"); + + let cleanup = parse_flags(&args(&[ + "query", + "mongo-cli-e2e", + "db.items.deleteMany({_id: {$in: ['rust-cli-e2e-1', 'rust-cli-e2e-2']}})", + "--allow-writes", + ])) + .unwrap(); + run_with_backend(&backend, cleanup).await.expect("initial cleanup"); + + let insert = parse_flags(&args(&[ + "query", + "mongo-cli-e2e", + "db.items.insert([{_id: 'rust-cli-e2e-1', name: 'Ada'}, {_id: 'rust-cli-e2e-2', name: 'Grace'}])", + "--allow-writes", + "--json", + ])) + .unwrap(); + let inserted: Value = serde_json::from_str(&run_with_backend(&backend, insert).await.unwrap()).unwrap(); + assert_eq!(inserted["row_count"], 2); + + let find = parse_flags(&args(&[ + "query", + "mongo-cli-e2e", + "db.items.find({_id: {$in: ['rust-cli-e2e-1', 'rust-cli-e2e-2']}}).sort({_id: 1})", + "--json", + ])) + .unwrap(); + let found: Value = serde_json::from_str(&run_with_backend(&backend, find).await.unwrap()).unwrap(); + assert_eq!(found["row_count"], 2); + assert_eq!(found["rows"][0]["name"], "Ada"); + assert_eq!(found["rows"][1]["name"], "Grace"); + + let cleanup = parse_flags(&args(&[ + "query", + "mongo-cli-e2e", + "db.items.deleteMany({_id: {$in: ['rust-cli-e2e-1', 'rust-cli-e2e-2']}})", + "--allow-writes", + ])) + .unwrap(); + run_with_backend(&backend, cleanup).await.expect("final cleanup"); + } +} diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 18c60aaddb..e0226301e9 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -40,6 +40,7 @@ sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"] [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] } +json5 = "0.4" regex = "1" rayon = "1" percent-encoding = "2" diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 15da4a7c97..a7c2c81e2e 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod history; pub mod jdbc; pub mod models; pub mod mongo_ops; +pub mod mongo_shell; #[cfg(feature = "mq-admin")] pub mod mq; pub mod nacos; diff --git a/crates/dbx-mcp/src/mongo.rs b/crates/dbx-core/src/mongo_shell.rs similarity index 70% rename from crates/dbx-mcp/src/mongo.rs rename to crates/dbx-core/src/mongo_shell.rs index da9d7eb864..1b7de0a407 100644 --- a/crates/dbx-mcp/src/mongo.rs +++ b/crates/dbx-core/src/mongo_shell.rs @@ -1,20 +1,57 @@ +use serde::Serialize; use serde_json::Value; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind")] pub enum MongoCommand { + #[serde(rename = "version")] Version, + #[serde(rename = "use")] + Use { database: String }, + #[serde(rename = "find")] Find { collection: String, filter: String, projection: Option, sort: Option, skip: u64, limit: i64 }, + #[serde(rename = "findOne")] + FindOne { collection: String, filter: String, projection: Option, options: Option }, + #[serde(rename = "countDocuments")] Count { collection: String, filter: String, accurate: bool }, + #[serde(rename = "aggregate")] Aggregate { collection: String, pipeline: String, options: Option }, + #[serde(rename = "distinct")] Distinct { collection: String, field: String, filter: Option }, + #[serde(rename = "getIndexes")] GetIndexes { collection: String }, + #[serde(rename = "collectionStats")] CollectionStats { collection: String, metric: String, scale: Option }, - Insert { collection: String, documents: String }, + #[serde(rename = "insert")] + Insert { + collection: String, + #[serde(rename = "docsJson")] + documents: String, + }, + #[serde(rename = "update")] Update { collection: String, filter: String, update: String, options: Option, many: bool }, + #[serde(rename = "delete")] Delete { collection: String, filter: String, many: bool }, + #[serde(rename = "createIndex")] CreateIndex { collection: String, keys: String, options: Option }, + #[serde(rename = "dropIndexes")] DropIndexes { collection: String, indexes: Option, single: bool }, + #[serde(rename = "dropCollection")] DropCollection { collection: String }, + #[serde(rename = "findOneAndUpdate")] + FindOneAndUpdate { collection: String, filter: String, update: String, options: Option }, + #[serde(rename = "findOneAndReplace")] + FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option }, + #[serde(rename = "findOneAndDelete")] + FindOneAndDelete { collection: String, filter: String, options: Option }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MongoSafetyError { + WritesDisabled, + EmptyFilter, + Dangerous, + ProductionWrite, } impl MongoCommand { @@ -27,6 +64,9 @@ impl MongoCommand { | Self::CreateIndex { .. } | Self::DropIndexes { .. } | Self::DropCollection { .. } + | Self::FindOneAndUpdate { .. } + | Self::FindOneAndReplace { .. } + | Self::FindOneAndDelete { .. } ) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline)) } @@ -38,17 +78,45 @@ impl MongoCommand { pub fn has_empty_filter(&self) -> bool { match self { - Self::Update { filter, .. } | Self::Delete { filter, .. } => is_empty_object(filter), + Self::Update { filter, .. } + | Self::Delete { filter, .. } + | Self::FindOneAndUpdate { filter, .. } + | Self::FindOneAndReplace { filter, .. } + | Self::FindOneAndDelete { filter, .. } => is_empty_object(filter), _ => false, } } } +pub fn validate_safety( + command: &MongoCommand, + allow_writes: bool, + allow_dangerous: bool, + production_database: bool, +) -> Result<(), MongoSafetyError> { + if command.is_mutating() && !allow_writes { + return Err(MongoSafetyError::WritesDisabled); + } + if command.has_empty_filter() && !allow_dangerous { + return Err(MongoSafetyError::EmptyFilter); + } + if command.is_dangerous() && !allow_dangerous { + return Err(MongoSafetyError::Dangerous); + } + if command.is_mutating() && production_database { + return Err(MongoSafetyError::ProductionWrite); + } + Ok(()) +} + pub fn parse(input: &str) -> Result { let source = input.trim().trim_end_matches(';').trim(); if source.eq_ignore_ascii_case("db.version()") { return Ok(MongoCommand::Version); } + if let Some(database) = parse_use_database(source) { + return Ok(MongoCommand::Use { database }); + } let (collection, prefix_end) = parse_collection_prefix(source)?; if let Some((args, tail)) = method_call(source, prefix_end, "find") { @@ -75,6 +143,44 @@ pub fn parse(input: &str) -> Result { return Ok(MongoCommand::Find { collection, filter, projection, sort, skip, limit }); } + if let Some((args, tail)) = method_call(source, prefix_end, "findOne") { + if !tail.is_empty() || args.len() > 3 { + return Err("Invalid MongoDB findOne() command.".to_string()); + } + return Ok(MongoCommand::FindOne { + collection, + filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?, + projection: optional_json_argument(args.get(1))?, + options: optional_json_argument(args.get(2))?, + }); + } + + for method in ["findOneAndUpdate", "findOneAndReplace"] { + if let Some((args, tail)) = method_call(source, prefix_end, method) { + if !tail.is_empty() || !(2..=3).contains(&args.len()) { + return Err(format!("Invalid MongoDB {method}() command.")); + } + let filter = normalized_json(&args[0])?; + let value = normalized_json(&args[1])?; + let options = optional_json_argument(args.get(2))?; + return Ok(if method == "findOneAndUpdate" { + MongoCommand::FindOneAndUpdate { collection, filter, update: value, options } + } else { + MongoCommand::FindOneAndReplace { collection, filter, replacement: value, options } + }); + } + } + if let Some((args, tail)) = method_call(source, prefix_end, "findOneAndDelete") { + if !tail.is_empty() || !(1..=2).contains(&args.len()) { + return Err("Invalid MongoDB findOneAndDelete() command.".to_string()); + } + return Ok(MongoCommand::FindOneAndDelete { + collection, + filter: normalized_json(&args[0])?, + options: optional_json_argument(args.get(1))?, + }); + } + for (method, accurate) in [("countDocuments", true), ("count", false)] { if let Some((args, tail)) = method_call(source, prefix_end, method) { if !tail.is_empty() || args.len() > 1 { @@ -152,6 +258,18 @@ pub fn parse(input: &str) -> Result { } return Ok(MongoCommand::Insert { collection, documents }); } + // MongoDB keeps insert() for legacy shell compatibility; preserve its + // single-document-or-array contract without silently ignoring options. + if let Some((args, tail)) = method_call(source, prefix_end, "insert") { + if !tail.is_empty() || args.len() != 1 { + return Err("Invalid MongoDB insert() command.".to_string()); + } + let documents = normalized_json(&args[0])?; + if !parse_json_value(&documents).is_some_and(|value| value.is_object() || value.is_array()) { + return Err("MongoDB insert() requires a document or document array.".to_string()); + } + return Ok(MongoCommand::Insert { collection, documents }); + } for (method, many) in [("updateOne", false), ("updateMany", true)] { if let Some((args, tail)) = method_call(source, prefix_end, method) { @@ -338,6 +456,25 @@ fn parse_json_value(value: &str) -> Option { serde_json::from_str(value).ok() } +fn optional_json_argument(value: Option<&String>) -> Result, String> { + value.filter(|value| !value.trim().is_empty()).map(|value| normalized_json(value)).transpose() +} + +fn parse_use_database(source: &str) -> Option { + let mut parts = source.split_whitespace(); + if !parts.next()?.eq_ignore_ascii_case("use") { + return None; + } + let database = parts.next()?; + if parts.next().is_some() + || database.is_empty() + || !database.chars().all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) + { + return None; + } + Some(database.to_string()) +} + fn is_empty_object(value: &str) -> bool { parse_json_value(value).is_some_and(|value| value.as_object().is_some_and(|object| object.is_empty())) } @@ -480,6 +617,61 @@ mod tests { assert!(matches!(update, MongoCommand::Update { many: true, options: Some(_), .. })); } + #[test] + fn accepts_legacy_insert_and_rejects_unsupported_options() { + assert_eq!( + parse(r#"db.getCollection("accounting_reconciliations").insert({accountId: 999, status: "done"})"#) + .unwrap(), + MongoCommand::Insert { + collection: "accounting_reconciliations".to_string(), + documents: r#"{"accountId":999,"status":"done"}"#.to_string(), + } + ); + assert_eq!( + parse("db.products.insert([{name: 'first'}, {name: 'second'}])").unwrap(), + MongoCommand::Insert { + collection: "products".to_string(), + documents: r#"[{"name":"first"},{"name":"second"}]"#.to_string(), + } + ); + assert!(parse("db.products.insert({name: 'demo'}, {writeConcern: {w: 1}})").is_err()); + assert!(parse("db.products.insert()").is_err()); + assert!(parse("db.products.insert('demo')").is_err()); + } + + #[test] + fn parses_desktop_find_one_find_and_modify_and_use_commands() { + assert_eq!( + parse("db.users.findOne({name: 'Ada'}, {_id: 0}, {maxTimeMS: 500})").unwrap(), + MongoCommand::FindOne { + collection: "users".to_string(), + filter: r#"{"name":"Ada"}"#.to_string(), + projection: Some(r#"{"_id":0}"#.to_string()), + options: Some(r#"{"maxTimeMS":500}"#.to_string()), + } + ); + assert!(matches!( + parse("db.users.findOneAndUpdate({_id: 1}, {$set: {active: true}}, {returnDocument: 'after'})").unwrap(), + MongoCommand::FindOneAndUpdate { options: Some(_), .. } + )); + assert!(matches!( + parse("db.users.findOneAndReplace({_id: 1}, {name: 'Grace'})").unwrap(), + MongoCommand::FindOneAndReplace { .. } + )); + assert!(matches!(parse("db.users.findOneAndDelete({_id: 1})").unwrap(), MongoCommand::FindOneAndDelete { .. })); + assert_eq!(parse("use analytics-test").unwrap(), MongoCommand::Use { database: "analytics-test".to_string() }); + } + + #[test] + fn serializes_frontend_command_contract() { + let insert = serde_json::to_value(parse("db.items.insert({_id: 1})").unwrap()).unwrap(); + assert_eq!(insert["kind"], "insert"); + assert_eq!(insert["docsJson"], r#"{"_id":1}"#); + let count = serde_json::to_value(parse("db.items.count({})").unwrap()).unwrap(); + assert_eq!(count["kind"], "countDocuments"); + assert_eq!(count["accurate"], false); + } + #[test] fn accepts_stats_and_rejects_negative_pagination() { assert!(matches!( diff --git a/crates/dbx-mcp/Cargo.toml b/crates/dbx-mcp/Cargo.toml index 1ccf6fa7ac..4894685564 100644 --- a/crates/dbx-mcp/Cargo.toml +++ b/crates/dbx-mcp/Cargo.toml @@ -13,7 +13,6 @@ async-trait = "0.1" dbx-core = { path = "../dbx-core", default-features = false } rmcp = { version = "2.2.0", features = ["client", "transport-io"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -json5 = "0.4" schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index ebea4e6a45..4936cd9be9 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -53,6 +53,17 @@ pub trait DbxBackend: Send + Sync { arguments: Value, permissions: AgentSqlPermissions, ) -> ToolResult; + async fn execute_query( + &self, + connection: &ConnectionConfig, + database: &str, + sql: &str, + max_rows: Option, + timeout_secs: Option, + ) -> Result { + let _ = (connection, database, sql, max_rows, timeout_secs); + Err("SQL queries are not supported by this backend.".to_string()) + } async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String>; async fn list_tables( &self, @@ -88,7 +99,7 @@ pub trait DbxBackend: Send + Sync { connection: &ConnectionConfig, database: &str, command: &MongoCommand, - ) -> Result { + ) -> Result { let _ = (connection, database, command); Err("MongoDB shell commands are not supported by this backend.".to_string()) } @@ -255,6 +266,26 @@ impl DbxBackend for LocalBackend { agent_tools::execute_tool(&call, &self.state, &connection.id, database, &connection.db_type, permissions).await } + async fn execute_query( + &self, + connection: &ConnectionConfig, + database: &str, + sql: &str, + max_rows: Option, + timeout_secs: Option, + ) -> Result { + dbx_core::query::execute_sql_statement_with_options( + &self.state, + &connection.id, + database, + sql, + None, + None, + dbx_core::query::QueryExecutionOptions { max_rows, timeout_secs, ..Default::default() }, + ) + .await + } + async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String> { self.state.storage.save_connections(connections).await?; *self.state.configs.write().await = @@ -303,14 +334,15 @@ impl DbxBackend for LocalBackend { connection: &ConnectionConfig, database: &str, command: &MongoCommand, - ) -> Result { + ) -> Result { use dbx_core::mongo_ops; let connection_id = &connection.id; match command { MongoCommand::Version => mongo_ops::mongo_server_version_core(&self.state, connection_id, database) .await - .map(|version| format!("| version |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&version))), + .map(|version| scalar_query_result("version", Value::String(version))), + MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))), MongoCommand::Find { collection, filter, projection, sort, skip, limit } => { let result = mongo_ops::mongo_find_documents_core( &self.state, @@ -324,7 +356,20 @@ impl DbxBackend for LocalBackend { sort.as_deref(), ) .await?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) + } + MongoCommand::FindOne { collection, filter, projection, options } => { + let result = mongo_ops::mongo_find_one_core( + &self.state, + connection_id, + database, + collection, + Some(filter), + projection.as_deref(), + options.as_deref(), + ) + .await?; + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::Count { collection, filter, accurate } => { let mode = if *accurate { "accurate" } else { "legacy" }; @@ -337,7 +382,7 @@ impl DbxBackend for LocalBackend { Some(mode), ) .await?; - Ok(format!("| count |\n| --- |\n| {total} |\n\n1 row(s)")) + Ok(scalar_query_result("count", Value::from(total))) } MongoCommand::Aggregate { collection, pipeline, options } => { let result = mongo_ops::mongo_aggregate_documents_core( @@ -350,7 +395,7 @@ impl DbxBackend for LocalBackend { options.as_deref(), ) .await?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::Distinct { collection, field, filter } => { let result = mongo_ops::mongo_distinct_core( @@ -362,7 +407,7 @@ impl DbxBackend for LocalBackend { filter.as_deref(), ) .await?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::GetIndexes { collection } => { let result = mongo_ops::mongo_aggregate_documents_core( @@ -375,7 +420,7 @@ impl DbxBackend for LocalBackend { None, ) .await?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::CollectionStats { collection, metric, scale } => { let stats = mongo_ops::mongo_collection_stats_core( @@ -388,7 +433,7 @@ impl DbxBackend for LocalBackend { .await?; let value = serde_json::to_value(stats).map_err(|error| error.to_string())?; if metric == "stats" { - Ok(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())) + Ok(mongo_documents_query_result(vec![value])) } else { let key = match metric.as_str() { "dataSize" => "size", @@ -397,14 +442,14 @@ impl DbxBackend for LocalBackend { _ => metric, }; let metric_value = value.get(key).cloned().unwrap_or(Value::Null); - Ok(format!("| {metric} |\n| --- |\n| {} |\n\n1 row(s)", format_query_cell(&metric_value))) + Ok(scalar_query_result(metric, metric_value)) } } MongoCommand::Insert { collection, documents } => { let affected = mongo_ops::mongo_insert_documents_core(&self.state, connection_id, database, collection, documents) .await?; - Ok(format!("Query executed. {affected} row(s) affected.")) + Ok(affected_query_result(affected)) } MongoCommand::Update { collection, filter, update, options, many } => { let affected = mongo_ops::mongo_update_documents_core( @@ -418,7 +463,7 @@ impl DbxBackend for LocalBackend { options.as_deref(), ) .await?; - Ok(format!("Query executed. {affected} row(s) affected.")) + Ok(affected_query_result(affected)) } MongoCommand::Delete { collection, filter, many } => { let affected = mongo_ops::mongo_delete_documents_core( @@ -430,7 +475,7 @@ impl DbxBackend for LocalBackend { *many, ) .await?; - Ok(format!("Query executed. {affected} row(s) affected.")) + Ok(affected_query_result(affected)) } MongoCommand::CreateIndex { collection, keys, options } => { let name = mongo_ops::mongo_create_index_core( @@ -442,7 +487,7 @@ impl DbxBackend for LocalBackend { options.as_deref(), ) .await?; - Ok(format!("| name |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&name))) + Ok(scalar_query_result("name", Value::String(name))) } MongoCommand::DropIndexes { collection, indexes, single } => { let result = mongo_ops::mongo_drop_indexes_core( @@ -454,11 +499,54 @@ impl DbxBackend for LocalBackend { *single, ) .await?; - Ok(format!("Query executed. {} row(s) affected.", result.affected_rows)) + let rows = result.dropped_names.into_iter().map(|name| vec![Value::String(name)]).collect::>(); + Ok(query_result( + if rows.is_empty() { Vec::new() } else { vec!["name".to_string()] }, + rows, + result.affected_rows, + )) } MongoCommand::DropCollection { collection } => { mongo_ops::mongo_drop_collection_core(&self.state, connection_id, database, collection).await?; - Ok("Query executed. 1 row(s) affected.".to_string()) + Ok(affected_query_result(1)) + } + MongoCommand::FindOneAndUpdate { collection, filter, update, options } => { + let result = mongo_ops::mongo_find_one_and_update_core( + &self.state, + connection_id, + database, + collection, + filter, + update, + options.as_deref(), + ) + .await?; + Ok(mongo_documents_query_result(result.documents)) + } + MongoCommand::FindOneAndReplace { collection, filter, replacement, options } => { + let result = mongo_ops::mongo_find_one_and_replace_core( + &self.state, + connection_id, + database, + collection, + filter, + replacement, + options.as_deref(), + ) + .await?; + Ok(mongo_documents_query_result(result.documents)) + } + MongoCommand::FindOneAndDelete { collection, filter, options } => { + let result = mongo_ops::mongo_find_one_and_delete_core( + &self.state, + connection_id, + database, + collection, + filter, + options.as_deref(), + ) + .await?; + Ok(mongo_documents_query_result(result.documents)) } } } @@ -532,6 +620,29 @@ impl DbxBackend for WebBackend { } } + async fn execute_query( + &self, + connection: &ConnectionConfig, + database: &str, + sql: &str, + _max_rows: Option, + _timeout_secs: Option, + ) -> Result { + if connection.db_type == DatabaseType::MongoDb { + return Err("MongoDB shell commands in DBX Web mode are not implemented by the Rust CLI yet.".to_string()); + } + self.ensure_connected(connection).await?; + self.request( + reqwest::Method::POST, + "/api/query/execute", + Some(json!({ "connectionId": connection.id, "database": database, "sql": sql })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid query response: {error}")) + } + async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> { Err("Connection changes are unavailable in DBX Web mode.".to_string()) } @@ -665,7 +776,7 @@ impl DbxBackend for WebBackend { connection: &ConnectionConfig, database: &str, command: &MongoCommand, - ) -> Result { + ) -> Result { self.ensure_connected(connection).await?; let connection_id = &connection.id; match command { @@ -680,8 +791,9 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB version response: {error}"))?; - Ok(format!("| version |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&version))) + Ok(scalar_query_result("version", Value::String(version))) } + MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))), MongoCommand::Find { collection, filter, projection, sort, skip, limit } => { let result = self .request( @@ -702,7 +814,27 @@ impl DbxBackend for WebBackend { .json::() .await .map_err(|error| format!("Invalid MongoDB find response: {error}"))?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) + } + MongoCommand::FindOne { collection, filter, projection, options } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/find-one", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filter": filter, + "projection": projection, + "options": options, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB findOne response: {error}"))?; + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::Count { collection, filter, accurate } => { let total: u64 = self @@ -721,7 +853,7 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB count response: {error}"))?; - Ok(format!("| count |\n| --- |\n| {total} |\n\n1 row(s)")) + Ok(scalar_query_result("count", Value::from(total))) } MongoCommand::Aggregate { collection, pipeline, options } => { let result = self @@ -741,7 +873,7 @@ impl DbxBackend for WebBackend { .json::() .await .map_err(|error| format!("Invalid MongoDB aggregate response: {error}"))?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::Distinct { collection, field, filter } => { let result = self @@ -760,7 +892,7 @@ impl DbxBackend for WebBackend { .json::() .await .map_err(|error| format!("Invalid MongoDB distinct response: {error}"))?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::GetIndexes { collection } => { let result = self @@ -779,7 +911,7 @@ impl DbxBackend for WebBackend { .json::() .await .map_err(|error| format!("Invalid MongoDB indexes response: {error}"))?; - Ok(format_mongo_documents(&result.documents)) + Ok(mongo_documents_query_result(result.documents)) } MongoCommand::CollectionStats { collection, metric, scale } => { let value: Value = self @@ -798,7 +930,7 @@ impl DbxBackend for WebBackend { .await .map_err(|error| format!("Invalid MongoDB stats response: {error}"))?; if metric == "stats" { - Ok(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())) + Ok(mongo_documents_query_result(vec![value])) } else { let key = match metric.as_str() { "dataSize" => "size", @@ -807,7 +939,7 @@ impl DbxBackend for WebBackend { _ => metric, }; let metric_value = value.get(key).cloned().unwrap_or(Value::Null); - Ok(format!("| {metric} |\n| --- |\n| {} |\n\n1 row(s)", format_query_cell(&metric_value))) + Ok(scalar_query_result(metric, metric_value)) } } MongoCommand::Insert { collection, documents } => { @@ -826,7 +958,7 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB insert response: {error}"))?; - Ok(format_affected_rows(&value)) + Ok(affected_query_result(affected_rows_from_value(&value))) } MongoCommand::Update { collection, filter, update, options, many } => { let value: Value = self @@ -847,7 +979,7 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB update response: {error}"))?; - Ok(format_affected_rows(&value)) + Ok(affected_query_result(affected_rows_from_value(&value))) } MongoCommand::Delete { collection, filter, many } => { let value: Value = self @@ -866,7 +998,7 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB delete response: {error}"))?; - Ok(format_affected_rows(&value)) + Ok(affected_query_result(affected_rows_from_value(&value))) } MongoCommand::CreateIndex { collection, keys, options } => { let value: Value = self @@ -885,9 +1017,9 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB create index response: {error}"))?; - Ok(format!( - "| name |\n| --- |\n| {} |\n\n1 row(s)", - escape_markdown_cell(value.get("name").and_then(Value::as_str).unwrap_or("")) + Ok(scalar_query_result( + "name", + Value::String(value.get("name").and_then(Value::as_str).unwrap_or("").to_string()), )) } MongoCommand::DropIndexes { collection, indexes, single } => { @@ -907,7 +1039,20 @@ impl DbxBackend for WebBackend { .json() .await .map_err(|error| format!("Invalid MongoDB drop indexes response: {error}"))?; - Ok(format_affected_rows(&value)) + let dropped_names = value + .get("dropped_names") + .or_else(|| value.get("droppedNames")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|name| vec![Value::String(name.to_string())]) + .collect::>(); + Ok(query_result( + if dropped_names.is_empty() { Vec::new() } else { vec!["name".to_string()] }, + dropped_names, + affected_rows_from_value(&value), + )) } MongoCommand::DropCollection { collection } => { self.request( @@ -916,7 +1061,66 @@ impl DbxBackend for WebBackend { Some(json!({ "connectionId": connection_id, "database": database, "collection": collection })), ) .await?; - Ok("Query executed. 1 row(s) affected.".to_string()) + Ok(affected_query_result(1)) + } + MongoCommand::FindOneAndUpdate { collection, filter, update, options } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/find-one-and-update", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filterJson": filter, + "updateJson": update, + "optionsJson": options, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB findOneAndUpdate response: {error}"))?; + Ok(mongo_documents_query_result(result.documents)) + } + MongoCommand::FindOneAndReplace { collection, filter, replacement, options } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/find-one-and-replace", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filterJson": filter, + "replacementJson": replacement, + "optionsJson": options, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB findOneAndReplace response: {error}"))?; + Ok(mongo_documents_query_result(result.documents)) + } + MongoCommand::FindOneAndDelete { collection, filter, options } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/find-one-and-delete", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filterJson": filter, + "optionsJson": options, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB findOneAndDelete response: {error}"))?; + Ok(mongo_documents_query_result(result.documents)) } } } @@ -938,7 +1142,7 @@ fn url_encode(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() } -fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> String { +pub(crate) fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> String { if result.columns.is_empty() { return format!("Query executed. {} row(s) affected.", result.affected_rows); } @@ -953,6 +1157,64 @@ fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> S output } +fn query_result(columns: Vec, rows: Vec>, affected_rows: u64) -> dbx_core::db::QueryResult { + dbx_core::db::QueryResult { + columns, + column_types: Vec::new(), + column_sortables: Vec::new(), + rows, + affected_rows, + execution_time_ms: 0, + truncated: false, + session_id: None, + has_more: false, + } +} + +fn scalar_query_result(column: impl Into, value: Value) -> dbx_core::db::QueryResult { + query_result(vec![column.into()], vec![vec![value]], 0) +} + +fn affected_query_result(affected_rows: u64) -> dbx_core::db::QueryResult { + query_result(Vec::new(), Vec::new(), affected_rows) +} + +fn mongo_documents_query_result(documents: Vec) -> dbx_core::db::QueryResult { + if documents.is_empty() { + return query_result(Vec::new(), Vec::new(), 0); + } + let mut columns = std::collections::BTreeSet::new(); + for document in &documents { + if let Some(object) = document.as_object() { + columns.extend(object.keys().cloned()); + } else { + columns.insert("value".to_string()); + } + } + let columns = columns.into_iter().collect::>(); + let rows = documents + .into_iter() + .map(|document| { + columns + .iter() + .map(|column| { + document + .as_object() + .and_then(|object| object.get(column)) + .cloned() + .or_else(|| (column == "value").then(|| document.clone())) + .unwrap_or(Value::Null) + }) + .collect() + }) + .collect(); + query_result(columns, rows, 0) +} + +fn affected_rows_from_value(value: &Value) -> u64 { + value.get("affected_rows").or_else(|| value.get("affectedRows")).and_then(Value::as_u64).unwrap_or(0) +} + fn format_query_cell(value: &Value) -> String { match value { Value::Null => "NULL".to_string(), @@ -962,44 +1224,6 @@ fn format_query_cell(value: &Value) -> String { } } -fn format_mongo_documents(documents: &[Value]) -> String { - if documents.is_empty() { - return "Query returned 0 rows.".to_string(); - } - let mut headers = std::collections::BTreeSet::new(); - for document in documents { - if let Some(object) = document.as_object() { - headers.extend(object.keys().cloned()); - } else { - headers.insert("value".to_string()); - } - } - let headers = headers.into_iter().collect::>(); - let rows = documents - .iter() - .map(|document| { - headers - .iter() - .map(|header| { - document - .as_object() - .and_then(|object| object.get(header)) - .or_else(|| (header == "value").then_some(document)) - .map(format_query_cell) - .unwrap_or_else(|| "NULL".to_string()) - }) - .collect::>() - }) - .collect::>(); - format!("{}\n\n{} row(s)", markdown_table(&headers, &rows), documents.len()) -} - -fn format_affected_rows(value: &Value) -> String { - let affected = - value.get("affected_rows").or_else(|| value.get("affectedRows")).and_then(Value::as_u64).unwrap_or(0); - format!("Query executed. {affected} row(s) affected.") -} - fn markdown_table(headers: &[String], rows: &[Vec]) -> String { let mut output = format!( "| {} |\n| {} |", diff --git a/crates/dbx-mcp/src/lib.rs b/crates/dbx-mcp/src/lib.rs index dba136f251..fcdb719b44 100644 --- a/crates/dbx-mcp/src/lib.rs +++ b/crates/dbx-mcp/src/lib.rs @@ -1,7 +1,7 @@ pub mod backend; -pub mod mongo; pub mod paths; pub mod server; pub use backend::{ConnectionSummary, DbxBackend, LocalBackend, WebBackend}; +pub use dbx_core::mongo_shell as mongo; pub use server::{DbxMcpServer, McpScope}; diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index 9efdc876db..aa1d92de6e 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -9,8 +9,8 @@ use serde::Deserialize; use serde_json::json; use uuid::Uuid; -use crate::backend::{new_connection_config, parse_database_type, ConnectionSummary, DbxBackend}; -use crate::mongo::{self, MongoCommand}; +use crate::backend::{format_query_result, new_connection_config, parse_database_type, ConnectionSummary, DbxBackend}; +use crate::mongo::{self, MongoCommand, MongoSafetyError}; use dbx_core::{ db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety}, models::connection::DatabaseType, @@ -262,7 +262,7 @@ impl DbxMcpServer { Err(error) => return error, }; return match self.backend.execute_mongo_command(&connection, &database, &command).await { - Ok(result) => text(result), + Ok(result) => text(format_query_result(&result, 100)), Err(error) => tool_error("QUERY_ERROR", error), }; } @@ -644,26 +644,29 @@ fn validate_mongo_command( ) })?; let permissions = default_permissions(); - if command.is_mutating() && !permissions.allow_writes { - return Err(tool_error( - "SQL_BLOCKED", - "MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.", - )); - } - if command.has_empty_filter() && !permissions.allow_dangerous { - return Err(tool_error( - "SQL_BLOCKED", - "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.", - )); - } - if command.is_dangerous() && !permissions.allow_dangerous { - return Err(tool_error( - "SQL_BLOCKED", - "Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.", - )); - } - if command.is_mutating() && is_production_database(connection, database) { - return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database.")); + if let Err(error) = mongo::validate_safety( + &command, + permissions.allow_writes, + permissions.allow_dangerous, + is_production_database(connection, database), + ) { + return Err(match error { + MongoSafetyError::WritesDisabled => tool_error( + "SQL_BLOCKED", + "MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.", + ), + MongoSafetyError::EmptyFilter => tool_error( + "SQL_BLOCKED", + "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.", + ), + MongoSafetyError::Dangerous => tool_error( + "SQL_BLOCKED", + "Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.", + ), + MongoSafetyError::ProductionWrite => { + tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database.") + } + }); } Ok(command) } diff --git a/crates/dbx-mcp/tests/local.rs b/crates/dbx-mcp/tests/local.rs index 3f6b62d8b3..ceb6b28b28 100644 --- a/crates/dbx-mcp/tests/local.rs +++ b/crates/dbx-mcp/tests/local.rs @@ -78,7 +78,7 @@ async fn executes_mongo_shell_commands_without_desktop_process() { std::env::set_var("DBX_MCP_ALLOW_WRITES", "1"); call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await; - call_query(&client, "db.items.insertOne({_id: 'rust-mcp-e2e', name: 'Ada'})").await; + call_query(&client, "db.items.insert({_id: 'rust-mcp-e2e', name: 'Ada'})").await; let result = call_query(&client, "db.items.find({_id: 'rust-mcp-e2e'}).limit(1)").await; assert!(result.contains("Ada"), "unexpected MongoDB result: {result}"); call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await; diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 57aa5a88c9..a75df6b088 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -508,6 +508,7 @@ async fn main() { .route("/document-store/update-document", post(routes::document_store::update_document)) .route("/document-store/delete-document", post(routes::document_store::delete_document)) .route("/mongo/find-documents", post(routes::mongo::find_documents)) + .route("/mongo/parse-shell-command", post(routes::mongo::parse_shell_command)) .route("/mongo/find-one", post(routes::mongo::find_one)) .route("/mongo/count-documents", post(routes::mongo::count_documents)) .route("/mongo/server-version", post(routes::mongo::server_version)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 7be48ed2ee..44345575cf 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -8,6 +8,18 @@ use serde::Deserialize; use crate::error::AppError; use crate::state::WebState; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MongoParseShellCommandRequest { + source: String, +} + +pub async fn parse_shell_command( + Json(req): Json, +) -> Result, AppError> { + dbx_core::mongo_shell::parse(&req.source).map(Json).map_err(AppError) +} + async fn run_cancellable(state: &Arc, execution_id: Option, future: F) -> Result where F: Future>, diff --git a/docs/content/docs/cli.cn.mdx b/docs/content/docs/cli.cn.mdx index a26901a6ea..f9a3938bdc 100644 --- a/docs/content/docs/cli.cn.mdx +++ b/docs/content/docs/cli.cn.mdx @@ -18,7 +18,7 @@ brew tap t8y2/dbx brew install dbx-cli ``` -需要 Node.js 22.13.0 或更高版本。 +通过 npm 安装时需要 Node.js 18.18.0 或更高版本。CLI 本身不依赖 `better-sqlite3`,也不受 Node.js native module ABI 影响。Homebrew 用户不需要单独管理 Node.js。 查看版本: @@ -52,13 +52,7 @@ dbx doctor dbx doctor --json ``` -如果切换 Node.js 版本后,`dbx doctor` 报 `NODE_MODULE_VERSION` 不匹配,请用运行 `dbx` 的同一个 Node.js 版本重建 native 依赖: - -```bash -pnpm rebuild better-sqlite3 keytar --pending -``` - -如果是全局 npm 安装,使用同一个 Node.js 版本重新安装 CLI: +如果 npm 没有安装当前平台包,请不要使用 `--no-optional`,重新安装: ```bash npm uninstall -g @dbx-app/cli @@ -72,7 +66,7 @@ dbx capabilities dbx capabilities --json ``` -当前直接执行支持 PostgreSQL/Redshift、MySQL 兼容数据库(MySQL、Doris、StarRocks)和 SQLite。其它数据库类型在对应驱动加入 `@dbx-app/node-core` 前,会使用 DBX 桌面端 bridge。 +当前直接执行支持 PostgreSQL/Redshift、MySQL 兼容数据库(MySQL、Doris、StarRocks)和 SQLite。其它数据库类型会使用 DBX 桌面端 bridge 或 DBX Agent/JDBC 基础设施。 ## 默认连接 diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 990b027990..f446225b73 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -20,7 +20,7 @@ brew tap t8y2/dbx brew install dbx-cli ``` -Requires Node.js 22.13.0 or newer. +Node.js 18.18.0 or newer is required for the npm launcher. The CLI itself does not depend on `better-sqlite3` or a Node.js native-module ABI. Homebrew users do not need to manage Node.js separately. Check the installed version: @@ -54,13 +54,7 @@ dbx doctor dbx doctor --json ``` -If `dbx doctor` reports a `NODE_MODULE_VERSION` mismatch after switching Node.js versions, rebuild the native dependencies with the Node.js version you use to run `dbx`: - -```bash -pnpm rebuild better-sqlite3 keytar --pending -``` - -For global npm installs, reinstall the CLI with the same Node.js version: +If the optional platform package was not installed, reinstall without `--no-optional`: ```bash npm uninstall -g @dbx-app/cli @@ -74,7 +68,7 @@ dbx capabilities dbx capabilities --json ``` -Direct execution currently supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge until their drivers are added to `@dbx-app/node-core`. +Direct execution currently supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge or DBX Agent/JDBC infrastructure. ## Default Connection diff --git a/package.json b/package.json index a4bb6959f5..cfb8630e7a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test", "pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/mongo-shell pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check", "verify:package-install": "node scripts/verify-package-install.mjs /tmp/dbx-pack-check", + "test:cli:migration": "node scripts/verify-cli-migration.mjs", + "benchmark:cli": "node scripts/benchmark-cli.mjs", "publish:dry-run": "pnpm build:packages && pnpm pack:packages && pnpm verify:package-install", "release": "node scripts/release.mjs", "release:agents": "node scripts/release.mjs agents", diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 601d67c16b..63895b8723 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -10,6 +10,7 @@ import { mongoDistinctToQueryResult, mongoDocumentsToQueryResult, mongoIndexesToQueryResult, + normalizeRustMongoCommand, parseMongoAggregateCommand, parseMongoCollectionStatsCommand, parseMongoCommand, @@ -39,6 +40,21 @@ test("parseMongoFindCommand parses db collection find with an empty JSON filter" }); }); +test("normalizeRustMongoCommand preserves the desktop command contract", () => { + assert.deepEqual( + normalizeRustMongoCommand({ kind: "countDocuments", collection: "users", filter: "{}", accurate: false }), + { kind: "countDocuments", collection: "users", filter: "{}", mode: "legacy" }, + ); + assert.deepEqual( + normalizeRustMongoCommand({ kind: "dropIndexes", collection: "users", indexes: '"email_1"', single: true }), + { kind: "dropIndex", collection: "users", index: '"email_1"' }, + ); + assert.deepEqual( + normalizeRustMongoCommand({ kind: "findOne", collection: "users", filter: "{}", projection: null, options: null }), + { kind: "findOne", collection: "users", filter: "{}" }, + ); +}); + test("parseMongoFindCommand parses getCollection find with chained sort skip and limit", () => { assert.deepEqual(parseMongoFindCommand('db.getCollection("audit.logs").find({"level":"warn"}).sort({"createdAt":-1}).skip(20).limit(10)'), { collection: "audit.logs", diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 26de279a49..1f5d4b15e0 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -5,6 +5,7 @@ import { isReactive } from "vue"; import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/query/queryResultArchive.ts"; import { analyzeEditableQueryEditability } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts"; import { resultSqlForGrid } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts"; +import { parseMongoCommand } from "../../apps/desktop/src/lib/mongo/mongoShellCommand.ts"; import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts"; import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; @@ -91,6 +92,21 @@ function withConnectionHealthMock(handler: typeof fetch): typeof fetch { if (String(input) === "/api/connection/check-health") { return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); } + if (String(input) === "/api/mongo/parse-shell-command") { + const source = JSON.parse(String(init?.body ?? "{}")).source as string; + const parsed = parseMongoCommand(source)?.command; + if (!parsed) return new Response("invalid MongoDB command", { status: 400 }); + let command: Record = parsed as unknown as Record; + if (parsed.kind === "countDocuments") { + const { mode, ...rest } = parsed; + command = { ...rest, kind: "countDocuments", accurate: mode === "accurate" }; + } else if (parsed.kind === "dropIndex") { + command = { kind: "dropIndexes", collection: parsed.collection, indexes: parsed.index, single: true }; + } else if (parsed.kind === "dropIndexes") { + command = { ...parsed, single: false }; + } + return new Response(JSON.stringify(command), { status: 200, headers: { "Content-Type": "application/json" } }); + } return handler(input, init); }; } @@ -3495,7 +3511,7 @@ test("mongo multi-command execution reconnects before running commands", async ( }); connectionStore.connectedIds.delete("mongo-1"); - globalThis.fetch = async (input, init) => { + globalThis.fetch = withConnectionHealthMock(async (input, init) => { const url = String(input); if (url === "/api/connection/connect") { requests.push(url); @@ -3514,7 +3530,7 @@ test("mongo multi-command execution reconnects before running commands", async ( }); } return new Response("unexpected request", { status: 500 }); - }; + }); try { const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); @@ -3592,10 +3608,10 @@ test("mongo use-only execution updates the tab without reconnecting", async () = }); connectionStore.connectedIds.delete("mongo-1"); - globalThis.fetch = async (input) => { + globalThis.fetch = withConnectionHealthMock(async (input) => { requests.push(String(input)); return new Response("unexpected request", { status: 500 }); - }; + }); try { const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json new file mode 100644 index 0000000000..c1bce1b435 --- /dev/null +++ b/packages/cli-darwin-arm64/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dbx-app/cli-darwin-arm64", + "version": "0.4.34", + "description": "Precompiled DBX CLI binary for darwin arm64", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-darwin-arm64" }, + "os": ["darwin"], + "cpu": ["arm64"], + "bin": { "dbx-native": "bin/dbx" }, + "files": ["bin"] +} diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json new file mode 100644 index 0000000000..a87d2b97b9 --- /dev/null +++ b/packages/cli-darwin-x64/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dbx-app/cli-darwin-x64", + "version": "0.4.34", + "description": "Precompiled DBX CLI binary for darwin x64", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-darwin-x64" }, + "os": ["darwin"], + "cpu": ["x64"], + "bin": { "dbx-native": "bin/dbx" }, + "files": ["bin"] +} diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json new file mode 100644 index 0000000000..779b89da0e --- /dev/null +++ b/packages/cli-linux-arm64-gnu/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dbx-app/cli-linux-arm64-gnu", + "version": "0.4.34", + "description": "Precompiled DBX CLI binary for Linux arm64 glibc", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-linux-arm64-gnu" }, + "os": ["linux"], + "cpu": ["arm64"], + "libc": ["glibc"], + "bin": { "dbx-native": "bin/dbx" }, + "files": ["bin"] +} diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json new file mode 100644 index 0000000000..4d8c6ba537 --- /dev/null +++ b/packages/cli-linux-x64-gnu/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dbx-app/cli-linux-x64-gnu", + "version": "0.4.34", + "description": "Precompiled DBX CLI binary for Linux x64 glibc", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-linux-x64-gnu" }, + "os": ["linux"], + "cpu": ["x64"], + "libc": ["glibc"], + "bin": { "dbx-native": "bin/dbx" }, + "files": ["bin"] +} diff --git a/packages/cli-win32-arm64/package.json b/packages/cli-win32-arm64/package.json new file mode 100644 index 0000000000..a92525b04a --- /dev/null +++ b/packages/cli-win32-arm64/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dbx-app/cli-win32-arm64", + "version": "0.4.34", + "description": "Precompiled DBX CLI binary for Windows arm64", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-win32-arm64" }, + "os": ["win32"], + "cpu": ["arm64"], + "bin": { "dbx-native": "bin/dbx.exe" }, + "files": ["bin"] +} diff --git a/packages/cli-win32-x64/package.json b/packages/cli-win32-x64/package.json new file mode 100644 index 0000000000..c32242fa4e --- /dev/null +++ b/packages/cli-win32-x64/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dbx-app/cli-win32-x64", + "version": "0.4.34", + "description": "Precompiled DBX CLI binary for Windows x64", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-win32-x64" }, + "os": ["win32"], + "cpu": ["x64"], + "bin": { "dbx-native": "bin/dbx.exe" }, + "files": ["bin"] +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 158a066f42..e214c7f80c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,7 +17,7 @@ brew tap t8y2/dbx brew install dbx-cli ``` -Requires Node.js 22.13.0 or newer. +The npm package installs the native CLI for the current platform automatically. Node.js 18.18.0 or newer is only needed for the npm launcher; direct native distributions do not require Node.js. ## Usage @@ -90,23 +90,19 @@ Some CLI commands can run without DBX Desktop: - `query` - `context` -Direct execution currently supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge until their drivers are added to `@dbx-app/node-core`. +Direct execution supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge or DBX Agent/JDBC infrastructure. Use `dbx doctor` to check whether the DBX connection database, connection table, native SQLite loader, and desktop bridge are available. Use `dbx capabilities` to list direct-query and bridge-required database types. -If `dbx doctor` reports a `NODE_MODULE_VERSION` mismatch after switching Node.js versions, rebuild the native dependencies with the Node.js version you use to run `dbx`: - -```bash -pnpm rebuild better-sqlite3 keytar --pending -``` - -For global npm installs, reinstall the CLI with the same Node.js version: +If the optional platform package was not installed, reinstall without `--no-optional`: ```bash npm uninstall -g @dbx-app/cli npm install -g @dbx-app/cli ``` +The native CLI does not require `better-sqlite3` and is not coupled to the Node.js ABI. + ## Error Codes CLI JSON errors use stable codes: diff --git a/packages/cli/bin/dbx.js b/packages/cli/bin/dbx.js new file mode 100755 index 0000000000..fb2f848643 --- /dev/null +++ b/packages/cli/bin/dbx.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); +const platformPackages = { + "darwin-arm64": "@dbx-app/cli-darwin-arm64", + "darwin-x64": "@dbx-app/cli-darwin-x64", + "linux-arm64": "@dbx-app/cli-linux-arm64-gnu", + "linux-x64": "@dbx-app/cli-linux-x64-gnu", + "win32-arm64": "@dbx-app/cli-win32-arm64", + "win32-x64": "@dbx-app/cli-win32-x64", +}; + +const platformKey = `${process.platform}-${process.arch}`; +const packageName = platformPackages[platformKey]; +if (!packageName) { + console.error(`Unsupported platform: ${platformKey}`); + process.exit(1); +} + +let binary; +try { + const packageJson = require.resolve(`${packageName}/package.json`); + binary = join(dirname(packageJson), "bin", process.platform === "win32" ? "dbx.exe" : "dbx"); +} catch { + console.error(`The optional package ${packageName} was not installed. Reinstall @dbx-app/cli without --no-optional.`); + process.exit(1); +} + +if (!existsSync(binary)) { + console.error(`DBX CLI binary was not found at ${binary}`); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit", env: process.env }); +if (result.error) { + console.error(result.error.message); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/packages/cli/package.json b/packages/cli/package.json index 4eae2f3176..64dc4cabbd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,27 +21,27 @@ "directory": "packages/cli" }, "bin": { - "dbx": "dist/cli.js" + "dbx": "bin/dbx.js" }, "files": [ - "dist" + "bin" ], "type": "module", "scripts": { - "start": "tsx src/cli.ts", - "test": "pnpm --filter @dbx-app/node-core build && vitest run --config vitest.config.ts", - "build": "pnpm --filter @dbx-app/node-core build && tsc", - "prepublishOnly": "tsc" + "start": "cargo run -p dbx-cli --no-default-features --", + "test": "cargo test -p dbx-cli --no-default-features", + "build": "cargo build -p dbx-cli --release --no-default-features", + "prepublishOnly": "node --check bin/dbx.js" }, - "dependencies": { - "@dbx-app/node-core": "workspace:^" - }, - "devDependencies": { - "@types/node": "^22.15.21", - "tsx": "^4.19.4", - "typescript": "^5.8.3" + "optionalDependencies": { + "@dbx-app/cli-darwin-arm64": "workspace:*", + "@dbx-app/cli-darwin-x64": "workspace:*", + "@dbx-app/cli-linux-arm64-gnu": "workspace:*", + "@dbx-app/cli-linux-x64-gnu": "workspace:*", + "@dbx-app/cli-win32-arm64": "workspace:*", + "@dbx-app/cli-win32-x64": "workspace:*" }, "engines": { - "node": ">=22.13.0" + "node": ">=18.18.0" } } diff --git a/packages/cli/src/cli-format.ts b/packages/cli/src/cli-format.ts deleted file mode 100644 index 6a26fad363..0000000000 --- a/packages/cli/src/cli-format.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { formatCell, mdTable, type ConnectionConfig } from "@dbx-app/node-core"; - -export { formatCell, mdTable }; - -export interface ConnectionSummary { - name: string; - type: string; - host: string; - port: number; - database?: string; -} - -export function connectionSummary(connection: ConnectionConfig): ConnectionSummary { - return { - name: connection.name, - type: connection.db_type, - host: connection.host, - port: connection.port, - database: connection.database || undefined, - }; -} - -export interface ErrorPayload { - error: { - code: string; - message: string; - hint?: string; - }; -} - -export function errorPayload(code: string, message: string): ErrorPayload { - const hint = errorHint(code, message); - return { error: hint ? { code, message, hint } : { code, message } }; -} - -export function formatErrorMessage(code: string, message: string): string { - const hint = errorHint(code, message); - return hint ? `${message}\n\nHint: ${hint}` : message; -} - -function errorHint(code: string, message: string): string | undefined { - if (code === "CONNECTION_STORE_ERROR" && /NODE_MODULE_VERSION|compiled against a different Node\.js version/i.test(message)) { - return "Rebuild DBX CLI native dependencies with your active Node.js: pnpm rebuild better-sqlite3 keytar --pending, or reinstall the package with the same Node.js version you use to run dbx."; - } - return undefined; -} - -export function csvTable(headers: string[], rows: T[]): string { - const lines = [headers.map(csvCell).join(",")]; - for (const row of rows) { - const values = row as Record; - lines.push(headers.map((header) => csvCell(values[header])).join(",")); - } - return `${lines.join("\n")}\n`; -} - -function csvCell(value: unknown): string { - if (value === null || value === undefined) return ""; - const text = typeof value === "object" ? JSON.stringify(value) : String(value); - if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; - return text; -} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts deleted file mode 100644 index d062b014ab..0000000000 --- a/packages/cli/src/cli.ts +++ /dev/null @@ -1,410 +0,0 @@ -#!/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; - 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; - 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> { - 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 [--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; - }); -} diff --git a/packages/cli/tests/bin.test.ts b/packages/cli/tests/bin.test.ts deleted file mode 100644 index ce3f05fcbf..0000000000 --- a/packages/cli/tests/bin.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm, symlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; -import { test } from "vitest"; - -const packageDir = fileURLToPath(new URL("..", import.meta.url)); -const cliSource = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); - -test("prints version when invoked through an npm-style symlink", async () => { - const bin = await symlinkedCli(); - try { - const result = runDbx(bin, ["--version"]); - - assert.equal(result.status, 0); - assert.match(result.stdout, /^\d+\.\d+\.\d+\n$/); - assert.equal(result.stderr, ""); - } finally { - await rm(bin.dir, { recursive: true, force: true }); - } -}); - -test("prints capabilities when invoked through an npm-style symlink", async () => { - const bin = await symlinkedCli(); - try { - const result = runDbx(bin, ["capabilities", "--json"]); - - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] }; - assert.ok(payload.directQueryTypes.includes("postgres")); - assert.ok(payload.directQueryTypes.includes("rqlite")); - assert.ok(payload.bridgeRequiredTypes.includes("oracle")); - } finally { - await rm(bin.dir, { recursive: true, force: true }); - } -}); - -async function symlinkedCli() { - const dir = await mkdtemp(join(tmpdir(), "dbx-cli-bin-")); - const path = join(dir, "dbx"); - await symlink(cliSource, path); - return { dir, path }; -} - -function runDbx(bin: { path: string }, args: string[]) { - return spawnSync(process.execPath, ["--import", "tsx", bin.path, ...args], { - cwd: packageDir, - encoding: "utf-8", - }); -} diff --git a/packages/cli/tests/cli-format.test.ts b/packages/cli/tests/cli-format.test.ts deleted file mode 100644 index 32d5ba610b..0000000000 --- a/packages/cli/tests/cli-format.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import type { ConnectionConfig } from "@dbx-app/node-core"; -import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "../src/cli-format.js"; - -const connection: ConnectionConfig = { - id: "1", - name: "local", - db_type: "postgres", - host: "127.0.0.1", - port: 5432, - username: "app", - password: "secret", - database: "demo", - ssh_enabled: false, - proxy_password: "proxy-secret", - ssl: false, -}; - -test("redacts secrets from connection summaries", () => { - const summary = connectionSummary(connection); - - assert.deepEqual(summary, { - name: "local", - type: "postgres", - host: "127.0.0.1", - port: 5432, - database: "demo", - }); - assert.equal(JSON.stringify(summary).includes("secret"), false); -}); - -test("formats markdown tables", () => { - const table = mdTable(["Name", "Type"], [["local", "postgres"]]); - - assert.match(table, /Name/); - assert.match(table, /postgres/); -}); - -test("formats database cell values with shared formatter", () => { - assert.equal(formatCell(null), "NULL"); - assert.equal(formatCell({ ok: true }), '{"ok":true}'); -}); - -test("builds stable error payloads", () => { - assert.deepEqual(errorPayload("SQL_BLOCKED", "read-only"), { - error: { code: "SQL_BLOCKED", message: "read-only" }, - }); -}); - -test("adds remediation hints for native SQLite ABI errors", () => { - assert.deepEqual(errorPayload("CONNECTION_STORE_ERROR", "NODE_MODULE_VERSION 127 mismatch"), { - error: { - code: "CONNECTION_STORE_ERROR", - message: "NODE_MODULE_VERSION 127 mismatch", - hint: "Rebuild DBX CLI native dependencies with your active Node.js: pnpm rebuild better-sqlite3 keytar --pending, or reinstall the package with the same Node.js version you use to run dbx.", - }, - }); - assert.match(formatErrorMessage("CONNECTION_STORE_ERROR", "compiled against a different Node.js version"), /Hint: Rebuild/); -}); - -test("formats csv tables with escaping", () => { - const csv = csvTable(["name", "note"], [{ name: "Ada", note: 'hello, "dbx"' }]); - - assert.equal(csv, 'name,note\nAda,"hello, ""dbx"""\n'); -}); diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts deleted file mode 100644 index b1cd7a6d20..0000000000 --- a/packages/cli/tests/cli.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "vitest"; -import type { Backend, ConnectionConfig, DbxDiagnostics } from "@dbx-app/node-core"; -import { runCli } from "../src/cli.js"; - -const connection: ConnectionConfig = { - id: "1", - name: "local", - db_type: "postgres", - host: "127.0.0.1", - port: 5432, - username: "app", - password: "secret", - database: "demo", - ssh_enabled: false, - ssl: false, -}; - -function fakeBackend(overrides: Partial = {}): Backend { - return { - loadConnections: async () => [connection], - findConnection: async (name) => (name === "local" ? connection : undefined), - addConnection: async () => connection, - removeConnection: async () => true, - listTables: async () => [{ name: "users", type: "BASE TABLE" }], - describeTable: async () => [ - { - name: "id", - data_type: "integer", - is_nullable: false, - column_default: null, - is_primary_key: true, - comment: null, - }, - ], - executeQuery: async () => ({ columns: ["total"], rows: [{ total: 1 }], row_count: 1 }), - ...overrides, - }; -} - -const diagnostics: DbxDiagnostics = { - appDataDir: "/tmp/dbx", - dbPath: "/tmp/dbx/dbx.db", - dbPathExists: true, - connectionsTableExists: true, - connectionRowCount: 2, - loadConnectionsOk: true, - loadedConnectionCount: 2, - bridgePortFile: "/tmp/dbx/mcp-bridge-port", - bridgePortFileExists: false, - directQueryTypes: ["postgres", "mysql", "sqlite", "rqlite"], - bridgeRequiredTypes: ["oracle", "mongodb"], -}; - -test("lists connections as json", async () => { - const result = await runCli(["connections", "list", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - assert.deepEqual(JSON.parse(result.stdout), { - connections: [{ name: "local", type: "postgres", host: "127.0.0.1", port: 5432, database: "demo" }], - }); -}); - -test("blocks write query by default", async () => { - const result = await runCli(["query", "local", "update users set name = 'x' where id = 1", "--json"], { - backend: fakeBackend(), - }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "SQL_BLOCKED"); -}); - -test("runs read query as json", async () => { - const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], { - backend: fakeBackend(), - }); - - assert.equal(result.exitCode, 0); - assert.deepEqual(JSON.parse(result.stdout), { - connection: "local", - columns: ["total"], - rows: [{ total: 1 }], - row_count: 1, - }); -}); - -test("closes backend resources created by the CLI", async () => { - let closed = false; - const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], { - backendFactory: async () => - fakeBackend({ - close: async () => { - closed = true; - }, - }), - }); - - assert.equal(result.exitCode, 0); - assert.equal(closed, true); -}); - -test("closes backend resources created by the CLI after failures", async () => { - let closed = false; - const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], { - backendFactory: async () => - fakeBackend({ - executeQuery: async () => { - throw new Error("boom"); - }, - close: async () => { - closed = true; - }, - }), - }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.message, "boom"); - assert.equal(closed, true); -}); - -test("does not close caller-provided backend resources", async () => { - let closed = false; - const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], { - backend: fakeBackend({ - close: async () => { - closed = true; - }, - }), - }); - - assert.equal(result.exitCode, 0); - assert.equal(closed, false); -}); - -test("runs query as csv", async () => { - const result = await runCli(["query", "local", "select count(*) as total from users", "--format", "csv"], { - backend: fakeBackend(), - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.stdout, "total\n1\n"); -}); - -test("uses json format alias", async () => { - const result = await runCli(["connections", "list", "--format", "json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - assert.deepEqual(JSON.parse(result.stdout), { - connections: [{ name: "local", type: "postgres", host: "127.0.0.1", port: 5432, database: "demo" }], - }); -}); - -test("prints diagnostics as json", async () => { - const result = await runCli(["doctor", "--json"], { - backend: fakeBackend(), - diagnostics: async () => diagnostics, - }); - - assert.equal(result.exitCode, 0); - assert.deepEqual(JSON.parse(result.stdout), diagnostics); -}); - -test("prints connection-store remediation in doctor output", async () => { - const result = await runCli(["doctor"], { - backend: fakeBackend(), - diagnostics: async () => ({ - ...diagnostics, - loadConnectionsOk: false, - loadConnectionsError: "compiled against a different Node.js version", - loadConnectionsHint: "Rebuild DBX CLI native dependencies with your active Node.js.", - }), - }); - - assert.equal(result.exitCode, 0); - assert.match(result.stdout, /Connection fix/); - assert.match(result.stdout, /Rebuild DBX CLI native dependencies/); -}); - -test("surfaces connection store failures instead of returning an empty list", async () => { - const result = await runCli(["connections", "list", "--json"], { - backend: fakeBackend({ - loadConnections: async () => { - throw Object.assign(new Error("Failed to load DBX connections from /tmp/dbx/dbx.db: NODE_MODULE_VERSION 127 mismatch"), { - code: "CONNECTION_STORE_ERROR", - }); - }, - }), - }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "CONNECTION_STORE_ERROR"); - assert.match(JSON.parse(result.stderr).error.hint, /Rebuild DBX CLI native dependencies/); -}); - -test("prints capabilities as json", async () => { - const result = await runCli(["capabilities", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] }; - assert.ok(payload.directQueryTypes.includes("postgres")); - assert.ok(payload.directQueryTypes.includes("sqlite")); - assert.ok(payload.directQueryTypes.includes("rqlite")); - assert.ok(payload.bridgeRequiredTypes.includes("oracle")); -}); - -test("rejects invalid formats", async () => { - const result = await runCli(["query", "local", "select 1", "--json", "--format", "xml"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION"); -}); - -test("uses DBX_CONNECTION when query connection is omitted", async () => { - const result = await runCli(["query", "select count(*) as total from users", "--json"], { - backend: fakeBackend(), - env: { DBX_CONNECTION: "local" }, - }); - - assert.equal(result.exitCode, 0); - assert.equal(JSON.parse(result.stdout).connection, "local"); -}); - -test("uses DBX_CONNECTION when context connection is omitted", async () => { - const result = await runCli(["context", "--tables", "users"], { - backend: fakeBackend(), - env: { DBX_CONNECTION: "local" }, - }); - - assert.equal(result.exitCode, 0); - assert.match(result.stdout, /Connection: local/); -}); - -test("passes query limit and timeout to the backend", async () => { - let received: unknown; - const result = await runCli(["query", "local", "select * from users", "--limit", "5", "--timeout", "2s", "--json"], { - backend: fakeBackend({ - executeQuery: async (_config, _sql, options) => { - received = options; - return { columns: ["id"], rows: [{ id: 1 }], row_count: 1 }; - }, - }), - }); - - assert.equal(result.exitCode, 0); - assert.deepEqual(received, { maxRows: 5, timeoutMs: 2000 }); -}); - -test("rejects invalid query limits", async () => { - const result = await runCli(["query", "local", "select 1", "--limit", "0", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION"); -}); - -test("rejects invalid query timeouts", async () => { - const result = await runCli(["query", "local", "select 1", "--timeout", "forever", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION"); -}); - -test("requires a connection when DBX_CONNECTION is not set", async () => { - const result = await runCli(["query", "select 1", "--json"], { backend: fakeBackend(), env: {} }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_ARGUMENT"); -}); - -test("describes schema as json", async () => { - const result = await runCli(["schema", "describe", "local", "users", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - assert.equal(JSON.parse(result.stdout).columns[0].name, "id"); -}); - -test("lists schema tables as json", async () => { - const result = await runCli(["schema", "list", "local", "--schema", "public", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - assert.deepEqual(JSON.parse(result.stdout), { - connection: "local", - schema: "public", - tables: [{ name: "users", type: "BASE TABLE" }], - }); -}); - -test("builds schema context as prompt-ready text", async () => { - const result = await runCli(["context", "local", "--tables", "users"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - assert.match(result.stdout, /Connection: local/); - assert.match(result.stdout, /## users/); - assert.match(result.stdout, /id integer NOT NULL PK/); -}); - -test("runs SQL from file", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-cli-")); - const file = join(dir, "query.sql"); - let executedSql = ""; - - try { - await writeFile(file, "select count(*) as total from users", "utf-8"); - - const result = await runCli(["query", "local", "--file", file, "--json"], { - backend: fakeBackend({ - executeQuery: async (_config, sql) => { - executedSql = sql; - return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 }; - }, - }), - }); - - assert.equal(result.exitCode, 0); - assert.equal(executedSql, "select count(*) as total from users"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("allows non-dangerous writes when explicitly enabled", async () => { - let executed = false; - const result = await runCli(["query", "local", "update users set name = 'x' where id = 1", "--allow-writes", "--json"], { - backend: fakeBackend({ - executeQuery: async () => { - executed = true; - return { columns: [], rows: [], row_count: 1 }; - }, - }), - }); - - assert.equal(result.exitCode, 0); - assert.equal(executed, true); - assert.equal(JSON.parse(result.stdout).row_count, 1); -}); - -test("keeps dangerous SQL blocked without allow-dangerous-sql", async () => { - const result = await runCli(["query", "local", "drop table users", "--allow-writes", "--json"], { - backend: fakeBackend(), - }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "SQL_BLOCKED"); -}); - -test("rejects unknown options", async () => { - const result = await runCli(["connections", "list", "--wat", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "UNKNOWN_OPTION"); -}); - -test("rejects options with missing values", async () => { - const result = await runCli(["schema", "list", "local", "--schema", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION"); -}); - -test("rejects query with both inline SQL and file SQL", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-cli-")); - const file = join(dir, "query.sql"); - - try { - await writeFile(file, "select 1", "utf-8"); - - const result = await runCli(["query", "local", "select 2", "--file", file, "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_ARGUMENT"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("requires allow-writes before allow-dangerous-sql", async () => { - const result = await runCli(["query", "local", "drop table users", "--allow-dangerous-sql", "--json"], { - backend: fakeBackend(), - env: {}, - }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION"); -}); - -test("rejects invalid max-tables values", async () => { - const result = await runCli(["context", "local", "--max-tables", "nope", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION"); -}); - -test("prints the package version", async () => { - const result = await runCli(["--version"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 0); - assert.match(result.stdout, /^\d+\.\d+\.\d+\n$/); -}); - -test("rejects unexpected positional arguments", async () => { - const result = await runCli(["connections", "list", "extra", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "INVALID_ARGUMENT"); -}); - -test("uses a specific connection-not-found error code", async () => { - const result = await runCli(["schema", "list", "missing", "--json"], { backend: fakeBackend() }); - - assert.equal(result.exitCode, 1); - assert.equal(JSON.parse(result.stderr).error.code, "CONNECTION_NOT_FOUND"); -}); - -test("supports -- to pass SQL that starts with a dash", async () => { - let executedSql = ""; - const result = await runCli(["query", "local", "--json", "--", "-- comment\nselect 1"], { - backend: fakeBackend({ - executeQuery: async (_config, sql) => { - executedSql = sql; - return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 }; - }, - }), - }); - - assert.equal(result.exitCode, 0); - assert.equal(executedSql, "-- comment\nselect 1"); -}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json deleted file mode 100644 index 9b2da40253..0000000000 --- a/packages/cli/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "nodenext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts deleted file mode 100644 index 4cfb6392fe..0000000000 --- a/packages/cli/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/*.test.ts"], - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ef7ba9ae5..39e12eb0e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,20 +233,37 @@ importers: version: 3.3.3(typescript@6.0.3) packages/cli: - dependencies: - '@dbx-app/node-core': - specifier: workspace:^ - version: link:../node-core - devDependencies: - '@types/node': - specifier: ^22.15.21 - version: 22.19.19 - tsx: - specifier: ^4.19.4 - version: 4.21.0 - typescript: - specifier: ^5.8.3 - version: 5.9.3 + optionalDependencies: + '@dbx-app/cli-darwin-arm64': + specifier: workspace:* + version: link:../cli-darwin-arm64 + '@dbx-app/cli-darwin-x64': + specifier: workspace:* + version: link:../cli-darwin-x64 + '@dbx-app/cli-linux-arm64-gnu': + specifier: workspace:* + version: link:../cli-linux-arm64-gnu + '@dbx-app/cli-linux-x64-gnu': + specifier: workspace:* + version: link:../cli-linux-x64-gnu + '@dbx-app/cli-win32-arm64': + specifier: workspace:* + version: link:../cli-win32-arm64 + '@dbx-app/cli-win32-x64': + specifier: workspace:* + version: link:../cli-win32-x64 + + packages/cli-darwin-arm64: {} + + packages/cli-darwin-x64: {} + + packages/cli-linux-arm64-gnu: {} + + packages/cli-linux-x64-gnu: {} + + packages/cli-win32-arm64: {} + + packages/cli-win32-x64: {} packages/mcp-darwin-arm64: {} @@ -257,25 +274,6 @@ importers: packages/mcp-linux-x64-gnu: {} packages/mcp-server: - optionalDependencies: - '@dbx-app/mcp-darwin-arm64': - specifier: 0.4.34 - version: link:../mcp-darwin-arm64 - '@dbx-app/mcp-darwin-x64': - specifier: 0.4.34 - version: link:../mcp-darwin-x64 - '@dbx-app/mcp-linux-arm64-gnu': - specifier: 0.4.34 - version: link:../mcp-linux-arm64-gnu - '@dbx-app/mcp-linux-x64-gnu': - specifier: 0.4.34 - version: link:../mcp-linux-x64-gnu - '@dbx-app/mcp-win32-arm64': - specifier: 0.4.34 - version: link:../mcp-win32-arm64 - '@dbx-app/mcp-win32-x64': - specifier: 0.4.34 - version: link:../mcp-win32-x64 devDependencies: '@dbx-app/node-core': specifier: workspace:^ @@ -298,6 +296,25 @@ importers: zod: specifier: ^3.25.20 version: 3.25.76 + optionalDependencies: + '@dbx-app/mcp-darwin-arm64': + specifier: 0.4.34 + version: 0.4.34 + '@dbx-app/mcp-darwin-x64': + specifier: 0.4.34 + version: 0.4.34 + '@dbx-app/mcp-linux-arm64-gnu': + specifier: 0.4.34 + version: 0.4.34 + '@dbx-app/mcp-linux-x64-gnu': + specifier: 0.4.34 + version: 0.4.34 + '@dbx-app/mcp-win32-arm64': + specifier: 0.4.34 + version: 0.4.34 + '@dbx-app/mcp-win32-x64': + specifier: 0.4.34 + version: 0.4.34 packages/mcp-win32-arm64: {} @@ -534,6 +551,42 @@ packages: '@codemirror/view@6.43.0': resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + '@dbx-app/mcp-darwin-arm64@0.4.34': + resolution: {integrity: sha512-RxxXq5GnBvTxETF3vtT1KQEvVSf2XcTEcSozJJ0wNBt5pfyJPeI3m3xRHrzgKlElmGfFFL191D+Jf0eaM0s0bQ==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@dbx-app/mcp-darwin-x64@0.4.34': + resolution: {integrity: sha512-2DIIh3DsEDijpWyKdKoCG+pa1XCnGTSnUyrPZlDoZ78L0I0trkb0NFCaFmnKiLR5LWJ2ZvPnYvwEmBMbnDnviw==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@dbx-app/mcp-linux-arm64-gnu@0.4.34': + resolution: {integrity: sha512-ogI2DnQNAO5cr86GlKfvDIw7/cF27o+PRyn5a7/CXKmBugLtPtN4C8UV0wVkObu1avxuMza/7QpAoDB+v+tCZA==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@dbx-app/mcp-linux-x64-gnu@0.4.34': + resolution: {integrity: sha512-CH+2juzBjlJcsSR8A8KUjE1CPi432v8YQ6nmJ3fDxmqt7THa0NT8vvTmczzy9KBhR9dNkEIZ94qZASuh5okN+g==} + cpu: [x64] + os: [linux] + hasBin: true + + '@dbx-app/mcp-win32-arm64@0.4.34': + resolution: {integrity: sha512-sRqH+0yX66t0cqapr110470U7B9JB6M9pVVlfAyIJdeIJZHlPiDi0Kz/+n+7sO1Pbf6W43E7lueE0i4N0u8Sow==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@dbx-app/mcp-win32-x64@0.4.34': + resolution: {integrity: sha512-lE3hyvI2oKTxgWaeW0OgLbX9gjIwM2t4OE+icg57QvX+WKHcrwu1p7ZWUoW3DmRO4ngKCG8hA9u9ynSGxWhq+A==} + cpu: [x64] + os: [win32] + hasBin: true + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -4488,6 +4541,24 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@dbx-app/mcp-darwin-arm64@0.4.34': + optional: true + + '@dbx-app/mcp-darwin-x64@0.4.34': + optional: true + + '@dbx-app/mcp-linux-arm64-gnu@0.4.34': + optional: true + + '@dbx-app/mcp-linux-x64-gnu@0.4.34': + optional: true + + '@dbx-app/mcp-win32-arm64@0.4.34': + optional: true + + '@dbx-app/mcp-win32-x64@0.4.34': + optional: true + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 diff --git a/scripts/benchmark-cli.mjs b/scripts/benchmark-cli.mjs new file mode 100644 index 0000000000..4f4d11d669 --- /dev/null +++ b/scripts/benchmark-cli.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; +import { performance } from "node:perf_hooks"; + +const rustBinary = process.env.DBX_CLI_RUST_BIN; +const legacyBinary = process.env.DBX_CLI_LEGACY_BIN; +const connection = process.env.DBX_CLI_TEST_CONNECTION; +const table = process.env.DBX_CLI_TEST_TABLE ?? "orders"; +const iterations = Number.parseInt(process.env.DBX_CLI_BENCH_ITERATIONS ?? "12", 10); + +if (!rustBinary || !legacyBinary || !connection) { + throw new Error("Set DBX_CLI_RUST_BIN, DBX_CLI_LEGACY_BIN, and DBX_CLI_TEST_CONNECTION."); +} +if (!Number.isInteger(iterations) || iterations < 3) { + throw new Error("DBX_CLI_BENCH_ITERATIONS must be an integer of at least 3."); +} + +const cases = [ + { name: "version", args: ["--version"], iterations: Math.max(iterations, 30) }, + { name: "capabilities", args: ["capabilities", "--json"], iterations: Math.max(iterations, 30) }, + { name: "connections", args: ["connections", "list", "--json"], iterations: Math.max(iterations, 20) }, + { name: "schema-list", args: ["schema", "list", connection, "--json"], iterations }, + { name: "schema-describe", args: ["schema", "describe", connection, table, "--json"], iterations }, + { name: "context", args: ["context", connection, "--tables", table, "--json"], iterations }, + { name: "query", args: ["query", connection, "select 1 as benchmark_value", "--json"], iterations }, +]; + +function run(binary, args) { + const started = performance.now(); + const result = spawnSync(binary, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + const elapsed = performance.now() - started; + if (result.status !== 0) throw new Error(`${binary} ${args.join(" ")} failed: ${result.stderr}`); + return elapsed; +} + +function percentile(values, ratio) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; +} + +function summarize(values) { + return { + median: percentile(values, 0.5), + p90: percentile(values, 0.9), + p95: percentile(values, 0.95), + mean: values.reduce((sum, value) => sum + value, 0) / values.length, + }; +} + +function peakRss(binary, args) { + const values = []; + for (let index = 0; index < 5; index += 1) { + const result = spawnSync("/usr/bin/time", ["-l", binary, ...args], { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] }); + if (result.status !== 0) throw new Error(`RSS measurement failed: ${result.stderr}`); + const match = result.stderr.match(/(\d+)\s+maximum resident set size/); + if (!match) throw new Error(`Unable to parse maximum resident set size: ${result.stderr}`); + values.push(Number(match[1]) / 1024 / 1024); + } + return percentile(values, 0.5); +} + +console.log(`iterations=${iterations} connection=${connection} table=${table}`); +console.log("case\truntime\tmedian_ms\tp90_ms\tp95_ms\tmean_ms\tpeak_rss_mib"); + +for (const benchmark of cases) { + run(rustBinary, benchmark.args); + run(legacyBinary, benchmark.args); + const samples = { rust: [], legacy: [] }; + for (let index = 0; index < benchmark.iterations; index += 1) { + const order = index % 2 === 0 ? [["rust", rustBinary], ["legacy", legacyBinary]] : [["legacy", legacyBinary], ["rust", rustBinary]]; + for (const [runtime, binary] of order) samples[runtime].push(run(binary, benchmark.args)); + } + for (const [runtime, binary] of [["rust", rustBinary], ["legacy", legacyBinary]]) { + const stats = summarize(samples[runtime]); + const rss = peakRss(binary, benchmark.args); + console.log( + `${benchmark.name}\t${runtime}\t${stats.median.toFixed(1)}\t${stats.p90.toFixed(1)}\t${stats.p95.toFixed(1)}\t${stats.mean.toFixed(1)}\t${rss.toFixed(1)}`, + ); + } +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 11e8fd47d2..fc81c01083 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -20,7 +20,13 @@ const PACKAGE_RELEASE_PATHS = [ "packages/cli/src/", "packages/cli/README.md", "packages/cli/package.json", - "packages/cli/tsconfig.json", + "packages/cli/bin/", + "packages/cli-darwin-arm64/", + "packages/cli-darwin-x64/", + "packages/cli-linux-arm64-gnu/", + "packages/cli-linux-x64-gnu/", + "packages/cli-win32-arm64/", + "packages/cli-win32-x64/", "packages/mcp-server/src/", "packages/mcp-server/bin/", "packages/mcp-server/README.md", @@ -34,6 +40,8 @@ const PACKAGE_RELEASE_PATHS = [ "packages/mcp-win32-arm64/", "packages/mcp-win32-x64/", "crates/dbx-mcp/", + "crates/dbx-cli/", + "crates/dbx-core/src/mongo_shell.rs", "Cargo.toml", "Cargo.lock", ".github/workflows/mcp-release.yml", diff --git a/scripts/verify-cli-migration.mjs b/scripts/verify-cli-migration.mjs new file mode 100644 index 0000000000..3226c5788d --- /dev/null +++ b/scripts/verify-cli-migration.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const rustBinary = process.env.DBX_CLI_RUST_BIN; +const legacyBinary = process.env.DBX_CLI_LEGACY_BIN; +const connection = process.env.DBX_CLI_TEST_CONNECTION; + +if (!rustBinary || !existsSync(rustBinary)) { + throw new Error("Set DBX_CLI_RUST_BIN to a release dbx binary before running this test."); +} +if (!legacyBinary || !existsSync(legacyBinary)) { + throw new Error("Set DBX_CLI_LEGACY_BIN to the legacy TypeScript dbx binary before running this test."); +} +if (!connection) { + throw new Error("Set DBX_CLI_TEST_CONNECTION to a safe test connection name before running this test."); +} + +function run(binary, args, extraEnv = {}) { + const env = { ...process.env }; + for (const [key, value] of Object.entries(extraEnv)) { + if (value == null) delete env[key]; + else env[key] = value; + } + const result = spawnSync(binary, args, { encoding: "utf8", env }); + return { status: result.status, stdout: result.stdout.trim(), stderr: result.stderr.trim() }; +} + +function jsonOutput(result) { + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +function compareJsonCommand(name, args, compare = (legacy, rust) => assert.deepEqual(rust, legacy)) { + const legacy = jsonOutput(run(legacyBinary, args)); + const rust = jsonOutput(run(rustBinary, args)); + compare(legacy, rust); + console.log(`PASS ${name}`); +} + +function normalizeRows(rows) { + return rows.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value == null ? value : String(value)]))); +} + +const version = run(rustBinary, ["--version"]); +assert.equal(version.status, 0); +assert.match(version.stdout, /^\d+\.\d+\.\d+$/); +assert.equal(run(legacyBinary, ["--version"]).status, 0); +console.log("PASS version"); + +assert.equal(run(rustBinary, ["--help"]).status, 0); +assert.match(run(rustBinary, ["--help"]).stdout, /dbx query/); +assert.match(run(legacyBinary, ["--help"]).stdout, /dbx query/); +console.log("PASS help"); + +compareJsonCommand("capabilities", ["capabilities", "--json"], (legacy, rust) => { + assert.deepEqual(Object.keys(rust).sort(), Object.keys(legacy).sort()); + assert.ok(rust.directQueryTypes.includes("postgres")); + assert.ok(rust.bridgeRequiredTypes.includes("oracle")); +}); + +compareJsonCommand("connections", ["connections", "list", "--json"], (legacy, rust) => { + assert.deepEqual(rust.connections, legacy.connections); +}); + +compareJsonCommand("schema list", ["schema", "list", connection, "--json"], (legacy, rust) => { + assert.equal(rust.connection, legacy.connection); + assert.ok(rust.tables.length >= legacy.tables.length); + assert.deepEqual(rust.tables.slice(0, legacy.tables.length), legacy.tables); +}); + +const schema = jsonOutput(run(rustBinary, ["schema", "list", connection, "--json"])); +const table = schema.tables[0]?.name; +if (!table) throw new Error(`No table found in ${connection}`); + +compareJsonCommand("schema describe", ["schema", "describe", connection, table, "--json"], (legacy, rust) => { + assert.equal(rust.connection, legacy.connection); + assert.equal(rust.table, legacy.table); + assert.deepEqual( + rust.columns.map(({ name, is_nullable, column_default, is_primary_key, enum_values }) => ({ name, is_nullable, column_default, is_primary_key, enum_values: enum_values ?? null })), + legacy.columns.map(({ name, is_nullable, column_default, is_primary_key, enum_values }) => ({ name, is_nullable, column_default, is_primary_key, enum_values: enum_values ?? null })), + ); +}); + +compareJsonCommand("query", ["query", connection, "select 1 as dbx_cli_migration_check", "--json"], (legacy, rust) => { + assert.deepEqual(rust.columns, legacy.columns); + assert.deepEqual(normalizeRows(rust.rows), normalizeRows(legacy.rows)); + assert.equal(rust.row_count, legacy.row_count); +}); + +const temporaryDirectory = mkdtempSync(join(tmpdir(), "dbx-cli-migration-")); +const sqlFile = join(temporaryDirectory, "query.sql"); +writeFileSync(sqlFile, "select 1 as dbx_cli_file_check", "utf8"); +try { + compareJsonCommand("file query", ["query", connection, "--file", sqlFile, "--json"], (legacy, rust) => { + assert.deepEqual(rust.columns, legacy.columns); + assert.deepEqual(normalizeRows(rust.rows), normalizeRows(legacy.rows)); + assert.equal(rust.row_count, legacy.row_count); + }); +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); +} + +const context = run(rustBinary, ["context", connection, "--max-tables", "1"]); +assert.equal(context.status, 0, context.stderr); +assert.match(context.stdout, /Connection:/); +assert.match(context.stdout, /## /); +console.log("PASS context"); + +const contextJson = jsonOutput(run(rustBinary, ["context", "--tables", table, "--json"], { DBX_CONNECTION: connection })); +assert.equal(contextJson.connection, connection); +assert.equal(contextJson.tables.length, 1); +assert.equal(contextJson.tables[0].name.toLowerCase(), table.toLowerCase()); +assert.equal(typeof contextJson.truncated, "boolean"); +console.log("PASS DBX_CONNECTION context"); + +const defaultConnectionQuery = jsonOutput(run(rustBinary, ["query", "select 1 as default_connection", "--json"], { DBX_CONNECTION: connection })); +assert.equal(defaultConnectionQuery.connection, connection); +assert.equal(String(defaultConnectionQuery.rows[0].default_connection), "1"); +console.log("PASS DBX_CONNECTION query"); + +const formatAlias = jsonOutput(run(rustBinary, ["query", connection, "select 1 as format_alias", "--format", "json"])); +assert.equal(String(formatAlias.rows[0].format_alias), "1"); +console.log("PASS format json alias"); + +const csv = run(rustBinary, ["query", connection, "select 'a,b' as csv_value", "--format", "csv"]); +assert.equal(csv.status, 0, csv.stderr); +assert.match(csv.stdout, /^csv_value\n"a,b"$/); +console.log("PASS csv escaping"); + +const limited = jsonOutput( + run(rustBinary, ["query", connection, "select 1 as n union all select 2 union all select 3", "--limit", "2", "--json"]), +); +assert.equal(limited.rows.length, 2); +assert.equal(limited.row_count, 2); +console.log("PASS query limit"); + +const doubleDash = jsonOutput(run(rustBinary, ["query", connection, "--json", "--", "-- comment\nselect 1 as double_dash"])); +assert.equal(String(doubleDash.rows[0].double_dash), "1"); +console.log("PASS double dash SQL"); + +const doctor = jsonOutput(run(rustBinary, ["doctor", "--json"])); +for (const key of ["appDataDir", "dbPath", "loadConnectionsOk", "directQueryTypes", "bridgeRequiredTypes"]) assert.ok(key in doctor); +console.log("PASS doctor"); + +for (const [name, args, code] of [ + ["unknown option", ["connections", "list", "--wat", "--json"], "UNKNOWN_OPTION"], + ["missing option value", ["schema", "list", connection, "--schema", "--json"], "INVALID_OPTION"], + ["missing connection", ["schema", "list", "missing-db", "--json"], "CONNECTION_NOT_FOUND"], + ["blocked write", ["query", connection, "update users set value = 1", "--json"], "SQL_BLOCKED"], + ["dangerous requires writes", ["query", connection, "drop table users", "--allow-dangerous-sql", "--json"], "INVALID_OPTION"], + ["dangerous blocked", ["query", connection, "drop table users", "--allow-writes", "--json"], "SQL_BLOCKED"], + ["invalid format", ["connections", "list", "--format", "yaml", "--json"], "INVALID_OPTION"], + ["invalid limit", ["query", connection, "select 1", "--limit", "0", "--json"], "INVALID_OPTION"], + ["invalid timeout", ["query", connection, "select 1", "--timeout", "later", "--json"], "INVALID_OPTION"], + ["invalid max tables", ["context", connection, "--max-tables", "nope", "--json"], "INVALID_OPTION"], + ["unexpected positional", ["connections", "list", "extra", "--json"], "INVALID_ARGUMENT"], +]) { + const result = run(rustBinary, args); + assert.equal(result.status, 1, `${name}: ${result.stdout}`); + assert.equal(JSON.parse(result.stderr).error.code, code); + console.log(`PASS ${name}`); +} + +const missingDefault = run(rustBinary, ["query", "select 1", "--json"], { DBX_CONNECTION: null }); +assert.equal(missingDefault.status, 1); +assert.equal(JSON.parse(missingDefault.stderr).error.code, "INVALID_ARGUMENT"); +console.log("PASS missing default connection"); + +const temporaryConflictDirectory = mkdtempSync(join(tmpdir(), "dbx-cli-migration-conflict-")); +const conflictFile = join(temporaryConflictDirectory, "query.sql"); +writeFileSync(conflictFile, "select 1", "utf8"); +try { + const conflict = run(rustBinary, ["query", connection, "select 2", "--file", conflictFile, "--json"]); + assert.equal(conflict.status, 1); + assert.equal(JSON.parse(conflict.stderr).error.code, "INVALID_ARGUMENT"); + console.log("PASS inline and file conflict"); +} finally { + rmSync(temporaryConflictDirectory, { recursive: true, force: true }); +} + +console.log("CLI migration matrix passed."); diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index f84ea7e763..9a76193eca 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -5,6 +5,11 @@ use tauri::State; use crate::commands::connection::{ensure_connection_writable, AppState}; use dbx_core::db::mongo_driver::MongoDocumentResult; +#[tauri::command] +pub fn mongo_parse_shell_command(source: String) -> Result { + dbx_core::mongo_shell::parse(&source) +} + async fn run_cancellable(state: &Arc, execution_id: Option, future: F) -> Result where F: Future>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f9e9be069..79d4e55f8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1299,6 +1299,7 @@ pub fn run() { commands::document_cmd::document_upload_gridfs_file, commands::document_cmd::document_delete_gridfs_file, commands::mongo_cmd::mongo_find_documents, + commands::mongo_cmd::mongo_parse_shell_command, commands::mongo_cmd::mongo_find_one, commands::mongo_cmd::mongo_count_documents, commands::mongo_cmd::mongo_server_version,