mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(cli): migrate CLI to native Rust binary
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+14
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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" }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<MongoCommand> {
|
||||
const raw = await post<Record<string, unknown>>("/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<MongoDocumentResult> {
|
||||
return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId });
|
||||
}
|
||||
|
||||
@@ -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<MongoCommand> {
|
||||
const raw = await invoke<Record<string, unknown>>("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<MongoDocumentResult> {
|
||||
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
|
||||
}
|
||||
|
||||
@@ -90,6 +90,20 @@ export type MongoCommand =
|
||||
|
||||
export type MongoWriteCommand = Extract<MongoCommand, { kind: MongoWriteKind }>;
|
||||
|
||||
export function normalizeRustMongoCommand(raw: Record<string, unknown>): MongoCommand {
|
||||
const command = Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== null)) as Record<string, any>;
|
||||
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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>, sort: Option<String>, skip: u64, limit: i64 },
|
||||
#[serde(rename = "findOne")]
|
||||
FindOne { collection: String, filter: String, projection: Option<String>, options: Option<String> },
|
||||
#[serde(rename = "countDocuments")]
|
||||
Count { collection: String, filter: String, accurate: bool },
|
||||
#[serde(rename = "aggregate")]
|
||||
Aggregate { collection: String, pipeline: String, options: Option<String> },
|
||||
#[serde(rename = "distinct")]
|
||||
Distinct { collection: String, field: String, filter: Option<String> },
|
||||
#[serde(rename = "getIndexes")]
|
||||
GetIndexes { collection: String },
|
||||
#[serde(rename = "collectionStats")]
|
||||
CollectionStats { collection: String, metric: String, scale: Option<serde_json::Number> },
|
||||
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<String>, many: bool },
|
||||
#[serde(rename = "delete")]
|
||||
Delete { collection: String, filter: String, many: bool },
|
||||
#[serde(rename = "createIndex")]
|
||||
CreateIndex { collection: String, keys: String, options: Option<String> },
|
||||
#[serde(rename = "dropIndexes")]
|
||||
DropIndexes { collection: String, indexes: Option<String>, single: bool },
|
||||
#[serde(rename = "dropCollection")]
|
||||
DropCollection { collection: String },
|
||||
#[serde(rename = "findOneAndUpdate")]
|
||||
FindOneAndUpdate { collection: String, filter: String, update: String, options: Option<String> },
|
||||
#[serde(rename = "findOneAndReplace")]
|
||||
FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option<String> },
|
||||
#[serde(rename = "findOneAndDelete")]
|
||||
FindOneAndDelete { collection: String, filter: String, options: Option<String> },
|
||||
}
|
||||
|
||||
#[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<MongoCommand, String> {
|
||||
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<MongoCommand, String> {
|
||||
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<MongoCommand, String> {
|
||||
}
|
||||
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<Value> {
|
||||
serde_json::from_str(value).ok()
|
||||
}
|
||||
|
||||
fn optional_json_argument(value: Option<&String>) -> Result<Option<String>, String> {
|
||||
value.filter(|value| !value.trim().is_empty()).map(|value| normalized_json(value)).transpose()
|
||||
}
|
||||
|
||||
fn parse_use_database(source: &str) -> Option<String> {
|
||||
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!(
|
||||
@@ -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"
|
||||
|
||||
+296
-72
@@ -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<usize>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<dbx_core::db::QueryResult, String> {
|
||||
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<String, String> {
|
||||
) -> Result<dbx_core::db::QueryResult, String> {
|
||||
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<usize>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<dbx_core::db::QueryResult, String> {
|
||||
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<String, String> {
|
||||
) -> Result<dbx_core::db::QueryResult, String> {
|
||||
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::<Vec<_>>();
|
||||
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<usize>,
|
||||
_timeout_secs: Option<u64>,
|
||||
) -> Result<dbx_core::db::QueryResult, String> {
|
||||
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<String, String> {
|
||||
) -> Result<dbx_core::db::QueryResult, String> {
|
||||
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::<WebMongoDocuments>()
|
||||
.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::<WebMongoDocuments>()
|
||||
.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::<WebMongoDocuments>()
|
||||
.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::<WebMongoDocuments>()
|
||||
.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::<WebMongoDocuments>()
|
||||
.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::<Vec<_>>();
|
||||
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::<WebMongoDocuments>()
|
||||
.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::<WebMongoDocuments>()
|
||||
.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::<WebMongoDocuments>()
|
||||
.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<String>, rows: Vec<Vec<Value>>, 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<String>, 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<Value>) -> 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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>]) -> String {
|
||||
let mut output = format!(
|
||||
"| {} |\n| {} |",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<MongoParseShellCommandRequest>,
|
||||
) -> Result<Json<dbx_core::mongo_shell::MongoCommand>, AppError> {
|
||||
dbx_core::mongo_shell::parse(&req.source).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
async fn run_cancellable<T, F>(state: &Arc<WebState>, execution_id: Option<String>, future: F) -> Result<T, AppError>
|
||||
where
|
||||
F: Future<Output = Result<T, String>>,
|
||||
|
||||
@@ -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 基础设施。
|
||||
|
||||
## 默认连接
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+16
@@ -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",
|
||||
|
||||
+20
-4
@@ -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<string, unknown> = parsed as unknown as Record<string, unknown>;
|
||||
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", "");
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Executable
+44
@@ -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);
|
||||
+14
-14
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T extends object>(headers: string[], rows: T[]): string {
|
||||
const lines = [headers.map(csvCell).join(",")];
|
||||
for (const row of rows) {
|
||||
const values = row as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<Backend>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
diagnostics?: () => Promise<DbxDiagnostics>;
|
||||
}
|
||||
|
||||
interface ParsedFlags {
|
||||
args: string[];
|
||||
json: boolean;
|
||||
format: "table" | "json" | "csv";
|
||||
schema?: string;
|
||||
database?: string;
|
||||
tables?: string[];
|
||||
maxTables?: number;
|
||||
maxRows?: number;
|
||||
timeoutMs?: number;
|
||||
file?: string;
|
||||
allowWrites: boolean;
|
||||
allowDangerous: boolean;
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
}
|
||||
|
||||
class CliError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[], options: RunOptions = {}): Promise<CliResult> {
|
||||
const env = options.env ?? process.env;
|
||||
let ownedBackend: Backend | undefined;
|
||||
|
||||
try {
|
||||
const flags = parseFlags(argv);
|
||||
const args = flags.args;
|
||||
|
||||
if (flags.version) {
|
||||
return ok(`${await packageVersion()}\n`);
|
||||
}
|
||||
|
||||
if (args.length === 0 || flags.help || args[0] === "help") {
|
||||
return ok(`${usage()}\n`);
|
||||
}
|
||||
|
||||
const backendFactory = options.backendFactory ?? createBackend;
|
||||
const backend = options.backend ?? (ownedBackend = await backendFactory(env));
|
||||
|
||||
if (args[0] === "doctor") {
|
||||
ensureArgCount(args, 1, "dbx doctor");
|
||||
const diagnostics = await (options.diagnostics ?? getDbxDiagnostics)();
|
||||
if (flags.format === "json") return okJson(diagnostics);
|
||||
if (flags.format === "csv") {
|
||||
return ok(
|
||||
csvTable(
|
||||
["check", "value"],
|
||||
[
|
||||
{ check: "appDataDir", value: diagnostics.appDataDir },
|
||||
{ check: "dbPath", value: diagnostics.dbPath },
|
||||
{ check: "dbPathExists", value: diagnostics.dbPathExists },
|
||||
{ check: "connectionsTableExists", value: diagnostics.connectionsTableExists },
|
||||
{ check: "connectionRowCount", value: diagnostics.connectionRowCount },
|
||||
{ check: "loadConnectionsOk", value: diagnostics.loadConnectionsOk },
|
||||
{ check: "loadedConnectionCount", value: diagnostics.loadedConnectionCount },
|
||||
{ check: "loadConnectionsError", value: diagnostics.loadConnectionsError ?? "" },
|
||||
{ check: "loadConnectionsHint", value: diagnostics.loadConnectionsHint ?? "" },
|
||||
{ check: "bridgePortFile", value: diagnostics.bridgePortFile },
|
||||
{ check: "bridgePortFileExists", value: diagnostics.bridgePortFileExists },
|
||||
{ check: "bridgeUrl", value: diagnostics.bridgeUrl ?? "" },
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok(formatDoctor(diagnostics));
|
||||
}
|
||||
|
||||
if (args[0] === "capabilities") {
|
||||
ensureArgCount(args, 1, "dbx capabilities");
|
||||
const payload = {
|
||||
directQueryTypes: [...DIRECT_QUERY_TYPES],
|
||||
bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES],
|
||||
};
|
||||
if (flags.format === "json") return okJson(payload);
|
||||
if (flags.format === "csv") {
|
||||
return ok(csvTable(["mode", "type"], [...payload.directQueryTypes.map((type) => ({ mode: "direct", type })), ...payload.bridgeRequiredTypes.map((type) => ({ mode: "bridge", type }))]));
|
||||
}
|
||||
return ok(
|
||||
`${mdTable(
|
||||
["Mode", "Types"],
|
||||
[
|
||||
["Direct", payload.directQueryTypes.join(", ")],
|
||||
["Requires DBX Desktop", payload.bridgeRequiredTypes.join(", ")],
|
||||
],
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "connections" && args[1] === "list") {
|
||||
ensureArgCount(args, 2, "dbx connections list");
|
||||
const connections = (await backend.loadConnections()).map(connectionSummary);
|
||||
if (flags.format === "json") return okJson({ connections });
|
||||
if (flags.format === "csv") return ok(csvTable(["name", "type", "host", "port", "database"], connections));
|
||||
return ok(
|
||||
`${mdTable(
|
||||
["Name", "Type", "Host", "Port", "Database"],
|
||||
connections.map((c) => [c.name, c.type, c.host, String(c.port), c.database ?? ""]),
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "schema" && args[1] === "list") {
|
||||
ensureArgCount(args, 3, "dbx schema list");
|
||||
const connectionName = required(args[2], "Connection name is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const tables = await backend.listTables(config, flags.schema);
|
||||
if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, tables });
|
||||
if (flags.format === "csv") return ok(csvTable(["name", "type"], tables));
|
||||
return ok(
|
||||
`${mdTable(
|
||||
["Table", "Type"],
|
||||
tables.map((t) => [t.name, t.type]),
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "schema" && args[1] === "describe") {
|
||||
ensureArgCount(args, 4, "dbx schema describe");
|
||||
const connectionName = required(args[2], "Connection name is required.");
|
||||
const table = required(args[3], "Table name is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const columns = await backend.describeTable(config, table, flags.schema);
|
||||
if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, table, columns });
|
||||
if (flags.format === "csv") {
|
||||
return ok(csvTable(["name", "data_type", "is_nullable", "is_primary_key", "column_default", "comment"], columns));
|
||||
}
|
||||
return ok(
|
||||
`${mdTable(
|
||||
["Column", "Type", "Nullable", "Default", "Comment"],
|
||||
columns.map((c) => [c.is_primary_key ? `${c.name} (PK)` : c.name, c.data_type, c.is_nullable ? "YES" : "NO", c.column_default ?? "", c.comment ?? ""]),
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "query") {
|
||||
const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === (flags.file ? 1 : 2);
|
||||
ensureArgCount(args, usesDefaultConnection ? (flags.file ? 1 : 2) : flags.file ? 2 : 3, "dbx query");
|
||||
const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required.");
|
||||
if (flags.file && args[2]) {
|
||||
throw new CliError("INVALID_ARGUMENT", "Provide SQL either inline or with --file, not both.");
|
||||
}
|
||||
const sqlArg = usesDefaultConnection ? args[1] : args[2];
|
||||
const sql = flags.file ? await readFile(flags.file, "utf-8") : required(sqlArg, "SQL string or --file is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const envSafety = sqlSafetyFromCliEnv(env);
|
||||
if (flags.allowDangerous && !flags.allowWrites && !envSafety.allowWrites) {
|
||||
throw new CliError("INVALID_OPTION", "--allow-dangerous-sql requires --allow-writes.");
|
||||
}
|
||||
const safetyOptions: SqlSafetyOptions = {
|
||||
allowWrites: flags.allowWrites || envSafety.allowWrites,
|
||||
allowDangerous: flags.allowDangerous || envSafety.allowDangerous,
|
||||
hashLineComments: supportsHashLineComments(config.db_type),
|
||||
};
|
||||
const safety = evaluateSqlSafety(sql, safetyOptions);
|
||||
if (!safety.allowed) return fail("SQL_BLOCKED", safety.reason ?? "SQL blocked.", flags.json);
|
||||
const result = await backend.executeQuery(config, sql, { maxRows: flags.maxRows, timeoutMs: flags.timeoutMs });
|
||||
if (flags.format === "json") {
|
||||
return okJson({ connection: connectionName, columns: result.columns, rows: result.rows, row_count: result.row_count });
|
||||
}
|
||||
if (flags.format === "csv") return ok(csvTable(result.columns, result.rows));
|
||||
if (result.columns.length === 0) return ok(`Query executed. ${result.row_count} row(s) affected.\n`);
|
||||
return ok(
|
||||
`${mdTable(
|
||||
result.columns,
|
||||
result.rows.map((row) => result.columns.map((column) => formatCell(row[column]))),
|
||||
)}\n\n${result.row_count} row(s)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "context") {
|
||||
const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === 1;
|
||||
ensureArgCount(args, usesDefaultConnection ? 1 : 2, "dbx context");
|
||||
const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const context = await buildSchemaContext(backend, config, {
|
||||
schema: flags.schema,
|
||||
tables: flags.tables,
|
||||
maxTables: flags.maxTables,
|
||||
});
|
||||
if (flags.format === "json") return okJson(context);
|
||||
if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx context.");
|
||||
return ok(`${formatSchemaContext(context)}\n`);
|
||||
}
|
||||
|
||||
if (args[0] === "open") {
|
||||
ensureArgCount(args, 3, "dbx open");
|
||||
const connectionName = required(args[1], "Connection name is required.");
|
||||
const table = required(args[2], "Table name is required.");
|
||||
const response = await postBridge("/open-table", {
|
||||
connection_name: connectionName,
|
||||
table,
|
||||
schema: flags.schema,
|
||||
database: flags.database,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return fail("DBX_NOT_RUNNING", response.text || "DBX is not running. Please start DBX first.", flags.json);
|
||||
}
|
||||
if (flags.format === "json") return okJson({ opened: true, connection: connectionName, table, schema: flags.schema, database: flags.database });
|
||||
if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx open.");
|
||||
return ok(`Opened ${table} in DBX\n`);
|
||||
}
|
||||
|
||||
return fail("USAGE", usage(), flags.json);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = error instanceof CliError ? error.code : typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : "ERROR";
|
||||
const wantsJson = argv.includes("--json");
|
||||
return fail(code, message, wantsJson);
|
||||
} finally {
|
||||
await ownedBackend?.close?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): ParsedFlags {
|
||||
const args: string[] = [];
|
||||
const flags: ParsedFlags = {
|
||||
args,
|
||||
json: false,
|
||||
format: "table",
|
||||
allowWrites: false,
|
||||
allowDangerous: false,
|
||||
help: false,
|
||||
version: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--") {
|
||||
args.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
flags.json = true;
|
||||
flags.format = "json";
|
||||
} else if (arg === "--format") flags.format = parseFormat(readOptionValue(argv, ++i, "--format"));
|
||||
else if (arg === "--help" || arg === "-h") flags.help = true;
|
||||
else if (arg === "--version" || arg === "-V") flags.version = true;
|
||||
else if (arg === "--schema") flags.schema = readOptionValue(argv, ++i, "--schema");
|
||||
else if (arg === "--database") flags.database = readOptionValue(argv, ++i, "--database");
|
||||
else if (arg === "--tables") flags.tables = splitCsv(readOptionValue(argv, ++i, "--tables"));
|
||||
else if (arg === "--max-tables") flags.maxTables = parsePositiveInt(readOptionValue(argv, ++i, "--max-tables"), "--max-tables");
|
||||
else if (arg === "--limit") flags.maxRows = parsePositiveInt(readOptionValue(argv, ++i, "--limit"), "--limit");
|
||||
else if (arg === "--timeout") flags.timeoutMs = parseDurationMs(readOptionValue(argv, ++i, "--timeout"), "--timeout");
|
||||
else if (arg === "--file") flags.file = readOptionValue(argv, ++i, "--file");
|
||||
else if (arg === "--allow-writes") flags.allowWrites = true;
|
||||
else if (arg === "--allow-dangerous-sql") flags.allowDangerous = true;
|
||||
else if (arg.startsWith("-")) throw new CliError("UNKNOWN_OPTION", `Unknown option: ${arg}`);
|
||||
else args.push(arg);
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function parseFormat(value: string): "table" | "json" | "csv" {
|
||||
if (value === "table" || value === "json" || value === "csv") return value;
|
||||
throw new CliError("INVALID_OPTION", "--format must be one of: table, json, csv.");
|
||||
}
|
||||
|
||||
function ensureArgCount(args: string[], count: number, command: string): void {
|
||||
if (args.length !== count) {
|
||||
throw new CliError("INVALID_ARGUMENT", `${command} expects ${count - 1} argument(s); received ${args.length - 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionValue(argv: string[], index: number, option: string): string {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new CliError("INVALID_OPTION", `${option} requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, option: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new CliError("INVALID_OPTION", `${option} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseDurationMs(value: string, option: string): number {
|
||||
const match = value.match(/^(\d+)(ms|s|m)?$/);
|
||||
if (!match) {
|
||||
throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`);
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isInteger(amount) || amount < 1) {
|
||||
throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`);
|
||||
}
|
||||
const unit = match[2] ?? "ms";
|
||||
if (unit === "ms") return amount;
|
||||
if (unit === "s") return amount * 1000;
|
||||
return amount * 60_000;
|
||||
}
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
}
|
||||
|
||||
function sqlSafetyFromCliEnv(env: NodeJS.ProcessEnv): Required<Pick<SqlSafetyOptions, "allowWrites" | "allowDangerous">> {
|
||||
return {
|
||||
allowWrites: parseBooleanEnv(env.DBX_MCP_ALLOW_WRITES),
|
||||
allowDangerous: parseBooleanEnv(env.DBX_MCP_ALLOW_DANGEROUS_SQL),
|
||||
};
|
||||
}
|
||||
|
||||
function splitCsv(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function findConnectionOrThrow(backend: Backend, name: string) {
|
||||
const config = await backend.findConnection(name);
|
||||
if (!config) throw new CliError("CONNECTION_NOT_FOUND", `Connection "${name}" not found.`);
|
||||
return config;
|
||||
}
|
||||
|
||||
function required(value: string | undefined, message: string): string {
|
||||
if (!value) throw new Error(message);
|
||||
return value;
|
||||
}
|
||||
|
||||
function ok(stdout: string): CliResult {
|
||||
return { exitCode: 0, stdout, stderr: "" };
|
||||
}
|
||||
|
||||
function okJson(payload: unknown): CliResult {
|
||||
return ok(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function fail(code: string, message: string, json: boolean): CliResult {
|
||||
const text = json ? `${JSON.stringify(errorPayload(code, message), null, 2)}\n` : `${formatErrorMessage(code, message)}\n`;
|
||||
return { exitCode: 1, stdout: "", stderr: text };
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" dbx doctor [--json]",
|
||||
" dbx capabilities [--json]",
|
||||
" dbx connections list [--json]",
|
||||
" dbx schema list <connection> [--schema name] [--json]",
|
||||
" dbx schema describe <connection> <table> [--schema name] [--json]",
|
||||
" dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]",
|
||||
" dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]",
|
||||
" dbx open <connection> <table> [--schema name] [--database name] [--json]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatDoctor(diagnostics: DbxDiagnostics): string {
|
||||
const rows = [
|
||||
["App data directory", diagnostics.appDataDir],
|
||||
["DBX database", diagnostics.dbPathExists ? `found (${diagnostics.dbPath})` : `missing (${diagnostics.dbPath})`],
|
||||
["Connections table", diagnostics.connectionsTableExists ? `${diagnostics.connectionRowCount} row(s)` : "missing"],
|
||||
["Connection loading", diagnostics.loadConnectionsOk ? `ok (${diagnostics.loadedConnectionCount} loaded)` : `failed (${diagnostics.loadConnectionsError ?? "unknown error"})`],
|
||||
...(diagnostics.loadConnectionsHint ? [["Connection fix", diagnostics.loadConnectionsHint]] : []),
|
||||
["Desktop bridge", diagnostics.bridgePortFileExists ? `available (${diagnostics.bridgeUrl ?? diagnostics.bridgePortFile})` : "not running"],
|
||||
["Direct query types", diagnostics.directQueryTypes.join(", ")],
|
||||
["Bridge-required types", diagnostics.bridgeRequiredTypes.join(", ")],
|
||||
];
|
||||
return `${mdTable(["Check", "Value"], rows)}\n`;
|
||||
}
|
||||
|
||||
async function packageVersion(): Promise<string> {
|
||||
const packageJson = await readFile(new URL("../package.json", import.meta.url), "utf-8");
|
||||
const parsed = JSON.parse(packageJson) as { version?: string };
|
||||
return parsed.version ?? "0.0.0";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await runCli(process.argv.slice(2));
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url, process.argv[1])) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Vendored
-53
@@ -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",
|
||||
});
|
||||
}
|
||||
-66
@@ -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');
|
||||
});
|
||||
Vendored
-429
@@ -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> = {}): 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");
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+104
-33
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
@@ -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::MongoCommand, String> {
|
||||
dbx_core::mongo_shell::parse(&source)
|
||||
}
|
||||
|
||||
async fn run_cancellable<T, F>(state: &Arc<AppState>, execution_id: Option<String>, future: F) -> Result<T, String>
|
||||
where
|
||||
F: Future<Output = Result<T, String>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user