feat(cli): add dbx node packages
This commit is contained in:
parent
19f7cbd2ba
commit
1917c0855b
|
|
@ -15,15 +15,20 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
version: 10.27.0
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Frontend format check
|
||||
run: npx oxfmt --check "src/**/*.{ts,vue}"
|
||||
|
|
@ -34,6 +39,12 @@ jobs:
|
|||
- name: Frontend tests
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Node package tests
|
||||
run: pnpm test:packages
|
||||
|
||||
- name: Node package publish dry run
|
||||
run: pnpm publish:dry-run
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
@ -56,11 +67,6 @@ jobs:
|
|||
workspaces: './ -> target'
|
||||
shared-key: ci-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
|
||||
|
||||
- name: Cargo fmt check
|
||||
run: cargo fmt --check
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
name: MCP Release
|
||||
name: Node Packages Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'mcp/src/**'
|
||||
- 'mcp/tests/**'
|
||||
- 'mcp/package.json'
|
||||
- 'mcp/package-lock.json'
|
||||
- 'mcp/pnpm-lock.yaml'
|
||||
- 'mcp/pnpm-workspace.yaml'
|
||||
- 'mcp/tsconfig.json'
|
||||
- 'mcp/server.json'
|
||||
inputs:
|
||||
version:
|
||||
description: "Package version to publish, for example 0.4.3"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -20,9 +13,8 @@ permissions:
|
|||
|
||||
jobs:
|
||||
publish:
|
||||
name: Bump patch and publish MCP server
|
||||
name: Publish CLI and MCP packages
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor != 'github-actions[bot]' && (github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip mcp-release]'))
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -32,31 +24,22 @@ jobs:
|
|||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
version: 10.27.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
cache-dependency-path: mcp/pnpm-lock.yaml
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Check npm token
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${NODE_AUTH_TOKEN}" ]; then
|
||||
echo "::error::NPM_TOKEN secret is required to publish @dbx-app/mcp-server."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check release token
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${RELEASE_TOKEN}" ]; then
|
||||
echo "::error::MCP_RELEASE_TOKEN secret is required to push the MCP release commit and tag."
|
||||
echo "::error::NPM_TOKEN secret is required to publish DBX Node packages."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -66,99 +49,98 @@ jobs:
|
|||
sudo apt-get install -y libsecret-1-dev
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mcp
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run MCP tests
|
||||
working-directory: mcp
|
||||
run: pnpm test
|
||||
|
||||
- name: Build MCP server
|
||||
working-directory: mcp
|
||||
run: pnpm build
|
||||
|
||||
- name: Bump MCP patch version
|
||||
- name: Set package versions
|
||||
id: version
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const fs = require("fs");
|
||||
|
||||
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
const writeJson = (path, data) => {
|
||||
fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const packagePath = 'mcp/package.json';
|
||||
const packageLockPath = 'mcp/package-lock.json';
|
||||
const serverPath = 'mcp/server.json';
|
||||
|
||||
const pkg = readJson(packagePath);
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(pkg.version);
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported MCP package version: ${pkg.version}`);
|
||||
const version = process.env.VERSION.trim();
|
||||
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`Invalid semver version: ${version}`);
|
||||
}
|
||||
|
||||
const nextVersion = `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
|
||||
pkg.version = nextVersion;
|
||||
writeJson(packagePath, pkg);
|
||||
const readJson = (path) => JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const writeJson = (path, data) => fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
||||
|
||||
if (fs.existsSync(packageLockPath)) {
|
||||
const packageLock = readJson(packageLockPath);
|
||||
packageLock.version = nextVersion;
|
||||
if (packageLock.packages?.['']) {
|
||||
packageLock.packages[''].version = nextVersion;
|
||||
}
|
||||
writeJson(packageLockPath, packageLock);
|
||||
for (const path of [
|
||||
"packages/node-core/package.json",
|
||||
"packages/cli/package.json",
|
||||
"packages/mcp-server/package.json",
|
||||
]) {
|
||||
const pkg = readJson(path);
|
||||
pkg.version = version;
|
||||
writeJson(path, pkg);
|
||||
}
|
||||
|
||||
const serverPath = "packages/mcp-server/server.json";
|
||||
const server = readJson(serverPath);
|
||||
server.version = nextVersion;
|
||||
server.version = version;
|
||||
for (const packageInfo of server.packages ?? []) {
|
||||
if (packageInfo.registryType === 'npm' && packageInfo.identifier === pkg.name) {
|
||||
packageInfo.version = nextVersion;
|
||||
if (packageInfo.registryType === "npm" && packageInfo.identifier === "@dbx-app/mcp-server") {
|
||||
packageInfo.version = version;
|
||||
}
|
||||
}
|
||||
writeJson(serverPath, server);
|
||||
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${nextVersion}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
|
||||
NODE
|
||||
|
||||
- name: Check npm version is new
|
||||
working-directory: mcp
|
||||
- name: Check npm versions are new
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
if npm view "@dbx-app/mcp-server@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "::error::@dbx-app/mcp-server@${VERSION} already exists on npm."
|
||||
exit 1
|
||||
fi
|
||||
for PACKAGE in @dbx-app/node-core @dbx-app/cli @dbx-app/mcp-server; do
|
||||
if npm view "${PACKAGE}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "::error::${PACKAGE}@${VERSION} already exists on npm."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Pack MCP package
|
||||
working-directory: mcp
|
||||
run: npm pack --dry-run
|
||||
- name: Run package tests
|
||||
run: pnpm test:packages
|
||||
|
||||
- name: Build and pack packages
|
||||
run: pnpm publish:dry-run
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Commit MCP release version
|
||||
- name: Commit package release version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
git add mcp/package.json mcp/package-lock.json mcp/server.json
|
||||
git commit -m "chore(mcp): release ${VERSION} [skip mcp-release]"
|
||||
git tag "mcp-v${VERSION}"
|
||||
git add packages/node-core/package.json packages/cli/package.json packages/mcp-server/package.json packages/mcp-server/server.json
|
||||
git commit -m "chore(packages): release ${VERSION} [skip node-packages-release]"
|
||||
git tag "packages-v${VERSION}"
|
||||
|
||||
- name: Push MCP release commit and tag
|
||||
- name: Push package release commit and tag
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
if [ -n "${RELEASE_TOKEN}" ]; then
|
||||
git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
fi
|
||||
git push origin HEAD:main
|
||||
git push origin "mcp-v${VERSION}"
|
||||
git push origin "packages-v${VERSION}"
|
||||
|
||||
- name: Publish MCP package to npm
|
||||
working-directory: mcp
|
||||
- name: Publish Node core
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --provenance
|
||||
run: pnpm --filter @dbx-app/node-core publish --access public --provenance --no-git-checks
|
||||
|
||||
- name: Publish CLI
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: pnpm --filter @dbx-app/cli publish --access public --provenance --no-git-checks
|
||||
|
||||
- name: Publish MCP server
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: pnpm --filter @dbx-app/mcp-server publish --access public --provenance --no-git-checks
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -105,7 +105,7 @@ Dark mode with native title bar sync · 9 editor themes · English, 简体中文
|
|||
|
||||
## AI Agent Integration (MCP)
|
||||
|
||||
DBX provides an [MCP server](mcp/) that lets AI coding agents query your databases using connections already configured in DBX.
|
||||
DBX provides an [MCP server](packages/mcp-server/) that lets AI coding agents query your databases using connections already configured in DBX.
|
||||
|
||||
```bash
|
||||
npx @dbx-app/mcp-server
|
||||
|
|
@ -123,7 +123,15 @@ Add to your `.mcp.json`:
|
|||
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP-compatible agent. Supports listing connections, browsing tables, executing SQL, and opening tables directly in DBX's UI.
|
||||
|
||||
See the [MCP server README](mcp/README.md) for details.
|
||||
DBX also provides a dedicated CLI package for terminal, script, and Codex workflows:
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
See the [MCP server README](packages/mcp-server/README.md) and [CLI README](packages/cli/README.md) for details.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ SSH 隧道(密钥和密码认证)· 数据库和 AI 代理设置 · 断线
|
|||
|
||||
## AI 编程助手集成 (MCP)
|
||||
|
||||
DBX 提供 [MCP Server](mcp/),让 AI 编程助手直接使用 DBX 中已配置的数据库连接查询数据。
|
||||
DBX 提供 [MCP Server](packages/mcp-server/),让 AI 编程助手直接使用 DBX 中已配置的数据库连接查询数据。
|
||||
|
||||
```bash
|
||||
npx @dbx-app/mcp-server
|
||||
|
|
@ -123,7 +123,15 @@ npx @dbx-app/mcp-server
|
|||
|
||||
支持 Claude Code、Cursor、Windsurf 等 MCP 兼容的 AI 助手。可列出连接、浏览表、执行 SQL,还能直接在 DBX 界面中打开表。
|
||||
|
||||
详见 [MCP Server 说明](mcp/README.md)。
|
||||
DBX 也提供独立 CLI 包,适合终端、脚本和 Codex 工作流:
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
详见 [MCP Server 说明](packages/mcp-server/README.md) 和 [CLI 说明](packages/cli/README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
title: DBX CLI
|
||||
description: 在终端、脚本、CI 和 Codex 中使用 DBX 连接。
|
||||
---
|
||||
|
||||
<Callout type="info">
|
||||
DBX CLI 是独立命令行包,适合终端、脚本和 AI 编程助手工作流。它与 MCP Server 共享 DBX 连接存储和 SQL 安全规则。
|
||||
</Callout>
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
```
|
||||
|
||||
需要 Node.js 22.13.0 或更高版本。
|
||||
|
||||
查看版本:
|
||||
|
||||
```bash
|
||||
dbx --version
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
dbx connections list --json
|
||||
dbx connections list --format csv
|
||||
dbx schema list local --json
|
||||
dbx schema describe local users --json
|
||||
dbx query local "select count(*) as total from users" --json
|
||||
dbx query local "select id, name from users" --format csv
|
||||
dbx query local "select * from users" --limit 50 --timeout 10s --json
|
||||
dbx query local --file ./query.sql --json
|
||||
dbx context local --tables users,orders
|
||||
dbx open local users
|
||||
```
|
||||
|
||||
## 诊断
|
||||
|
||||
使用 `dbx doctor` 查看本地 DBX 路径、连接存储健康状态,以及桌面端 bridge 是否可用:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @dbx-app/cli
|
||||
npm install -g @dbx-app/cli
|
||||
```
|
||||
|
||||
使用 `dbx capabilities` 查看哪些数据库类型可以直接查询,哪些当前需要 DBX 桌面端:
|
||||
|
||||
```bash
|
||||
dbx capabilities
|
||||
dbx capabilities --json
|
||||
```
|
||||
|
||||
当前直接执行支持 PostgreSQL/Redshift、MySQL 兼容数据库(MySQL、Doris、StarRocks)和 SQLite。其它数据库类型在对应驱动加入 `@dbx-app/node-core` 前,会使用 DBX 桌面端 bridge。
|
||||
|
||||
## 默认连接
|
||||
|
||||
设置 `DBX_CONNECTION` 后,`query` 和 `context` 命令可以省略连接名:
|
||||
|
||||
```bash
|
||||
DBX_CONNECTION=local dbx query "select 1" --json
|
||||
DBX_CONNECTION=local dbx context --tables users,orders
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
使用 `--json` 或 `--format json` 可以获得稳定的机器可读输出。`--format csv` 适合把查询、连接、Schema 数据传给其它命令行工具。
|
||||
|
||||
```bash
|
||||
dbx query local "select id, name from users" --format csv
|
||||
```
|
||||
|
||||
错误会写入 stderr,并返回非零退出码。
|
||||
|
||||
## 查询控制
|
||||
|
||||
`dbx query` 执行单条 SQL,默认只读。
|
||||
|
||||
```bash
|
||||
dbx query local "select * from users" --limit 50 --timeout 10s --json
|
||||
```
|
||||
|
||||
时间支持 `ms`、`s`、`m`,例如 `500ms`、`10s`、`1m`。
|
||||
|
||||
非危险写操作需要显式使用 `--allow-writes`:
|
||||
|
||||
```bash
|
||||
dbx query local "update users set name = 'Ada' where id = 1" --allow-writes
|
||||
```
|
||||
|
||||
`DROP`、`TRUNCATE`、`ALTER` 等危险 SQL 需要同时显式使用 `--allow-writes` 和 `--allow-dangerous-sql`。
|
||||
|
||||
## 以短横线开头的 SQL
|
||||
|
||||
如果 SQL 以短横线开头,在 SQL 前加 `--`:
|
||||
|
||||
```bash
|
||||
dbx query local --json -- "-- comment
|
||||
select 1"
|
||||
```
|
||||
|
||||
## 错误码
|
||||
|
||||
CLI JSON 错误使用稳定错误码:
|
||||
|
||||
| 错误码 | 含义 |
|
||||
|---|---|
|
||||
| `UNKNOWN_OPTION` | 使用了不支持的参数 |
|
||||
| `INVALID_OPTION` | 参数缺少值或值不合法 |
|
||||
| `INVALID_ARGUMENT` | 位置参数缺失或冲突 |
|
||||
| `CONNECTION_STORE_ERROR` | DBX 连接存储存在,但无法读取 |
|
||||
| `CONNECTION_NOT_FOUND` | 找不到指定的 DBX 连接 |
|
||||
| `SQL_BLOCKED` | SQL 被安全规则拦截 |
|
||||
| `DBX_NOT_RUNNING` | DBX 桌面端 bridge 不可用 |
|
||||
| `ERROR` | 未预期的运行时错误 |
|
||||
|
||||
## Codex
|
||||
|
||||
Codex 可以直接通过 shell 调用 CLI:
|
||||
|
||||
```bash
|
||||
dbx schema describe local users --json
|
||||
dbx context local --tables users,orders | codex exec "Write a retention query"
|
||||
```
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
title: DBX CLI
|
||||
description: Use DBX connections from terminals, scripts, CI, and Codex.
|
||||
---
|
||||
|
||||
<Callout type="info">
|
||||
DBX CLI is a dedicated command line package for terminal, script, and coding-agent workflows. It shares DBX connection storage and SQL safety rules with the MCP server.
|
||||
</Callout>
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
```
|
||||
|
||||
Requires Node.js 22.13.0 or newer.
|
||||
|
||||
Check the installed version:
|
||||
|
||||
```bash
|
||||
dbx --version
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
dbx connections list --json
|
||||
dbx connections list --format csv
|
||||
dbx schema list local --json
|
||||
dbx schema describe local users --json
|
||||
dbx query local "select count(*) as total from users" --json
|
||||
dbx query local "select id, name from users" --format csv
|
||||
dbx query local "select * from users" --limit 50 --timeout 10s --json
|
||||
dbx query local --file ./query.sql --json
|
||||
dbx context local --tables users,orders
|
||||
dbx open local users
|
||||
```
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Use `dbx doctor` to inspect local DBX paths, connection-store health, and whether the desktop bridge is available:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @dbx-app/cli
|
||||
npm install -g @dbx-app/cli
|
||||
```
|
||||
|
||||
Use `dbx capabilities` to see which database types can be queried directly and which currently require DBX Desktop:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
## Default Connection
|
||||
|
||||
Set `DBX_CONNECTION` to omit the connection name for query and context commands:
|
||||
|
||||
```bash
|
||||
DBX_CONNECTION=local dbx query "select 1" --json
|
||||
DBX_CONNECTION=local dbx context --tables users,orders
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
Use `--json` or `--format json` for stable machine-readable output. Use `--format csv` for query, connection, and schema data that should be piped into other command line tools.
|
||||
|
||||
```bash
|
||||
dbx query local "select id, name from users" --format csv
|
||||
```
|
||||
|
||||
Errors are written to stderr and return a non-zero exit code.
|
||||
|
||||
## Query Controls
|
||||
|
||||
`dbx query` executes one SQL statement. It is read-only by default.
|
||||
|
||||
```bash
|
||||
dbx query local "select * from users" --limit 50 --timeout 10s --json
|
||||
```
|
||||
|
||||
Durations accept `ms`, `s`, or `m`, such as `500ms`, `10s`, or `1m`.
|
||||
|
||||
Use `--allow-writes` for non-dangerous write statements:
|
||||
|
||||
```bash
|
||||
dbx query local "update users set name = 'Ada' where id = 1" --allow-writes
|
||||
```
|
||||
|
||||
Dangerous SQL such as `DROP`, `TRUNCATE`, and `ALTER` requires both `--allow-writes` and `--allow-dangerous-sql`.
|
||||
|
||||
## SQL Starting With a Dash
|
||||
|
||||
Pass `--` before SQL that starts with a dash:
|
||||
|
||||
```bash
|
||||
dbx query local --json -- "-- comment
|
||||
select 1"
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
CLI JSON errors use stable codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `UNKNOWN_OPTION` | An unsupported flag was provided |
|
||||
| `INVALID_OPTION` | A flag is missing a value or has an invalid value |
|
||||
| `INVALID_ARGUMENT` | Positional arguments are missing or conflicting |
|
||||
| `CONNECTION_STORE_ERROR` | DBX connection storage exists but could not be read |
|
||||
| `CONNECTION_NOT_FOUND` | No DBX connection matched the requested name |
|
||||
| `SQL_BLOCKED` | SQL safety rules blocked execution |
|
||||
| `DBX_NOT_RUNNING` | DBX Desktop bridge is unavailable |
|
||||
| `ERROR` | Unexpected runtime failure |
|
||||
|
||||
## Codex
|
||||
|
||||
Codex can call the CLI directly from shell tools:
|
||||
|
||||
```bash
|
||||
dbx schema describe local users --json
|
||||
dbx context local --tables users,orders | codex exec "Write a retention query"
|
||||
```
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
"database-export",
|
||||
"---AI 与自动化---",
|
||||
"ai-assistant",
|
||||
"cli",
|
||||
"mcp",
|
||||
"---设置---",
|
||||
"plugins",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"database-export",
|
||||
"---AI & Automation---",
|
||||
"ai-assistant",
|
||||
"cli",
|
||||
"mcp",
|
||||
"---Settings---",
|
||||
"plugins",
|
||||
|
|
|
|||
|
|
@ -1,529 +0,0 @@
|
|||
# DBX Docs MDX Code Sync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Update the DBX static docs MDX pages in English and Simplified Chinese so they match the actual product behavior implemented in this repository.
|
||||
|
||||
**Architecture:** Treat repository code as the documentation source of truth, then revise existing Fumadocs MDX content in bilingual pairs. Keep the existing docs framework and components, and validate the result with file scans plus the docs build.
|
||||
|
||||
**Tech Stack:** Fumadocs MDX, Next.js docs app, Vue/Tauri frontend, Rust `dbx-core`, Node MCP server.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `docs/content/docs/*.mdx` and `docs/content/docs/*.cn.mdx`
|
||||
- Existing bilingual documentation pages.
|
||||
- Keep page slugs unchanged.
|
||||
- Keep English and Simplified Chinese page structures equivalent.
|
||||
- Read only: `src/types/database.ts`
|
||||
- Database type names and shared frontend models.
|
||||
- Read only: `src/components/connection/ConnectionDialog.vue`
|
||||
- Connection profiles, default ports, file-based types, SSH/proxy behavior, JDBC fields.
|
||||
- Read only: `src/lib/databaseCapabilitySets.ts`
|
||||
- Feature support by database type.
|
||||
- Read only: `src/lib/api.ts`, `src/lib/tauri.ts`, `src/lib/http.ts`
|
||||
- Desktop/web API parity and feature endpoints.
|
||||
- Read only: `src/lib/ai.ts`, `src/lib/aiSqlExecutionPolicy.ts`, `src/lib/aiSkills.ts`
|
||||
- AI Ask/Agent behavior and SQL safety policy.
|
||||
- Read only: `crates/dbx-core/src/sql.rs`
|
||||
- SQL file splitting and batch behavior.
|
||||
- Read only: `crates/dbx-core/src/table_import.rs`
|
||||
- File import formats, preview, mapping, batching, append/truncate modes.
|
||||
- Read only: `crates/dbx-core/src/transfer.rs`
|
||||
- Transfer modes, create table behavior, batching, cancellation.
|
||||
- Read only: `crates/dbx-core/src/database_export.rs`
|
||||
- Export SQL contents, selected table filtering, progress, cancellation.
|
||||
- Read only: `crates/dbx-core/src/plugins.rs`
|
||||
- Plugin/JDBC protocol behavior and timeout.
|
||||
- Read only: `mcp/src/*.ts`
|
||||
- MCP tools, desktop/web mode, SQL safety environment variables.
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
- Track task progress as execution proceeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Build A Documentation Fact Baseline
|
||||
|
||||
**Files:**
|
||||
- Read: `src/types/database.ts`
|
||||
- Read: `src/components/connection/ConnectionDialog.vue`
|
||||
- Read: `src/lib/databaseCapabilitySets.ts`
|
||||
- Read: `src/lib/api.ts`
|
||||
- Read: `src/lib/ai.ts`
|
||||
- Read: `src/lib/aiSqlExecutionPolicy.ts`
|
||||
- Read: `crates/dbx-core/src/sql.rs`
|
||||
- Read: `crates/dbx-core/src/table_import.rs`
|
||||
- Read: `crates/dbx-core/src/transfer.rs`
|
||||
- Read: `crates/dbx-core/src/database_export.rs`
|
||||
- Read: `crates/dbx-core/src/plugins.rs`
|
||||
- Read: `mcp/src/index.ts`
|
||||
- Read: `mcp/src/sql-safety.ts`
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
|
||||
- [x] **Step 1: Extract database type and profile facts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sed -n '1,80p' src/types/database.ts
|
||||
sed -n '113,230p' src/components/connection/ConnectionDialog.vue
|
||||
sed -n '404,445p' src/components/connection/ConnectionDialog.vue
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- A complete `DatabaseType` union.
|
||||
- Connection profile labels, default ports, and profile-to-driver mappings.
|
||||
- A picker list that shows user-visible database choices.
|
||||
|
||||
- [x] **Step 2: Extract feature support boundaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sed -n '1,220p' src/lib/databaseCapabilitySets.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Sets for schema-aware databases, SQL file unsupported types, diagrams, database search, table import, table structure, create database, field lineage, and transfer support.
|
||||
|
||||
- [x] **Step 3: Extract workflow and safety facts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sed -n '1,160p' src/lib/api.ts
|
||||
sed -n '1,220p' src/lib/ai.ts
|
||||
sed -n '1,180p' src/lib/aiSqlExecutionPolicy.ts
|
||||
sed -n '1,220p' crates/dbx-core/src/sql.rs
|
||||
sed -n '1,420p' crates/dbx-core/src/table_import.rs
|
||||
sed -n '1,260p' crates/dbx-core/src/transfer.rs
|
||||
sed -n '1,470p' crates/dbx-core/src/database_export.rs
|
||||
sed -n '1,380p' mcp/src/index.ts
|
||||
sed -n '1,180p' mcp/src/sql-safety.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Concrete facts for AI modes, SQL safety, SQL file splitting, import formats, transfer modes, export contents, MCP tools, and MCP safety defaults.
|
||||
|
||||
- [x] **Step 4: Mark this task complete in this plan**
|
||||
|
||||
Edit this file and change Task 1 checkboxes to checked after facts are collected.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Deeply Revise Core Setup And Database Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/content/docs/getting-started.mdx`
|
||||
- Modify: `docs/content/docs/getting-started.cn.mdx`
|
||||
- Modify: `docs/content/docs/databases.mdx`
|
||||
- Modify: `docs/content/docs/databases.cn.mdx`
|
||||
- Modify: `docs/content/docs/plugins.mdx`
|
||||
- Modify: `docs/content/docs/plugins.cn.mdx`
|
||||
- Modify: `docs/content/docs/ssh-tunnel.mdx`
|
||||
- Modify: `docs/content/docs/ssh-tunnel.cn.mdx`
|
||||
- Modify: `docs/content/docs/config-export.mdx`
|
||||
- Modify: `docs/content/docs/config-export.cn.mdx`
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
|
||||
- [x] **Step 1: Update Getting Started in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Desktop and Docker share the same Vue frontend through different backends.
|
||||
- Desktop uses Tauri commands; Docker/web uses HTTP routes.
|
||||
- Connection setup supports database type selection, connection URL parsing for common schemes, SSH, proxy, SSL, color labels, and visible database filtering where applicable.
|
||||
- File-based database types include SQLite, DuckDB, and Access.
|
||||
- Connection secrets are stored separately from the ordinary connection JSON.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Keep install tabs.
|
||||
- Add a concise "Desktop vs Docker" table.
|
||||
- Expand connection creation steps with URL import, SSH/proxy, file-based databases, and test/save flow.
|
||||
- Add safety notes for secrets and production connection colors.
|
||||
|
||||
- [x] **Step 2: Update Database Support in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Full type set comes from `src/types/database.ts`.
|
||||
- User-visible profile list comes from `ConnectionDialog.vue`.
|
||||
- Feature capability sets come from `src/lib/databaseCapabilitySets.ts`.
|
||||
- Some choices are native, some compatibility profiles, some Agent/JDBC-backed.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Replace the incomplete "Fully Supported" table with grouped support tables:
|
||||
- Built-in/common engines.
|
||||
- Compatibility profiles.
|
||||
- Agent/JDBC-oriented engines.
|
||||
- File-based engines.
|
||||
- Add a feature support matrix for schema browser, ER/diagram, database search, table import, table structure editor, field lineage, SQL file execution, and data transfer.
|
||||
- Keep DM/ODBC notes where still relevant.
|
||||
- Add a note that feature support is intentionally database-specific.
|
||||
|
||||
- [x] **Step 3: Update JDBC Plugin docs in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- The plugin protocol version is `SUPPORTED_PLUGIN_PROTOCOL_VERSION`.
|
||||
- Plugin calls have a request timeout.
|
||||
- JDBC plugin is optional and drivers are not bundled.
|
||||
- JDBC connections carry `jdbc_driver_class` and `jdbc_driver_paths`.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Clarify main app vs optional plugin responsibilities.
|
||||
- Document driver JAR import, driver class, connection test, and troubleshooting boundaries.
|
||||
- Add security and compatibility notes.
|
||||
|
||||
- [x] **Step 4: Lightly update SSH Tunnel and Config Export in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- SSH supports password and key auth, key passphrase, connect timeout, and optional LAN exposure.
|
||||
- Proxy supports SOCKS5 and HTTP fields in the connection model.
|
||||
- Config export/import should distinguish ordinary config from secret handling.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add concise boundary notes without turning these pages into internals docs.
|
||||
- Ensure links to Getting Started and Database Support are present.
|
||||
|
||||
- [x] **Step 5: Mark this task complete in this plan**
|
||||
|
||||
Edit this file and check all Task 2 boxes after both English and Chinese files are updated.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deeply Revise Core Workflow Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/content/docs/query-editor.mdx`
|
||||
- Modify: `docs/content/docs/query-editor.cn.mdx`
|
||||
- Modify: `docs/content/docs/data-grid.mdx`
|
||||
- Modify: `docs/content/docs/data-grid.cn.mdx`
|
||||
- Modify: `docs/content/docs/schema-browser.mdx`
|
||||
- Modify: `docs/content/docs/schema-browser.cn.mdx`
|
||||
- Modify: `docs/content/docs/schema-diff.mdx`
|
||||
- Modify: `docs/content/docs/schema-diff.cn.mdx`
|
||||
- Modify: `docs/content/docs/table-structure.mdx`
|
||||
- Modify: `docs/content/docs/table-structure.cn.mdx`
|
||||
- Modify: `docs/content/docs/field-lineage.mdx`
|
||||
- Modify: `docs/content/docs/field-lineage.cn.mdx`
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
|
||||
- [x] **Step 1: Update Query Editor in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- SQL execution APIs include query execution, multi execution, batch/script execution, transaction execution, cancellation, and query session close.
|
||||
- Statement selection and cursor statement logic are handled in frontend helpers.
|
||||
- Completion uses metadata and dialect awareness.
|
||||
- AI can use the current SQL and schema context but should not imply automatic execution in Ask mode.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add "execution target" explanation for selected SQL, current statement, and full editor contents.
|
||||
- Add cancellation/session notes.
|
||||
- Link to AI Assistant and Data Grid.
|
||||
- Keep shortcut table.
|
||||
|
||||
- [x] **Step 2: Update Data Grid in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Grid supports virtual scrolling, selection, column width, sorting, pagination, editing, row status, export formats, Markdown table export, and SQL preview for edits.
|
||||
- Query results may be read-only depending on query shape and available primary keys.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add "when editing is available" and "when result is read-only" sections.
|
||||
- Add review-before-save behavior.
|
||||
- Add export and copy formats.
|
||||
|
||||
- [x] **Step 3: Update Schema Browser and related schema pages in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Schema browser handles relational trees, Redis DB/key trees, MongoDB databases/collections, object browser, saved SQL library, pinned items, search, visible databases, object source, and refresh targets.
|
||||
- Diagram and field lineage support are database-specific from capability sets.
|
||||
- Table structure editor support is database-specific.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add database-specific object models.
|
||||
- Add capability boundary tables.
|
||||
- Cross-link schema diff, table structure, field lineage, database search if documented.
|
||||
|
||||
- [x] **Step 4: Mark this task complete in this plan**
|
||||
|
||||
Edit this file and check all Task 3 boxes after updates are complete.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deeply Revise Data Movement And Automation Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/content/docs/data-transfer.mdx`
|
||||
- Modify: `docs/content/docs/data-transfer.cn.mdx`
|
||||
- Modify: `docs/content/docs/table-import.mdx`
|
||||
- Modify: `docs/content/docs/table-import.cn.mdx`
|
||||
- Modify: `docs/content/docs/sql-file.mdx`
|
||||
- Modify: `docs/content/docs/sql-file.cn.mdx`
|
||||
- Modify: `docs/content/docs/database-export.mdx`
|
||||
- Modify: `docs/content/docs/database-export.cn.mdx`
|
||||
- Modify: `docs/content/docs/ai-assistant.mdx`
|
||||
- Modify: `docs/content/docs/ai-assistant.cn.mdx`
|
||||
- Modify: `docs/content/docs/mcp.mdx`
|
||||
- Modify: `docs/content/docs/mcp.cn.mdx`
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
|
||||
- [x] **Step 1: Update Data Transfer in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Transfer modes include append, overwrite, and upsert.
|
||||
- Transfer can create target tables, map basic column types, batch rows, report progress, and cancel.
|
||||
- SQL transfer support is database-specific.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add mode table.
|
||||
- Add source/target database support boundaries.
|
||||
- Add review and cancellation notes.
|
||||
|
||||
- [x] **Step 2: Update Table Import in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Supported files are CSV, TSV, JSON, XLSX, XLSM, XLS.
|
||||
- Preview limit defaults to 50 rows.
|
||||
- Import batch size defaults to 500 rows.
|
||||
- JSON import accepts object, array of objects, or array rows; mixed row shapes are rejected.
|
||||
- Empty CSV values become `NULL`.
|
||||
- Modes are append and truncate.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add file format table with parser behavior.
|
||||
- Add mapping rules and duplicate target column warning.
|
||||
- Add append/truncate mode safety callout.
|
||||
|
||||
- [x] **Step 3: Update SQL File and Database Export in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- SQL file execution supports preview, progress, cancellation, continue-on-error, semicolon-aware splitting, PostgreSQL dollar quotes, and SQL Server `GO` batches.
|
||||
- SQL file is unsupported for Redis, MongoDB, and Elasticsearch.
|
||||
- Export includes table DDL, data inserts, supported views/procedures/functions, selected table filtering, progress, and cancellation.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add SQL parser behavior table.
|
||||
- Add supported/unsupported database notes.
|
||||
- Add backup and import/export round-trip guidance.
|
||||
|
||||
- [x] **Step 4: Update AI Assistant in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- AI actions include generate, explain, optimize, fix, convert, and sample data.
|
||||
- Modes include Ask and Agent.
|
||||
- Schema context includes tables, columns, indexes, and foreign keys, with truncation behavior.
|
||||
- SQL execution policy auto-executes read statements only when agent intent is clear, confirms writes in uncertain or production-like contexts, and blocks dangerous statements.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add Ask vs Agent mode table.
|
||||
- Add SQL safety table.
|
||||
- Add schema context and `@table` mention guidance.
|
||||
|
||||
- [x] **Step 5: Update MCP in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- Tools include list connections, list tables, describe table, execute query, get schema context, add/remove connection, and desktop-only open table/execute-and-show.
|
||||
- MCP query execution returns max 100 rows.
|
||||
- MCP defaults to one statement and read-only SQL.
|
||||
- `DBX_MCP_ALLOW_WRITES` and `DBX_MCP_ALLOW_DANGEROUS_SQL` change safety behavior.
|
||||
- `DBX_WEB_URL` enables web mode.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Add tool table.
|
||||
- Add desktop vs web mode table.
|
||||
- Add safety environment variable section.
|
||||
|
||||
- [x] **Step 6: Mark this task complete in this plan**
|
||||
|
||||
Edit this file and check all Task 4 boxes after updates are complete.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Light Site-Wide Consistency Pass
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/content/docs/what-is-dbx.mdx`
|
||||
- Modify: `docs/content/docs/what-is-dbx.cn.mdx`
|
||||
- Review: `docs/content/docs/changelog.mdx`
|
||||
- Review: `docs/content/docs/changelog.cn.mdx`
|
||||
- Modify: any previously touched MDX page with broken cross-links or mismatched headings
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
|
||||
- [x] **Step 1: Update What Is DBX in both locales**
|
||||
|
||||
Use these facts:
|
||||
|
||||
- DBX has desktop and Docker/web deployment modes.
|
||||
- DBX includes SQL editing, data grid, schema tools, Redis/Mongo dedicated browsers, data movement tools, AI, MCP, plugins, and configuration migration.
|
||||
|
||||
Content requirements:
|
||||
|
||||
- Keep page concise.
|
||||
- Ensure feature list matches the revised core pages.
|
||||
- Keep screenshot reference intact unless broken.
|
||||
|
||||
- [x] **Step 2: Review changelog pages without inventing releases**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sed -n '1,60p' docs/content/docs/changelog.mdx
|
||||
sed -n '1,60p' docs/content/docs/changelog.cn.mdx
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- If only intro wording or links need consistency fixes, edit them.
|
||||
- Do not add unreleased entries.
|
||||
|
||||
- [x] **Step 3: Scan all MDX links for obvious locale mistakes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "\\](/docs/|\\](/en/docs/|\\](/cn/docs/" docs/content/docs -g '*.mdx'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- English docs use `/en/docs/...` or the established valid route.
|
||||
- Chinese docs use `/cn/docs/...`.
|
||||
- No accidentally mixed locale links in newly edited sections.
|
||||
|
||||
- [x] **Step 4: Mark this task complete in this plan**
|
||||
|
||||
Edit this file and check all Task 5 boxes after updates are complete.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify And Commit Documentation Changes
|
||||
|
||||
**Files:**
|
||||
- Verify: `docs/content/docs/*.mdx`
|
||||
- Verify: `docs/package.json`
|
||||
- Modify: `docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md`
|
||||
|
||||
- [x] **Step 1: Check bilingual page pairs exist**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dir = 'docs/content/docs';
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.mdx'));
|
||||
const base = new Set(files.filter((f) => !f.endsWith('.cn.mdx')).map((f) => f.replace(/\.mdx$/, '')));
|
||||
const cn = new Set(files.filter((f) => f.endsWith('.cn.mdx')).map((f) => f.replace(/\.cn\.mdx$/, '')));
|
||||
let ok = true;
|
||||
for (const name of base) {
|
||||
if (name === 'changelog') {}
|
||||
if (!cn.has(name) && name !== 'meta') {
|
||||
console.log(`Missing Chinese page for ${name}`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
for (const name of cn) {
|
||||
if (!base.has(name)) {
|
||||
console.log(`Missing English page for ${name}`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
process.exit(ok ? 0 : 1);
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected: exit code 0 and no missing page output.
|
||||
|
||||
- [x] **Step 2: Check English/Chinese heading parity for edited page pairs**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'NODE'
|
||||
const fs = require('fs');
|
||||
const pairs = [
|
||||
'what-is-dbx','getting-started','databases','query-editor','data-grid','schema-browser',
|
||||
'schema-diff','data-transfer','table-structure','field-lineage','table-import','sql-file',
|
||||
'database-export','ai-assistant','mcp','plugins','config-export','ssh-tunnel'
|
||||
];
|
||||
for (const slug of pairs) {
|
||||
const en = fs.readFileSync(`docs/content/docs/${slug}.mdx`, 'utf8').split('\n').filter((l) => /^#{2,4} /.test(l));
|
||||
const cn = fs.readFileSync(`docs/content/docs/${slug}.cn.mdx`, 'utf8').split('\n').filter((l) => /^#{2,4} /.test(l));
|
||||
if (en.length !== cn.length) {
|
||||
console.log(`${slug}: heading count differs en=${en.length} cn=${cn.length}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
NODE
|
||||
```
|
||||
|
||||
Expected: no output. If output appears, inspect whether the mismatch is intentional. Fix unintentional mismatches.
|
||||
|
||||
- [x] **Step 3: Build the docs site**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --dir docs build
|
||||
```
|
||||
|
||||
Expected: Next/Fumadocs build completes successfully.
|
||||
|
||||
- [x] **Step 4: Review git diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- docs/content/docs docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Only documentation content and plan progress changed.
|
||||
- No generated cache, build output, or unrelated source changes are included.
|
||||
|
||||
- [x] **Step 5: Commit docs content changes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/content/docs docs/superpowers/plans/2026-05-17-docs-mdx-code-sync-plan.md
|
||||
git commit -m "docs: sync MDX content with implementation"
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Commit succeeds with a Conventional Commit message.
|
||||
|
||||
- [x] **Step 6: Mark this task complete in this plan**
|
||||
|
||||
If the plan file is already committed before this checkbox is marked, leave the completion status in the final response instead of making a follow-up commit only for this checkbox.
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
# DBX Docs MDX Code Sync Design
|
||||
|
||||
## Purpose
|
||||
|
||||
The static documentation site should describe DBX as it actually works in this repository. The current Fumadocs MDX pages already cover the main product areas, but several pages read as broad feature introductions and do not consistently reflect the concrete implementation details in the Vue app, Rust core, web backend, and MCP package.
|
||||
|
||||
This work updates both English and Simplified Chinese MDX pages together so the two locales stay aligned.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
Use repository code as the primary source of documentation facts:
|
||||
|
||||
- `src/types/database.ts` for database type names and shared frontend models.
|
||||
- `src/components/connection/ConnectionDialog.vue` for connection profiles, default ports, file-based database handling, SSH/proxy availability, and user-visible connection fields.
|
||||
- `src/lib/databaseCapabilitySets.ts` for per-feature database support boundaries.
|
||||
- `src/lib/api.ts`, `src/lib/tauri.ts`, and `src/lib/http.ts` for desktop/web API parity.
|
||||
- `crates/dbx-core/src/*.rs` for SQL execution, SQL file execution, table import, data transfer, database export, schema operations, plugin handling, AI, Redis, and MongoDB behavior.
|
||||
- `src/components/*Dialog.vue` and `src/lib/*.ts` for user-facing workflows, previews, progress states, and safety decisions.
|
||||
- `mcp/src/*.ts` for MCP tools, desktop vs web mode behavior, SQL safety defaults, and connection storage integration.
|
||||
|
||||
Do not invent support claims that are not visible in code. If a behavior is implemented as best-effort or has database-specific limits, document the limit plainly.
|
||||
|
||||
## Scope
|
||||
|
||||
Deeply revise these core pages in both locales:
|
||||
|
||||
- `getting-started`
|
||||
- `databases`
|
||||
- `query-editor`
|
||||
- `data-grid`
|
||||
- `schema-browser`
|
||||
- `schema-diff`
|
||||
- `data-transfer`
|
||||
- `table-import`
|
||||
- `sql-file`
|
||||
- `database-export`
|
||||
- `ai-assistant`
|
||||
- `mcp`
|
||||
- `plugins`
|
||||
|
||||
Lightly revise these pages for consistency, links, and missing boundaries:
|
||||
|
||||
- `what-is-dbx`
|
||||
- `table-structure`
|
||||
- `field-lineage`
|
||||
- `config-export`
|
||||
- `ssh-tunnel`
|
||||
|
||||
Leave `changelog` content source intact. Only adjust introductory wording, formatting, or links if needed.
|
||||
|
||||
## Content Design
|
||||
|
||||
Each revised page should answer four questions:
|
||||
|
||||
1. What task does this page help the user complete?
|
||||
2. Which DBX implementation details matter for that task?
|
||||
3. What are the supported database types, file types, modes, or safety boundaries?
|
||||
4. Where should the user go next?
|
||||
|
||||
Use existing Fumadocs components only:
|
||||
|
||||
- `Callout` for warnings, implementation notes, and security boundaries.
|
||||
- `Steps` for workflows.
|
||||
- `Tabs` for platform or mode differences.
|
||||
- `Cards` for related pages.
|
||||
- `Accordion` only where it reduces scanning burden.
|
||||
|
||||
Keep the documentation practical. Prefer concrete workflows, tables, and limits over marketing copy.
|
||||
|
||||
## Key Facts To Reflect
|
||||
|
||||
Database support:
|
||||
|
||||
- DBX has explicit frontend and Rust `DatabaseType` variants for MySQL, PostgreSQL, SQLite, Redis, DuckDB, ClickHouse, SQL Server, MongoDB, Oracle, Elasticsearch, Doris, StarRocks, Redshift, Dameng, GaussDB, KingBase, HighGo, Vastbase, GoldenDB, Access, H2, Snowflake, Trino, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, TDengine, and JDBC.
|
||||
- Connection profiles include MySQL-compatible and PostgreSQL-compatible options that map onto shared driver types.
|
||||
- Some database types are native, some are compatibility profiles, some are Agent/JDBC-oriented, and feature availability varies by capability set.
|
||||
|
||||
Feature boundaries:
|
||||
|
||||
- Table import supports CSV, TSV, JSON, XLSX/XLSM/XLS files, previews the first rows, maps columns, imports in batches, and supports append or truncate mode.
|
||||
- SQL file execution splits SQL safely across comments, quoted strings, dollar quotes, and SQL Server `GO` batches; it reports progress and can continue after errors when configured.
|
||||
- Database export writes SQL files with table DDL, data inserts, and supported views/procedures/functions where available; export can be cancelled and supports selected-table filtering.
|
||||
- Data transfer supports append, overwrite, and upsert-oriented modes in code, optional target table creation, batching, progress, and cancellation.
|
||||
- AI assistant has Ask and Agent modes, schema context truncation behavior, SQL extraction, stream cancellation, and conservative SQL safety rules.
|
||||
- MCP defaults to one statement per query and read-only SQL execution unless environment variables explicitly allow writes or dangerous SQL; desktop-only tools require DBX desktop to be running, while web mode uses `DBX_WEB_URL`.
|
||||
- Redis and MongoDB have dedicated APIs and browser experiences rather than generic SQL table flows.
|
||||
|
||||
Safety:
|
||||
|
||||
- Generated or previewed SQL should be reviewed before execution.
|
||||
- Destructive operations such as DROP, TRUNCATE, ALTER, DELETE, and broad UPDATE need explicit caution.
|
||||
- Production-like connection names or hosts should be treated conservatively in AI/agent flows.
|
||||
- Import, transfer, SQL file execution, database export, and schema diff can change or expose significant data, so docs should tell users where review/cancel/backup steps exist.
|
||||
|
||||
## Bilingual Consistency
|
||||
|
||||
For every content change:
|
||||
|
||||
- Update `.mdx` and `.cn.mdx` in the same pass.
|
||||
- Keep heading structure, component structure, and cross-links equivalent.
|
||||
- Use natural English and natural Simplified Chinese rather than literal translation.
|
||||
- Preserve locale-specific links, for example `/en/docs/...` in English and `/cn/docs/...` in Chinese.
|
||||
|
||||
## Verification
|
||||
|
||||
After editing:
|
||||
|
||||
- Run a file-level scan for mismatched headings or missing counterpart pages.
|
||||
- Run the docs build from `docs/` with the existing package manager.
|
||||
- If the docs build cannot run because dependencies are missing or the environment blocks it, report the exact command and failure.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not redesign the docs site UI.
|
||||
- Do not add a new documentation framework or MDX plugin.
|
||||
- Do not change application runtime behavior.
|
||||
- Do not fabricate release notes for changelog entries.
|
||||
File diff suppressed because it is too large
Load Diff
1580
mcp/pnpm-lock.yaml
1580
mcp/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +0,0 @@
|
|||
packages:
|
||||
- '.'
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- keytar
|
||||
12
package.json
12
package.json
|
|
@ -3,11 +3,19 @@
|
|||
"private": true,
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:tauri": "tauri dev",
|
||||
"dev:web": "vite --port 5173 --mode web",
|
||||
"dev:backend": "RUST_LOG=${RUST_LOG:-info} DBX_PASSWORD=${DBX_PASSWORD:-test} cargo watch -x 'run -p dbx-web'",
|
||||
"build:packages": "pnpm --filter @dbx-app/node-core build && pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build",
|
||||
"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/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",
|
||||
"publish:dry-run": "pnpm build:packages && pnpm pack:packages",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"check": "oxfmt --check \"src/**/*.{ts,vue}\" && pnpm lint && vue-tsc --noEmit && pnpm test",
|
||||
"lint": "oxlint --vue-plugin src",
|
||||
|
|
@ -79,7 +87,9 @@
|
|||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
"better-sqlite3",
|
||||
"esbuild",
|
||||
"keytar"
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
# DBX CLI
|
||||
|
||||
Command line interface for DBX database connections, schema inspection, safe queries, and prompt-ready schema context.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
```
|
||||
|
||||
Requires Node.js 22.13.0 or newer.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
dbx connections list --json
|
||||
dbx connections list --format csv
|
||||
dbx schema list local --json
|
||||
dbx schema describe local users --json
|
||||
dbx query local "select count(*) as total from users" --json
|
||||
dbx query local "select id, name from users" --format csv
|
||||
dbx query local "select * from users" --limit 50 --timeout 10s --json
|
||||
dbx query local --file ./query.sql --json
|
||||
dbx context local --tables users,orders
|
||||
dbx open local users
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `dbx doctor` | Show local DBX config and desktop bridge diagnostics |
|
||||
| `dbx capabilities` | Show direct-query and desktop-bridge database support |
|
||||
| `dbx connections list` | List DBX connections without printing secrets |
|
||||
| `dbx schema list <connection>` | List tables and views |
|
||||
| `dbx schema describe <connection> <table>` | Show table columns |
|
||||
| `dbx query <connection> <sql>` | Execute one SQL statement |
|
||||
| `dbx query <connection> --file ./query.sql` | Execute SQL from a file |
|
||||
| `dbx context <connection>` | Print compact schema context for prompts |
|
||||
| `dbx open <connection> <table>` | Open a table in DBX Desktop |
|
||||
|
||||
## Output
|
||||
|
||||
Use `--json` or `--format json` for stable machine-readable output. Use `--format csv` for query, connection, and schema data that should be piped into other command line tools.
|
||||
|
||||
Errors are written to stderr and return a non-zero exit code.
|
||||
|
||||
## Query Controls
|
||||
|
||||
`dbx query` is read-only by default.
|
||||
|
||||
Use `--limit <n>` to control returned query rows and `--timeout <duration>` to control query timeout. Durations accept `ms`, `s`, or `m`, such as `500ms`, `10s`, or `1m`.
|
||||
|
||||
Use `--allow-writes` for non-dangerous write statements. Dangerous SQL such as `DROP`, `TRUNCATE`, and `ALTER` requires both `--allow-writes` and `--allow-dangerous-sql`.
|
||||
|
||||
For SQL that starts with a dash, pass `--` before the SQL:
|
||||
|
||||
```bash
|
||||
dbx query local --json -- "-- comment
|
||||
select 1"
|
||||
```
|
||||
|
||||
## Default Connection
|
||||
|
||||
Set `DBX_CONNECTION` to omit the connection name for query and context commands:
|
||||
|
||||
```bash
|
||||
DBX_CONNECTION=local dbx query "select 1" --json
|
||||
DBX_CONNECTION=local dbx context --tables users,orders
|
||||
```
|
||||
|
||||
## Desktop App Requirements
|
||||
|
||||
Some CLI commands can run without DBX Desktop:
|
||||
|
||||
- `connections list`
|
||||
- `schema list`
|
||||
- `schema describe`
|
||||
- `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`.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @dbx-app/cli
|
||||
npm install -g @dbx-app/cli
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
CLI JSON errors use stable codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `UNKNOWN_OPTION` | An unsupported flag was provided |
|
||||
| `INVALID_OPTION` | A flag is missing a value or has an invalid value |
|
||||
| `INVALID_ARGUMENT` | Positional arguments are missing or conflicting |
|
||||
| `CONNECTION_STORE_ERROR` | DBX connection storage exists but could not be read |
|
||||
| `CONNECTION_NOT_FOUND` | No DBX connection matched the requested name |
|
||||
| `SQL_BLOCKED` | SQL safety rules blocked execution |
|
||||
| `DBX_NOT_RUNNING` | DBX Desktop bridge is unavailable |
|
||||
| `ERROR` | Unexpected runtime failure |
|
||||
|
||||
## Codex
|
||||
|
||||
Codex can call the CLI directly from shell tools:
|
||||
|
||||
```bash
|
||||
dbx schema describe local users --json
|
||||
dbx context local --tables users,orders | codex exec "Write a retention query"
|
||||
```
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@dbx-app/cli",
|
||||
"version": "0.4.2",
|
||||
"description": "Command line interface for DBX database connections, schema, and safe queries",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"bin": {
|
||||
"dbx": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "tsx src/cli.ts",
|
||||
"test": "pnpm --filter @dbx-app/node-core build && tsx --test tests/*.test.ts",
|
||||
"build": "pnpm --filter @dbx-app/node-core build && tsc",
|
||||
"prepublishOnly": "tsc"
|
||||
},
|
||||
"keywords": [
|
||||
"cli",
|
||||
"database",
|
||||
"dbx",
|
||||
"codex",
|
||||
"terminal",
|
||||
"automation",
|
||||
"postgresql",
|
||||
"mysql",
|
||||
"ai-agent"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/t8y2/dbx",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"homepage": "https://github.com/t8y2/dbx/tree/main/packages/cli",
|
||||
"dependencies": {
|
||||
"@dbx-app/node-core": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.21",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { ConnectionConfig } from "@dbx-app/node-core";
|
||||
|
||||
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 mdTable(headers: string[], rows: string[][]): string {
|
||||
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3));
|
||||
const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`;
|
||||
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
|
||||
const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n");
|
||||
return body ? `${header}\n${sep}\n${body}` : `${header}\n${sep}`;
|
||||
}
|
||||
|
||||
export function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
buildSchemaContext,
|
||||
createBackend,
|
||||
DIRECT_QUERY_TYPES,
|
||||
BRIDGE_REQUIRED_TYPES,
|
||||
evaluateSqlSafety,
|
||||
formatSchemaContext,
|
||||
getDbxDiagnostics,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
type Backend,
|
||||
type DbxDiagnostics,
|
||||
type SqlSafetyOptions,
|
||||
} from "@dbx-app/node-core";
|
||||
import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "./cli-format.js";
|
||||
|
||||
export interface CliResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
backend?: Backend;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
diagnostics?: () => Promise<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;
|
||||
|
||||
try {
|
||||
const flags = parseFlags(argv);
|
||||
const args = flags.args;
|
||||
|
||||
if (flags.version) {
|
||||
return ok(`${await packageVersion()}\n`);
|
||||
}
|
||||
|
||||
if (args.length === 0 || flags.help || args[0] === "help") {
|
||||
return ok(`${usage()}\n`);
|
||||
}
|
||||
|
||||
const backend = options.backend ?? (await createBackend(env));
|
||||
|
||||
if (args[0] === "doctor") {
|
||||
ensureArgCount(args, 1, "dbx doctor");
|
||||
const diagnostics = await (options.diagnostics ?? getDbxDiagnostics)();
|
||||
if (flags.format === "json") return okJson(diagnostics);
|
||||
if (flags.format === "csv") {
|
||||
return ok(
|
||||
csvTable(["check", "value"], [
|
||||
{ check: "appDataDir", value: diagnostics.appDataDir },
|
||||
{ check: "dbPath", value: diagnostics.dbPath },
|
||||
{ check: "dbPathExists", value: diagnostics.dbPathExists },
|
||||
{ check: "connectionsTableExists", value: diagnostics.connectionsTableExists },
|
||||
{ check: "connectionRowCount", value: diagnostics.connectionRowCount },
|
||||
{ check: "loadConnectionsOk", value: diagnostics.loadConnectionsOk },
|
||||
{ check: "loadedConnectionCount", value: diagnostics.loadedConnectionCount },
|
||||
{ check: "loadConnectionsError", value: diagnostics.loadConnectionsError ?? "" },
|
||||
{ check: "loadConnectionsHint", value: diagnostics.loadConnectionsHint ?? "" },
|
||||
{ check: "bridgePortFile", value: diagnostics.bridgePortFile },
|
||||
{ check: "bridgePortFileExists", value: diagnostics.bridgePortFileExists },
|
||||
{ check: "bridgeUrl", value: diagnostics.bridgeUrl ?? "" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
return ok(formatDoctor(diagnostics));
|
||||
}
|
||||
|
||||
if (args[0] === "capabilities") {
|
||||
ensureArgCount(args, 1, "dbx capabilities");
|
||||
const payload = {
|
||||
directQueryTypes: [...DIRECT_QUERY_TYPES],
|
||||
bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES],
|
||||
};
|
||||
if (flags.format === "json") return okJson(payload);
|
||||
if (flags.format === "csv") {
|
||||
return ok(
|
||||
csvTable(
|
||||
["mode", "type"],
|
||||
[
|
||||
...payload.directQueryTypes.map((type) => ({ mode: "direct", type })),
|
||||
...payload.bridgeRequiredTypes.map((type) => ({ mode: "bridge", type })),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok(`${mdTable(["Mode", "Types"], [["Direct", payload.directQueryTypes.join(", ")], ["Requires DBX Desktop", payload.bridgeRequiredTypes.join(", ")]])}\n`);
|
||||
}
|
||||
|
||||
if (args[0] === "connections" && args[1] === "list") {
|
||||
ensureArgCount(args, 2, "dbx connections list");
|
||||
const connections = (await backend.loadConnections()).map(connectionSummary);
|
||||
if (flags.format === "json") return okJson({ connections });
|
||||
if (flags.format === "csv") return ok(csvTable(["name", "type", "host", "port", "database"], connections));
|
||||
return ok(
|
||||
`${mdTable(
|
||||
["Name", "Type", "Host", "Port", "Database"],
|
||||
connections.map((c) => [c.name, c.type, c.host, String(c.port), c.database ?? ""]),
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "schema" && args[1] === "list") {
|
||||
ensureArgCount(args, 3, "dbx schema list");
|
||||
const connectionName = required(args[2], "Connection name is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const tables = await backend.listTables(config, flags.schema);
|
||||
if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, tables });
|
||||
if (flags.format === "csv") return ok(csvTable(["name", "type"], tables));
|
||||
return ok(`${mdTable(["Table", "Type"], tables.map((t) => [t.name, t.type]))}\n`);
|
||||
}
|
||||
|
||||
if (args[0] === "schema" && args[1] === "describe") {
|
||||
ensureArgCount(args, 4, "dbx schema describe");
|
||||
const connectionName = required(args[2], "Connection name is required.");
|
||||
const table = required(args[3], "Table name is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const columns = await backend.describeTable(config, table, flags.schema);
|
||||
if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, table, columns });
|
||||
if (flags.format === "csv") {
|
||||
return ok(csvTable(["name", "data_type", "is_nullable", "is_primary_key", "column_default", "comment"], columns));
|
||||
}
|
||||
return ok(
|
||||
`${mdTable(
|
||||
["Column", "Type", "Nullable", "Default", "Comment"],
|
||||
columns.map((c) => [
|
||||
c.is_primary_key ? `${c.name} (PK)` : c.name,
|
||||
c.data_type,
|
||||
c.is_nullable ? "YES" : "NO",
|
||||
c.column_default ?? "",
|
||||
c.comment ?? "",
|
||||
]),
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "query") {
|
||||
const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === (flags.file ? 1 : 2);
|
||||
ensureArgCount(args, usesDefaultConnection ? (flags.file ? 1 : 2) : flags.file ? 2 : 3, "dbx query");
|
||||
const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required.");
|
||||
if (flags.file && args[2]) {
|
||||
throw new CliError("INVALID_ARGUMENT", "Provide SQL either inline or with --file, not both.");
|
||||
}
|
||||
const sqlArg = usesDefaultConnection ? args[1] : args[2];
|
||||
const sql = flags.file ? await readFile(flags.file, "utf-8") : required(sqlArg, "SQL string or --file is required.");
|
||||
const envSafety = sqlSafetyFromEnv(env);
|
||||
if (flags.allowDangerous && !flags.allowWrites && !envSafety.allowWrites) {
|
||||
throw new CliError("INVALID_OPTION", "--allow-dangerous-sql requires --allow-writes.");
|
||||
}
|
||||
const safetyOptions: SqlSafetyOptions = {
|
||||
allowWrites: flags.allowWrites || envSafety.allowWrites,
|
||||
allowDangerous: flags.allowDangerous || envSafety.allowDangerous,
|
||||
};
|
||||
const safety = evaluateSqlSafety(sql, safetyOptions);
|
||||
if (!safety.allowed) return fail("SQL_BLOCKED", safety.reason ?? "SQL blocked.", flags.json);
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const result = await backend.executeQuery(config, sql, { maxRows: flags.maxRows, timeoutMs: flags.timeoutMs });
|
||||
if (flags.format === "json") {
|
||||
return okJson({ connection: connectionName, columns: result.columns, rows: result.rows, row_count: result.row_count });
|
||||
}
|
||||
if (flags.format === "csv") return ok(csvTable(result.columns, result.rows));
|
||||
if (result.columns.length === 0) return ok(`Query executed. ${result.row_count} row(s) affected.\n`);
|
||||
return ok(
|
||||
`${mdTable(
|
||||
result.columns,
|
||||
result.rows.map((row) => result.columns.map((column) => formatCell(row[column]))),
|
||||
)}\n\n${result.row_count} row(s)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args[0] === "context") {
|
||||
const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === 1;
|
||||
ensureArgCount(args, usesDefaultConnection ? 1 : 2, "dbx context");
|
||||
const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required.");
|
||||
const config = await findConnectionOrThrow(backend, connectionName);
|
||||
const context = await buildSchemaContext(backend, config, {
|
||||
schema: flags.schema,
|
||||
tables: flags.tables,
|
||||
maxTables: flags.maxTables,
|
||||
});
|
||||
if (flags.format === "json") return okJson(context);
|
||||
if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx context.");
|
||||
return ok(`${formatSchemaContext(context)}\n`);
|
||||
}
|
||||
|
||||
if (args[0] === "open") {
|
||||
ensureArgCount(args, 3, "dbx open");
|
||||
const connectionName = required(args[1], "Connection name is required.");
|
||||
const table = required(args[2], "Table name is required.");
|
||||
const response = await postBridge("/open-table", {
|
||||
connection_name: connectionName,
|
||||
table,
|
||||
schema: flags.schema,
|
||||
database: flags.database,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return fail("DBX_NOT_RUNNING", response.text || "DBX is not running. Please start DBX first.", flags.json);
|
||||
}
|
||||
if (flags.format === "json") return okJson({ opened: true, connection: connectionName, table, schema: flags.schema, database: flags.database });
|
||||
if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx open.");
|
||||
return ok(`Opened ${table} in DBX\n`);
|
||||
}
|
||||
|
||||
return fail("USAGE", usage(), flags.json);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code =
|
||||
error instanceof CliError
|
||||
? error.code
|
||||
: typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
||||
? error.code
|
||||
: "ERROR";
|
||||
const wantsJson = argv.includes("--json");
|
||||
return fail(code, message, wantsJson);
|
||||
}
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): ParsedFlags {
|
||||
const args: string[] = [];
|
||||
const flags: ParsedFlags = {
|
||||
args,
|
||||
json: false,
|
||||
format: "table",
|
||||
allowWrites: false,
|
||||
allowDangerous: false,
|
||||
help: false,
|
||||
version: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--") {
|
||||
args.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
flags.json = true;
|
||||
flags.format = "json";
|
||||
}
|
||||
else if (arg === "--format") flags.format = parseFormat(readOptionValue(argv, ++i, "--format"));
|
||||
else if (arg === "--help" || arg === "-h") flags.help = true;
|
||||
else if (arg === "--version" || arg === "-V") flags.version = true;
|
||||
else if (arg === "--schema") flags.schema = readOptionValue(argv, ++i, "--schema");
|
||||
else if (arg === "--database") flags.database = readOptionValue(argv, ++i, "--database");
|
||||
else if (arg === "--tables") flags.tables = splitCsv(readOptionValue(argv, ++i, "--tables"));
|
||||
else if (arg === "--max-tables") flags.maxTables = parsePositiveInt(readOptionValue(argv, ++i, "--max-tables"), "--max-tables");
|
||||
else if (arg === "--limit") flags.maxRows = parsePositiveInt(readOptionValue(argv, ++i, "--limit"), "--limit");
|
||||
else if (arg === "--timeout") flags.timeoutMs = parseDurationMs(readOptionValue(argv, ++i, "--timeout"), "--timeout");
|
||||
else if (arg === "--file") flags.file = readOptionValue(argv, ++i, "--file");
|
||||
else if (arg === "--allow-writes") flags.allowWrites = true;
|
||||
else if (arg === "--allow-dangerous-sql") flags.allowDangerous = true;
|
||||
else if (arg.startsWith("-")) throw new CliError("UNKNOWN_OPTION", `Unknown option: ${arg}`);
|
||||
else args.push(arg);
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function parseFormat(value: string): "table" | "json" | "csv" {
|
||||
if (value === "table" || value === "json" || value === "csv") return value;
|
||||
throw new CliError("INVALID_OPTION", "--format must be one of: table, json, csv.");
|
||||
}
|
||||
|
||||
function ensureArgCount(args: string[], count: number, command: string): void {
|
||||
if (args.length !== count) {
|
||||
throw new CliError("INVALID_ARGUMENT", `${command} expects ${count - 1} argument(s); received ${args.length - 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionValue(argv: string[], index: number, option: string): string {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new CliError("INVALID_OPTION", `${option} requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, option: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new CliError("INVALID_OPTION", `${option} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseDurationMs(value: string, option: string): number {
|
||||
const match = value.match(/^(\d+)(ms|s|m)?$/);
|
||||
if (!match) {
|
||||
throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`);
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isInteger(amount) || amount < 1) {
|
||||
throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`);
|
||||
}
|
||||
const unit = match[2] ?? "ms";
|
||||
if (unit === "ms") return amount;
|
||||
if (unit === "s") return amount * 1000;
|
||||
return amount * 60_000;
|
||||
}
|
||||
|
||||
function splitCsv(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function findConnectionOrThrow(backend: Backend, name: string) {
|
||||
const config = await backend.findConnection(name);
|
||||
if (!config) throw new CliError("CONNECTION_NOT_FOUND", `Connection "${name}" not found.`);
|
||||
return config;
|
||||
}
|
||||
|
||||
function required(value: string | undefined, message: string): string {
|
||||
if (!value) throw new Error(message);
|
||||
return value;
|
||||
}
|
||||
|
||||
function ok(stdout: string): CliResult {
|
||||
return { exitCode: 0, stdout, stderr: "" };
|
||||
}
|
||||
|
||||
function okJson(payload: unknown): CliResult {
|
||||
return ok(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function fail(code: string, message: string, json: boolean): CliResult {
|
||||
const text = json ? `${JSON.stringify(errorPayload(code, message), null, 2)}\n` : `${formatErrorMessage(code, message)}\n`;
|
||||
return { exitCode: 1, stdout: "", stderr: text };
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" dbx doctor [--json]",
|
||||
" dbx capabilities [--json]",
|
||||
" dbx connections list [--json]",
|
||||
" dbx schema list <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 (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ConnectionConfig } from "@dbx-app/node-core";
|
||||
import { connectionSummary, csvTable, errorPayload, 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("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');
|
||||
});
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
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 "node:test";
|
||||
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"],
|
||||
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("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.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");
|
||||
});
|
||||
|
|
@ -48,7 +48,7 @@ Or for development (from source):
|
|||
"mcpServers": {
|
||||
"dbx": {
|
||||
"command": "npx",
|
||||
"args": ["tsx", "mcp/src/index.ts"],
|
||||
"args": ["tsx", "packages/mcp-server/src/index.ts"],
|
||||
"cwd": "/path/to/dbx"
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,18 @@ In Claude Code, just ask:
|
|||
- "Query the average salary from employees"
|
||||
- "Open the orders table in DBX"
|
||||
|
||||
## CLI
|
||||
|
||||
For terminal, script, and Codex workflows, install the dedicated CLI package:
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
See the [DBX CLI README](../cli/README.md) for command details.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|
|
@ -174,6 +186,18 @@ npx @dbx-app/mcp-server
|
|||
- "查询最近 7 天的订单数量"
|
||||
- "打开 orders 表"
|
||||
|
||||
### CLI
|
||||
|
||||
终端、脚本和 Codex 工作流请安装独立 CLI 包:
|
||||
|
||||
```bash
|
||||
npm install -g @dbx-app/cli
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
命令详情见 [DBX CLI README](../cli/README.md)。
|
||||
|
||||
### 工具列表
|
||||
|
||||
| 工具 | 说明 |
|
||||
|
|
@ -4,16 +4,21 @@
|
|||
"mcpName": "io.github.t8y2/dbx",
|
||||
"description": "MCP server for DBX — query databases from Claude Code, Cursor, and other AI agents",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server": "dist/index.js",
|
||||
"dbx-mcp-server": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"server.json"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"build": "tsc",
|
||||
"test": "pnpm --filter @dbx-app/node-core build && tsx --test tests/*.test.ts",
|
||||
"build": "pnpm --filter @dbx-app/node-core build && tsc",
|
||||
"prepublishOnly": "tsc"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
@ -29,28 +34,17 @@
|
|||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/t8y2/dbx",
|
||||
"directory": "mcp"
|
||||
"directory": "packages/mcp-server"
|
||||
},
|
||||
"homepage": "https://github.com/t8y2/dbx/tree/main/mcp",
|
||||
"homepage": "https://github.com/t8y2/dbx/tree/main/packages/mcp-server",
|
||||
"dependencies": {
|
||||
"@dbx-app/node-core": "workspace:^",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"keytar": "^7.9.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"pg": "^8.16.0",
|
||||
"zod": "^3.25.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +1,19 @@
|
|||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { z } from "zod";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { homedir, platform } from "node:os";
|
||||
import {
|
||||
loadConnections as desktopLoadConnections,
|
||||
findConnection as desktopFindConnection,
|
||||
addConnection as desktopAddConnection,
|
||||
removeConnection as desktopRemoveConnection,
|
||||
} from "./connections.js";
|
||||
import {
|
||||
listTables as desktopListTables,
|
||||
describeTable as desktopDescribeTable,
|
||||
executeQuery as desktopExecuteQuery,
|
||||
} from "./database.js";
|
||||
import type { ConnectionConfig } from "./connections.js";
|
||||
import type { TableInfo, ColumnInfo, QueryResult } from "./database.js";
|
||||
import { buildSchemaContext, formatSchemaContext } from "./schema-context.js";
|
||||
import { evaluateSqlSafety, sqlSafetyFromEnv } from "./sql-safety.js";
|
||||
|
||||
const isWebMode = !!process.env.DBX_WEB_URL;
|
||||
|
||||
interface Backend {
|
||||
loadConnections(): Promise<ConnectionConfig[]>;
|
||||
findConnection(name: string): Promise<ConnectionConfig | undefined>;
|
||||
addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig>;
|
||||
removeConnection(name: string): Promise<boolean>;
|
||||
listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
|
||||
describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
|
||||
executeQuery(config: ConnectionConfig, sql: string): Promise<QueryResult>;
|
||||
}
|
||||
|
||||
let backend: Backend;
|
||||
if (isWebMode) {
|
||||
const web = await import("./web-backend.js");
|
||||
backend = web;
|
||||
} else {
|
||||
backend = {
|
||||
loadConnections: desktopLoadConnections,
|
||||
findConnection: desktopFindConnection,
|
||||
addConnection: desktopAddConnection,
|
||||
removeConnection: desktopRemoveConnection,
|
||||
listTables: desktopListTables,
|
||||
describeTable: desktopDescribeTable,
|
||||
executeQuery: desktopExecuteQuery,
|
||||
};
|
||||
}
|
||||
buildSchemaContext,
|
||||
createBackend,
|
||||
evaluateSqlSafety,
|
||||
formatSchemaContext,
|
||||
notifyReload,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
type Backend,
|
||||
type ConnectionConfig,
|
||||
} from "@dbx-app/node-core";
|
||||
|
||||
function text(s: string) {
|
||||
return { content: [{ type: "text" as const, text: s }] };
|
||||
|
|
@ -61,19 +27,21 @@ function mdTable(headers: string[], rows: string[][]): string {
|
|||
return `${header}\n${sep}\n${body}`;
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "dbx",
|
||||
version: "0.3.0",
|
||||
});
|
||||
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
|
||||
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
|
||||
const server = new McpServer({
|
||||
name: "dbx",
|
||||
version: "0.4.2",
|
||||
});
|
||||
|
||||
server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
|
||||
server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
|
||||
const connections = await backend.loadConnections();
|
||||
if (connections.length === 0) return text("No connections configured in DBX.");
|
||||
const rows = connections.map((c) => [c.name, c.db_type, c.host, String(c.port), c.database || ""]);
|
||||
return text(mdTable(["Name", "Type", "Host", "Port", "Database"], rows));
|
||||
});
|
||||
});
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_list_tables",
|
||||
"List tables and views for a database connection",
|
||||
{
|
||||
|
|
@ -88,9 +56,9 @@ server.tool(
|
|||
const rows = tables.map((t) => [t.name, t.type]);
|
||||
return text(mdTable(["Table", "Type"], rows));
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_describe_table",
|
||||
"Get column definitions for a table",
|
||||
{
|
||||
|
|
@ -112,9 +80,9 @@ server.tool(
|
|||
]);
|
||||
return text(mdTable(["Column", "Type", "Nullable", "Default", "Comment"], rows));
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_execute_query",
|
||||
"Execute a SQL query on a database connection (max 100 rows returned)",
|
||||
{
|
||||
|
|
@ -136,9 +104,9 @@ server.tool(
|
|||
return text(`Query error: ${msg}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_get_schema_context",
|
||||
"Get compact table and column context for writing SQL",
|
||||
{
|
||||
|
|
@ -154,9 +122,9 @@ server.tool(
|
|||
if (context.tables.length === 0) return text("No matching tables found.");
|
||||
return text(formatSchemaContext(context));
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_add_connection",
|
||||
"Add a new database connection to DBX",
|
||||
{
|
||||
|
|
@ -193,9 +161,9 @@ server.tool(
|
|||
await notifyReload();
|
||||
return text(`Connection "${config.name}" added (id: ${config.id}).`);
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_remove_connection",
|
||||
"Remove a database connection from DBX",
|
||||
{
|
||||
|
|
@ -207,42 +175,11 @@ server.tool(
|
|||
await notifyReload();
|
||||
return text(`Connection "${connection_name}" removed.`);
|
||||
},
|
||||
);
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function appDataDir(): string {
|
||||
const home = homedir();
|
||||
switch (platform()) {
|
||||
case "darwin":
|
||||
return join(home, "Library", "Application Support", "com.dbx.app");
|
||||
case "win32":
|
||||
return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "com.dbx.app");
|
||||
default:
|
||||
return join(home, ".config", "com.dbx.app");
|
||||
}
|
||||
}
|
||||
|
||||
async function getBridgeUrl(): Promise<string> {
|
||||
const portFile = join(appDataDir(), "mcp-bridge-port");
|
||||
const port = (await readFile(portFile, "utf-8")).trim();
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
async function notifyReload(): Promise<void> {
|
||||
try {
|
||||
const bridgeUrl = await getBridgeUrl();
|
||||
await fetch(`${bridgeUrl}/reload-connections`, { method: "POST" });
|
||||
} catch {}
|
||||
}
|
||||
);
|
||||
|
||||
// Desktop-only tools: open table and execute-and-show require the Tauri bridge
|
||||
if (!isWebMode) {
|
||||
server.tool(
|
||||
if (!isWebMode) {
|
||||
server.tool(
|
||||
"dbx_open_table",
|
||||
"Open a table in DBX desktop app UI. Requires DBX to be running.",
|
||||
{
|
||||
|
|
@ -254,9 +191,9 @@ if (!isWebMode) {
|
|||
async ({ connection_name, table, database, schema }) => {
|
||||
return bridgeRequest("/open-table", { connection_name, table, database, schema }, `Opened ${table} in DBX`);
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.tool(
|
||||
"dbx_execute_and_show",
|
||||
"Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running.",
|
||||
{
|
||||
|
|
@ -269,30 +206,34 @@ if (!isWebMode) {
|
|||
if (!safety.allowed) return text(`Query blocked: ${safety.reason}`);
|
||||
return bridgeRequest("/execute-query", { connection_name, sql, database }, "Query sent to DBX");
|
||||
},
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
async function bridgeRequest(path: string, body: Record<string, unknown>, successMsg: string) {
|
||||
try {
|
||||
const bridgeUrl = await getBridgeUrl();
|
||||
const res = await fetch(`${bridgeUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) return text(successMsg);
|
||||
return text(`Failed: ${await res.text()}`);
|
||||
} catch {
|
||||
return text("DBX is not running. Please start DBX first.");
|
||||
}
|
||||
const res = await postBridge(path, body);
|
||||
if (res.ok) return text(successMsg);
|
||||
return text(res.text.startsWith("DBX is not running") ? res.text : `Failed: ${res.text}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend = await createBackend();
|
||||
const server = createDbxMcpServer(backend);
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("MCP Server failed to start:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((e) => {
|
||||
console.error("MCP Server failed to start:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Backend, ConnectionConfig } from "@dbx-app/node-core";
|
||||
import { createDbxMcpServer } from "../src/index.js";
|
||||
|
||||
const connection: ConnectionConfig = {
|
||||
id: "1",
|
||||
name: "local",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "app",
|
||||
password: "",
|
||||
database: "demo",
|
||||
ssh_enabled: false,
|
||||
ssl: false,
|
||||
};
|
||||
|
||||
const backend: Backend = {
|
||||
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 }),
|
||||
};
|
||||
|
||||
test("creates an MCP server without starting stdio transport", () => {
|
||||
const server = createDbxMcpServer(backend, { isWebMode: true });
|
||||
|
||||
assert.equal(typeof server.connect, "function");
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# DBX Node Core
|
||||
|
||||
Shared Node.js runtime utilities for DBX CLI and DBX MCP Server.
|
||||
|
||||
This package reads DBX Desktop connection storage, redacts connection summaries, builds schema context, applies SQL safety rules, and executes supported direct database queries.
|
||||
|
||||
## Supported Runtime
|
||||
|
||||
Requires Node.js 22.13.0 or newer.
|
||||
|
||||
## Direct Query Support
|
||||
|
||||
Direct execution currently supports:
|
||||
|
||||
- PostgreSQL and Redshift
|
||||
- MySQL-compatible databases, including MySQL, Doris, and StarRocks
|
||||
- SQLite
|
||||
|
||||
Other DBX connection types can be routed through DBX Desktop bridge integrations used by the CLI and MCP server.
|
||||
|
||||
## Public Modules
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBackend,
|
||||
loadConnections,
|
||||
getDbxDiagnostics,
|
||||
evaluateSqlSafety,
|
||||
buildSchemaContext,
|
||||
} from "@dbx-app/node-core";
|
||||
```
|
||||
|
||||
The package is intended as a shared implementation layer for official DBX Node packages. Applications should prefer `@dbx-app/cli` for terminal workflows and `@dbx-app/mcp-server` for MCP clients.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "@dbx-app/node-core",
|
||||
"version": "0.4.2",
|
||||
"description": "Shared Node.js database and DBX connection utilities for DBX CLI and MCP server",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./backend": "./dist/backend.js",
|
||||
"./bridge": "./dist/bridge.js",
|
||||
"./connections": "./dist/connections.js",
|
||||
"./database": "./dist/database.js",
|
||||
"./diagnostics": "./dist/diagnostics.js",
|
||||
"./paths": "./dist/paths.js",
|
||||
"./schema-context": "./dist/schema-context.js",
|
||||
"./sql-safety": "./dist/sql-safety.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"build": "tsc",
|
||||
"prepublishOnly": "tsc"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"keytar": "^7.9.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"pg": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
addConnection as desktopAddConnection,
|
||||
findConnection as desktopFindConnection,
|
||||
loadConnections as desktopLoadConnections,
|
||||
removeConnection as desktopRemoveConnection,
|
||||
} from "./connections.js";
|
||||
import {
|
||||
describeTable as desktopDescribeTable,
|
||||
executeQuery as desktopExecuteQuery,
|
||||
listTables as desktopListTables,
|
||||
} from "./database.js";
|
||||
import type { ConnectionConfig } from "./connections.js";
|
||||
import type { ColumnInfo, QueryOptions, QueryResult, TableInfo } from "./database.js";
|
||||
|
||||
export interface Backend {
|
||||
loadConnections(): Promise<ConnectionConfig[]>;
|
||||
findConnection(name: string): Promise<ConnectionConfig | undefined>;
|
||||
addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig>;
|
||||
removeConnection(name: string): Promise<boolean>;
|
||||
listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
|
||||
describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
|
||||
executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult>;
|
||||
}
|
||||
|
||||
export async function createBackend(env: NodeJS.ProcessEnv = process.env): Promise<Backend> {
|
||||
if (env.DBX_WEB_URL) {
|
||||
return await import("./web-backend.js");
|
||||
}
|
||||
|
||||
return {
|
||||
loadConnections: desktopLoadConnections,
|
||||
findConnection: desktopFindConnection,
|
||||
addConnection: desktopAddConnection,
|
||||
removeConnection: desktopRemoveConnection,
|
||||
listTables: desktopListTables,
|
||||
describeTable: desktopDescribeTable,
|
||||
executeQuery: desktopExecuteQuery,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { bridgePortFilePath } from "./paths.js";
|
||||
|
||||
export async function getBridgeUrl(): Promise<string> {
|
||||
const port = (await readFile(bridgePortFilePath(), "utf-8")).trim();
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export async function postBridge(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: true; text: string } | { ok: false; text: string }> {
|
||||
try {
|
||||
const bridgeUrl = await getBridgeUrl();
|
||||
const res = await fetch(`${bridgeUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { ok: res.ok, text: res.ok ? "" : await res.text() };
|
||||
} catch {
|
||||
return { ok: false, text: "DBX is not running. Please start DBX first." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyReload(): Promise<void> {
|
||||
await postBridge("/reload-connections", {});
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { join } from "node:path";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import Database from "better-sqlite3";
|
||||
import { dbPath as defaultDbPath } from "./paths.js";
|
||||
|
||||
export interface ConnectionConfig {
|
||||
id: string;
|
||||
|
|
@ -24,6 +25,31 @@ export interface ConnectionConfig {
|
|||
ssl: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionStoreOptions {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionStoreDiagnostics {
|
||||
dbPath: string;
|
||||
dbPathExists: boolean;
|
||||
connectionsTableExists: boolean;
|
||||
connectionSecretsTableExists: boolean;
|
||||
connectionRowCount: number;
|
||||
loadConnectionsOk: boolean;
|
||||
loadedConnectionCount: number;
|
||||
loadConnectionsError?: string;
|
||||
}
|
||||
|
||||
export class ConnectionStoreError extends Error {
|
||||
readonly code = "CONNECTION_STORE_ERROR";
|
||||
|
||||
constructor(path: string, cause: unknown) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`Failed to load DBX connections from ${path}: ${message}`);
|
||||
this.name = "ConnectionStoreError";
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig {
|
||||
if (config.db_type === "mysql" && config.driver_profile?.toLowerCase() === "tdengine") {
|
||||
return {
|
||||
|
|
@ -43,21 +69,8 @@ export function canonicalizeConnection(config: ConnectionConfig): ConnectionConf
|
|||
return config;
|
||||
}
|
||||
|
||||
function appDataDir(): string {
|
||||
const home = homedir();
|
||||
switch (platform()) {
|
||||
case "darwin":
|
||||
return join(home, "Library", "Application Support", "com.dbx.app");
|
||||
case "win32":
|
||||
return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "com.dbx.app");
|
||||
default:
|
||||
return join(home, ".config", "com.dbx.app");
|
||||
}
|
||||
}
|
||||
|
||||
function openDb(readonly = false): Database.Database {
|
||||
const dbPath = join(appDataDir(), "dbx.db");
|
||||
return new Database(dbPath, { readonly });
|
||||
function openDb(readonly = false, path = defaultDbPath()): Database.Database {
|
||||
return new Database(path, { readonly });
|
||||
}
|
||||
|
||||
function getSecret(db: Database.Database, connectionId: string, key: string): string {
|
||||
|
|
@ -67,9 +80,13 @@ function getSecret(db: Database.Database, connectionId: string, key: string): st
|
|||
return row?.secret ?? "";
|
||||
}
|
||||
|
||||
export async function loadConnections(): Promise<ConnectionConfig[]> {
|
||||
export async function loadConnections(options: ConnectionStoreOptions = {}): Promise<ConnectionConfig[]> {
|
||||
const path = options.path ?? defaultDbPath();
|
||||
if (!existsSync(path)) return [];
|
||||
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
const db = openDb(true);
|
||||
db = openDb(true, path);
|
||||
const rows = db.prepare("SELECT id, config_json FROM connections").all() as { id: string; config_json: string }[];
|
||||
const configs: ConnectionConfig[] = [];
|
||||
|
||||
|
|
@ -81,13 +98,63 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
|
|||
configs.push(config);
|
||||
}
|
||||
|
||||
db.close();
|
||||
return configs;
|
||||
} catch {
|
||||
return [];
|
||||
} catch (error) {
|
||||
throw new ConnectionStoreError(path, error);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectConnectionStore(options: ConnectionStoreOptions = {}): Promise<ConnectionStoreDiagnostics> {
|
||||
const path = options.path ?? defaultDbPath();
|
||||
const diagnostics: ConnectionStoreDiagnostics = {
|
||||
dbPath: path,
|
||||
dbPathExists: existsSync(path),
|
||||
connectionsTableExists: false,
|
||||
connectionSecretsTableExists: false,
|
||||
connectionRowCount: 0,
|
||||
loadConnectionsOk: true,
|
||||
loadedConnectionCount: 0,
|
||||
};
|
||||
|
||||
if (!diagnostics.dbPathExists) return diagnostics;
|
||||
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
db = openDb(true, path);
|
||||
diagnostics.connectionsTableExists = tableExists(db, "connections");
|
||||
diagnostics.connectionSecretsTableExists = tableExists(db, "connection_secrets");
|
||||
if (diagnostics.connectionsTableExists) {
|
||||
const row = db.prepare("SELECT COUNT(*) AS count FROM connections").get() as { count: number };
|
||||
diagnostics.connectionRowCount = row.count;
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.loadConnectionsOk = false;
|
||||
diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error);
|
||||
return diagnostics;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const connections = await loadConnections({ path });
|
||||
diagnostics.loadedConnectionCount = connections.length;
|
||||
} catch (error) {
|
||||
diagnostics.loadConnectionsOk = false;
|
||||
diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function tableExists(db: Database.Database, name: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name) as { "1": number } | undefined;
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function findConnection(name: string): Promise<ConnectionConfig | undefined> {
|
||||
const connections = await loadConnections();
|
||||
return connections.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
||||
|
|
@ -26,6 +26,11 @@ export interface QueryResult {
|
|||
row_count: number;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
maxRows?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const MAX_ROWS = 100;
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const QUERY_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -307,8 +312,16 @@ async function bridgeDataRequest<T>(path: string, body: Record<string, unknown>)
|
|||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function convertBridgeQueryResult(result: BridgeQueryResult): QueryResult {
|
||||
const rows = result.rows.slice(0, MAX_ROWS).map((row) => {
|
||||
function resolveMaxRows(options?: QueryOptions): number {
|
||||
return options?.maxRows ?? MAX_ROWS;
|
||||
}
|
||||
|
||||
function resolveTimeoutMs(options?: QueryOptions): number {
|
||||
return options?.timeoutMs ?? QUERY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function convertBridgeQueryResult(result: BridgeQueryResult, options?: QueryOptions): QueryResult {
|
||||
const rows = result.rows.slice(0, resolveMaxRows(options)).map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||
return obj;
|
||||
|
|
@ -323,9 +336,10 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|||
});
|
||||
}
|
||||
|
||||
async function queryWithRetry(config: ConnectionConfig, fn: () => Promise<QueryResult>): Promise<QueryResult> {
|
||||
async function queryWithRetry(config: ConnectionConfig, fn: () => Promise<QueryResult>, options?: QueryOptions): Promise<QueryResult> {
|
||||
const timeoutMs = resolveTimeoutMs(options);
|
||||
try {
|
||||
return await withTimeout(fn(), QUERY_TIMEOUT_MS);
|
||||
return await withTimeout(fn(), timeoutMs);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const retriable = /terminating connection|Connection lost|ECONNRESET|EPIPE|connection refused/i.test(msg);
|
||||
|
|
@ -333,34 +347,34 @@ async function queryWithRetry(config: ConnectionConfig, fn: () => Promise<QueryR
|
|||
const key = poolKey(config);
|
||||
const entry = pools.get(key);
|
||||
if (entry) evictPool(key, entry);
|
||||
return withTimeout(fn(), QUERY_TIMEOUT_MS);
|
||||
return withTimeout(fn(), timeoutMs);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function pgQuery(config: ConnectionConfig, sql: string, params?: unknown[]): Promise<QueryResult> {
|
||||
async function pgQuery(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise<QueryResult> {
|
||||
return queryWithRetry(config, async () => {
|
||||
const pool = await getPgPool(config);
|
||||
const result = await pool.query(sql, params);
|
||||
const rows = (result.rows || []).slice(0, MAX_ROWS);
|
||||
const rows = (result.rows || []).slice(0, resolveMaxRows(options));
|
||||
return { columns: result.fields?.map((f) => f.name) ?? [], rows, row_count: rows.length };
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function mysqlQuery(config: ConnectionConfig, sql: string, params?: unknown[]): Promise<QueryResult> {
|
||||
async function mysqlQuery(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise<QueryResult> {
|
||||
return queryWithRetry(config, async () => {
|
||||
const pool = await getMysqlPool(config);
|
||||
const [results, fields] = await pool.query(sql, params);
|
||||
const rows = (Array.isArray(results) ? results : []).slice(0, MAX_ROWS) as Record<string, unknown>[];
|
||||
const rows = (Array.isArray(results) ? results : []).slice(0, resolveMaxRows(options)) as Record<string, unknown>[];
|
||||
return { columns: (fields as Array<{ name: string }>)?.map((f) => f.name) ?? [], rows, row_count: rows.length };
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function query(config: ConnectionConfig, sql: string, params?: unknown[]): Promise<QueryResult> {
|
||||
if (config.db_type === "sqlite") return sqliteQuery(config, sql);
|
||||
if (isMysqlType(config.db_type)) return mysqlQuery(config, sql, params);
|
||||
return pgQuery(config, sql, params);
|
||||
async function query(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise<QueryResult> {
|
||||
if (config.db_type === "sqlite") return sqliteQuery(config, sql, options);
|
||||
if (isMysqlType(config.db_type)) return mysqlQuery(config, sql, params, options);
|
||||
return pgQuery(config, sql, params, options);
|
||||
}
|
||||
|
||||
function sqlitePath(config: ConnectionConfig): string {
|
||||
|
|
@ -377,12 +391,12 @@ function quoteSqliteIdentifier(identifier: string): string {
|
|||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function sqliteQuery(config: ConnectionConfig, sql: string): QueryResult {
|
||||
function sqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): QueryResult {
|
||||
const db = new Database(sqlitePath(config), { readonly: !sqlSafetyFromEnv().allowWrites });
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
if (stmt.reader) {
|
||||
const rows = stmt.all().slice(0, MAX_ROWS) as Record<string, unknown>[];
|
||||
const rows = stmt.all().slice(0, resolveMaxRows(options)) as Record<string, unknown>[];
|
||||
return { columns: stmt.columns().map((column) => column.name), rows, row_count: rows.length };
|
||||
}
|
||||
const result = stmt.run();
|
||||
|
|
@ -392,16 +406,16 @@ function sqliteQuery(config: ConnectionConfig, sql: string): QueryResult {
|
|||
}
|
||||
}
|
||||
|
||||
export async function executeQuery(config: ConnectionConfig, sql: string): Promise<QueryResult> {
|
||||
export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
|
||||
if (isDirectType(config.db_type)) {
|
||||
return query(config, sql);
|
||||
return query(config, sql, undefined, options);
|
||||
}
|
||||
const result = await bridgeDataRequest<BridgeQueryResult>("/data/execute-query", {
|
||||
const result = await withTimeout(bridgeDataRequest<BridgeQueryResult>("/data/execute-query", {
|
||||
connection_name: config.name,
|
||||
database: config.database || "",
|
||||
sql,
|
||||
});
|
||||
return convertBridgeQueryResult(result);
|
||||
}), resolveTimeoutMs(options));
|
||||
return convertBridgeQueryResult(result, options);
|
||||
}
|
||||
|
||||
export async function listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]> {
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { access, readFile } from "node:fs/promises";
|
||||
import { bridgePortFilePath, dbPath, appDataDir } from "./paths.js";
|
||||
import { inspectConnectionStore } from "./connections.js";
|
||||
|
||||
export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite"] as const;
|
||||
|
||||
export const BRIDGE_REQUIRED_TYPES = [
|
||||
"redis",
|
||||
"mongodb",
|
||||
"duckdb",
|
||||
"clickhouse",
|
||||
"sqlserver",
|
||||
"oracle",
|
||||
"elasticsearch",
|
||||
"dameng",
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"goldendb",
|
||||
"gaussdb",
|
||||
"tdengine",
|
||||
"h2",
|
||||
"snowflake",
|
||||
"trino",
|
||||
"hive",
|
||||
"db2",
|
||||
"informix",
|
||||
"neo4j",
|
||||
"cassandra",
|
||||
"bigquery",
|
||||
"kylin",
|
||||
"sundb",
|
||||
"jdbc",
|
||||
"access",
|
||||
] as const;
|
||||
|
||||
export interface DbxDiagnostics {
|
||||
appDataDir: string;
|
||||
dbPath: string;
|
||||
dbPathExists: boolean;
|
||||
connectionsTableExists: boolean;
|
||||
connectionSecretsTableExists?: boolean;
|
||||
connectionRowCount: number;
|
||||
loadConnectionsOk: boolean;
|
||||
loadedConnectionCount: number;
|
||||
loadConnectionsError?: string;
|
||||
loadConnectionsHint?: string;
|
||||
bridgePortFile: string;
|
||||
bridgePortFileExists: boolean;
|
||||
bridgeUrl?: string;
|
||||
directQueryTypes: string[];
|
||||
bridgeRequiredTypes: string[];
|
||||
}
|
||||
|
||||
export async function getDbxDiagnostics(): Promise<DbxDiagnostics> {
|
||||
const portFile = bridgePortFilePath();
|
||||
const bridgePortFileExists = await exists(portFile);
|
||||
let bridgeUrl: string | undefined;
|
||||
if (bridgePortFileExists) {
|
||||
const port = (await readFile(portFile, "utf-8")).trim();
|
||||
if (port) bridgeUrl = `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
const path = dbPath();
|
||||
const connectionStore = await inspectConnectionStore({ path });
|
||||
return {
|
||||
appDataDir: appDataDir(),
|
||||
dbPath: path,
|
||||
dbPathExists: connectionStore.dbPathExists,
|
||||
connectionsTableExists: connectionStore.connectionsTableExists,
|
||||
connectionSecretsTableExists: connectionStore.connectionSecretsTableExists,
|
||||
connectionRowCount: connectionStore.connectionRowCount,
|
||||
loadConnectionsOk: connectionStore.loadConnectionsOk,
|
||||
loadedConnectionCount: connectionStore.loadedConnectionCount,
|
||||
loadConnectionsError: connectionStore.loadConnectionsError,
|
||||
loadConnectionsHint: connectionStore.loadConnectionsError
|
||||
? connectionStoreHint(connectionStore.loadConnectionsError)
|
||||
: undefined,
|
||||
bridgePortFile: portFile,
|
||||
bridgePortFileExists,
|
||||
bridgeUrl,
|
||||
directQueryTypes: [...DIRECT_QUERY_TYPES],
|
||||
bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES],
|
||||
};
|
||||
}
|
||||
|
||||
function connectionStoreHint(message: string): string | undefined {
|
||||
if (/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;
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./backend.js";
|
||||
export * from "./bridge.js";
|
||||
export * from "./connections.js";
|
||||
export * from "./database.js";
|
||||
export * from "./diagnostics.js";
|
||||
export * from "./paths.js";
|
||||
export * from "./schema-context.js";
|
||||
export * from "./sql-safety.js";
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function appDataDir(): string {
|
||||
const home = homedir();
|
||||
switch (platform()) {
|
||||
case "darwin":
|
||||
return join(home, "Library", "Application Support", "com.dbx.app");
|
||||
case "win32":
|
||||
return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "com.dbx.app");
|
||||
default:
|
||||
return join(home, ".config", "com.dbx.app");
|
||||
}
|
||||
}
|
||||
|
||||
export function dbPath(): string {
|
||||
return join(appDataDir(), "dbx.db");
|
||||
}
|
||||
|
||||
export function bridgePortFilePath(): string {
|
||||
return join(appDataDir(), "mcp-bridge-port");
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ConnectionConfig } from "./connections.js";
|
||||
import type { TableInfo, ColumnInfo, QueryResult } from "./database.js";
|
||||
import type { TableInfo, ColumnInfo, QueryOptions, QueryResult } from "./database.js";
|
||||
|
||||
const baseUrl = process.env.DBX_WEB_URL!.replace(/\/+$/, "");
|
||||
const password = process.env.DBX_WEB_PASSWORD || "";
|
||||
|
|
@ -107,7 +107,7 @@ export async function describeTable(config: ConnectionConfig, table: string, sch
|
|||
return res.json();
|
||||
}
|
||||
|
||||
export async function executeQuery(config: ConnectionConfig, sql: string): Promise<QueryResult> {
|
||||
export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
|
||||
await ensureConnected(config);
|
||||
const res = await apiFetch("/api/query/execute", {
|
||||
method: "POST",
|
||||
|
|
@ -125,5 +125,6 @@ export async function executeQuery(config: ConnectionConfig, sql: string): Promi
|
|||
});
|
||||
return obj;
|
||||
});
|
||||
return { columns: data.columns, rows, row_count: rows.length };
|
||||
const limitedRows = rows.slice(0, options?.maxRows ?? rows.length);
|
||||
return { columns: data.columns, rows: limitedRows, row_count: limitedRows.length };
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import Database from "better-sqlite3";
|
||||
import { inspectConnectionStore, loadConnections } from "../src/connections.js";
|
||||
|
||||
test("connection store diagnostics report rows even when loading fails", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-store-"));
|
||||
const path = join(dir, "dbx.db");
|
||||
|
||||
try {
|
||||
const db = new Database(path);
|
||||
db.exec(`
|
||||
CREATE TABLE connections (id TEXT PRIMARY KEY, config_json TEXT NOT NULL);
|
||||
CREATE TABLE connection_secrets (connection_id TEXT, key TEXT, secret TEXT);
|
||||
`);
|
||||
db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run("broken", "{not json");
|
||||
db.close();
|
||||
|
||||
await assert.rejects(() => loadConnections({ path }), /Failed to load DBX connections/);
|
||||
|
||||
const diagnostics = await inspectConnectionStore({ path });
|
||||
assert.equal(diagnostics.dbPath, path);
|
||||
assert.equal(diagnostics.dbPathExists, true);
|
||||
assert.equal(diagnostics.connectionsTableExists, true);
|
||||
assert.equal(diagnostics.connectionRowCount, 1);
|
||||
assert.equal(diagnostics.loadConnectionsOk, false);
|
||||
assert.match(diagnostics.loadConnectionsError ?? "", /Failed to load DBX connections/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("missing connection store is treated as an empty store", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-store-"));
|
||||
const path = join(dir, "missing.db");
|
||||
|
||||
try {
|
||||
assert.deepEqual(await loadConnections({ path }), []);
|
||||
|
||||
const diagnostics = await inspectConnectionStore({ path });
|
||||
assert.equal(diagnostics.dbPathExists, false);
|
||||
assert.equal(diagnostics.connectionsTableExists, false);
|
||||
assert.equal(diagnostics.connectionRowCount, 0);
|
||||
assert.equal(diagnostics.loadConnectionsOk, true);
|
||||
assert.equal(diagnostics.loadedConnectionCount, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -39,6 +39,24 @@ test("queries SQLite connections without the DBX bridge", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("applies query row limits to SQLite connections", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-"));
|
||||
const path = join(dir, "app.db");
|
||||
const db = new Database(path);
|
||||
db.exec("create table users (id integer primary key, name text not null); insert into users (name) values ('Ada'), ('Grace');");
|
||||
db.close();
|
||||
|
||||
try {
|
||||
const result = await executeQuery(sqliteConfig(path), "select id, name from users order by id", { maxRows: 1 });
|
||||
|
||||
assert.deepEqual(result.columns, ["id", "name"]);
|
||||
assert.deepEqual(result.rows, [{ id: 1, name: "Ada" }]);
|
||||
assert.equal(result.row_count, 1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("lists and describes SQLite tables without the DBX bridge", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-"));
|
||||
const path = join(dir, "app.db");
|
||||
|
|
@ -67,4 +85,3 @@ test("lists and describes SQLite tables without the DBX bridge", async () => {
|
|||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
521
pnpm-lock.yaml
521
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
|||
packages:
|
||||
- "packages/*"
|
||||
Loading…
Reference in New Issue