diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 853009cb1..4c631d5b3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; import { buildSchemaContext, createBackend, @@ -9,6 +8,7 @@ import { evaluateSqlSafety, formatSchemaContext, getDbxDiagnostics, + isMainModule, postBridge, sqlSafetyFromEnv, type Backend, @@ -406,7 +406,7 @@ async function main() { process.exitCode = result.exitCode; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isMainModule(import.meta.url, process.argv[1])) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/packages/cli/tests/bin.test.ts b/packages/cli/tests/bin.test.ts new file mode 100644 index 000000000..6d5c9ff16 --- /dev/null +++ b/packages/cli/tests/bin.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const packageDir = fileURLToPath(new URL("..", import.meta.url)); +const cliSource = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); + +test("prints version when invoked through an npm-style symlink", async () => { + const bin = await symlinkedCli(); + try { + const result = runDbx(bin, ["--version"]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /^\d+\.\d+\.\d+\n$/); + assert.equal(result.stderr, ""); + } finally { + await rm(bin.dir, { recursive: true, force: true }); + } +}); + +test("prints capabilities when invoked through an npm-style symlink", async () => { + const bin = await symlinkedCli(); + try { + const result = runDbx(bin, ["capabilities", "--json"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] }; + assert.ok(payload.directQueryTypes.includes("postgres")); + assert.ok(payload.bridgeRequiredTypes.includes("oracle")); + } finally { + await rm(bin.dir, { recursive: true, force: true }); + } +}); + +async function symlinkedCli() { + const dir = await mkdtemp(join(tmpdir(), "dbx-cli-bin-")); + const path = join(dir, "dbx"); + await symlink(cliSource, path); + return { dir, path }; +} + +function runDbx(bin: { path: string }, args: string[]) { + return spawnSync(process.execPath, ["--import", "tsx", bin.path, ...args], { + cwd: packageDir, + encoding: "utf-8", + }); +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index a34d58980..4231cfdb7 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; import { z } from "zod"; import { buildSchemaContext, @@ -11,6 +10,7 @@ import { evaluateSqlSafety, formatCell, formatSchemaContext, + isMainModule, mdTable, notifyReload, parseMongoAggregateCommand, @@ -284,7 +284,7 @@ async function main() { await server.connect(transport); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isMainModule(import.meta.url, process.argv[1])) { main().catch((e) => { console.error("MCP Server failed to start:", e); process.exit(1); diff --git a/packages/node-core/package.json b/packages/node-core/package.json index f593f6bcf..e9dd6ca4c 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -16,6 +16,7 @@ "./connections": "./dist/connections.js", "./database": "./dist/database.js", "./diagnostics": "./dist/diagnostics.js", + "./entrypoint": "./dist/entrypoint.js", "./format": "./dist/format.js", "./paths": "./dist/paths.js", "./schema-context": "./dist/schema-context.js", diff --git a/packages/node-core/src/entrypoint.ts b/packages/node-core/src/entrypoint.ts new file mode 100644 index 000000000..4bfec99fc --- /dev/null +++ b/packages/node-core/src/entrypoint.ts @@ -0,0 +1,21 @@ +import { realpathSync } from "node:fs"; +import { normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean { + if (!argvPath) return false; + return normalizeEntryPath(fileURLToPath(moduleUrl)) === normalizeEntryPath(argvPath); +} + +function normalizeEntryPath(path: string): string { + const normalized = normalize(realpathIfPossible(resolve(path))); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function realpathIfPossible(path: string): string { + try { + return realpathSync.native(path); + } catch { + return path; + } +} diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 78899660d..be93ef12e 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -3,6 +3,7 @@ export * from "./bridge.js"; export * from "./connections.js"; export * from "./database.js"; export * from "./diagnostics.js"; +export * from "./entrypoint.js"; export * from "./format.js"; export * from "./paths.js"; export * from "./schema-context.js"; diff --git a/packages/node-core/tests/entrypoint.test.ts b/packages/node-core/tests/entrypoint.test.ts new file mode 100644 index 000000000..b0a423799 --- /dev/null +++ b/packages/node-core/tests/entrypoint.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import { isMainModule } from "../src/entrypoint.js"; + +test("matches a module invoked through its real file path", async () => { + const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-")); + try { + const entry = join(dir, "cli.js"); + await writeFile(entry, "", "utf-8"); + + assert.equal(isMainModule(pathToFileURL(entry).href, entry), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("matches a module invoked through an npm-style symlink", async () => { + const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-")); + try { + const entry = join(dir, "dist", "cli.js"); + const bin = join(dir, "dbx"); + await mkdir(join(dir, "dist")); + await writeFile(entry, "", "utf-8"); + await symlink(entry, bin); + + assert.equal(isMainModule(pathToFileURL(entry).href, bin), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("does not match a different entry file", async () => { + const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-")); + try { + const entry = join(dir, "cli.js"); + const other = join(dir, "other.js"); + await writeFile(entry, "", "utf-8"); + await writeFile(other, "", "utf-8"); + + assert.equal(isMainModule(pathToFileURL(entry).href, other), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +});