From 24ec62e220b6e27b5e31d3a8f82a34b8417e4fe6 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 13 Aug 2026 22:38:04 -0400 Subject: [PATCH 1/4] fix: run the CLI when invoked through a symlink npm installs the bin as a symlink (node_modules/.bin/cdb-converter -> ../cdb-converter/dist/cli.mjs) and npx goes through that link. Node puts the symlink path in process.argv[1] but the real path in import.meta.url, and resolve() normalises without dereferencing, so the entry-point guard was always false: the module was imported, run() never called, exit 0 with no output and no file written. Dereference both sides with realpathSync before comparing. The guard is repaired, not removed: importing the module as a library still must not run the converter. Two edge cases are handled: realpathSync throws on paths that do not exist (fall back to the resolved path), and Windows drive-letter casing can differ (compare case-insensitively on win32). Add an end-to-end regression that spawns the built CLI through a symlink, as npm/npx do. Calling run() in-process cannot observe this bug, which is why it shipped. Against the previous code, 4 of its 5 tests fail; the library-import test passes in both, covering the guard it protects. Co-Authored-By: Claude Opus 5 --- src/cli.ts | 54 ++++++++++++++-- test/cliEntrypoint.test.ts | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/cliEntrypoint.test.ts diff --git a/src/cli.ts b/src/cli.ts index e7fc82f..bec8ad2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, extname, resolve } from "node:path"; import process from "node:process"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import initSqlJs from "sql.js"; import { cdbToSql } from "./cdbToSql"; import { sqlToCdb } from "./sqlToCdb"; @@ -241,10 +242,53 @@ export async function run(argv: string[]): Promise { } } -const isDirectRun = - typeof process.argv[1] === "string" && - import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +/** + * Resolve a path to its real location, tolerating paths that do not exist + * (realpathSync throws on those) by falling back to the resolved path. + */ +function safeRealpath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * Path comparison for the entry-point check. Windows paths are compared + * case-insensitively: the drive letter casing of argv[1] and of the module path + * can differ for the very same file. + */ +function samePath(a: string, b: string): boolean { + return process.platform === "win32" + ? a.toLowerCase() === b.toLowerCase() + : a === b; +} + +/** + * True when this module is the process entry point, false when it is imported + * as a library (`import { run } from "cdb-converter"`). + * + * Both sides must be dereferenced: npm installs the bin as a symlink + * (node_modules/.bin/cdb-converter -> ../cdb-converter/dist/cli.mjs), and Node + * puts the *symlink* path in process.argv[1] while import.meta.url points at + * the real file. Comparing them without realpath makes the check always false, + * so the CLI silently does nothing when run via npx or the installed binary. + */ +export function isDirectRun( + argv1: string | undefined, + moduleUrl: string, +): boolean { + if (typeof argv1 !== "string" || argv1.length === 0) { + return false; + } + + return samePath( + safeRealpath(resolve(argv1)), + safeRealpath(fileURLToPath(moduleUrl)), + ); +} -if (isDirectRun) { +if (isDirectRun(process.argv[1], import.meta.url)) { void run(process.argv.slice(2)); } diff --git a/test/cliEntrypoint.test.ts b/test/cliEntrypoint.test.ts new file mode 100644 index 0000000..520a07b --- /dev/null +++ b/test/cliEntrypoint.test.ts @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + copyFile, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * End-to-end guard for the CLI entry-point check. + * + * These tests must spawn the built CLI *through a symlink*, because that is how + * npm/npx invoke it (node_modules/.bin/cdb-converter -> dist/cli.mjs). Calling + * run() in-process cannot observe the bug: Node reports the symlink path in + * process.argv[1] but the real path in import.meta.url, and a comparison that + * does not dereference both makes the CLI exit 0 without doing anything. + */ + +const repoRoot = resolve(fileURLToPath(import.meta.url), "../.."); +const cliPath = join(repoRoot, "dist", "cli.mjs"); +const fixture = join(repoRoot, "test", "fixtures", "OfficialRelease-2014.cdb"); + +let workDir: string; +let linkPath: string; + +function runCli(args: string[]): { status: number; stdout: string } { + let stdout = ""; + let status = 0; + + try { + stdout = execFileSync(process.execPath, [linkPath, ...args], { + cwd: workDir, + encoding: "utf8", + }); + } catch (error) { + const failure = error as { status?: number; stdout?: string }; + status = failure.status ?? 1; + stdout = failure.stdout ?? ""; + } + + return { status, stdout }; +} + +beforeAll(async () => { + execFileSync("npm", ["run", "build"], { cwd: repoRoot, stdio: "ignore" }); + + workDir = await mkdtemp(join(tmpdir(), "cdb-converter-cli-")); + await mkdir(join(workDir, "bin")); + + // Mirrors what `npm install` creates for the "bin" entry. + linkPath = join(workDir, "bin", "cdb-converter"); + await symlink(cliPath, linkPath); + + await copyFile(fixture, join(workDir, "input.cdb")); +}, 120_000); + +afterAll(async () => { + if (workDir) { + await rm(workDir, { recursive: true, force: true }); + } +}); + +describe("CLI invoked through a symlink", () => { + it("converts a real .cdb and writes the output file", () => { + const { status, stdout } = runCli(["input.cdb", "output.sqlite"]); + + expect(status).toBe(0); + expect(existsSync(join(workDir, "output.sqlite"))).toBe(true); + expect(stdout).toContain("Output :"); + expect(stdout).toMatch(/Tables : \d+/); + }, 120_000); + + it("converts back from .sqlite to .cdb", () => { + expect(runCli(["input.cdb", "roundtrip.sqlite"]).status).toBe(0); + + const { status } = runCli(["roundtrip.sqlite", "roundtrip.cdb"]); + + expect(status).toBe(0); + expect(existsSync(join(workDir, "roundtrip.cdb"))).toBe(true); + }, 120_000); + + it("prints help and version", () => { + const help = runCli(["--help"]); + expect(help.status).toBe(0); + expect(help.stdout).toContain("Usage:"); + + const version = runCli(["--version"]); + expect(version.status).toBe(0); + expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + }, 60_000); + + it("exits non-zero on a missing input file", () => { + expect(runCli([]).status).toBe(1); + }, 60_000); +}); + +describe("CLI imported as a library", () => { + it("does not run the converter on import", async () => { + const importer = join(workDir, "importer.mjs"); + await writeFile( + importer, + `import { run } from ${JSON.stringify(cliPath)};\n` + + `console.log(typeof run);\n`, + ); + + const stdout = execFileSync( + process.execPath, + [importer, "input.cdb", "should-not-exist.sqlite"], + { cwd: workDir, encoding: "utf8" }, + ); + + expect(stdout.trim()).toBe("function"); + expect(existsSync(join(workDir, "should-not-exist.sqlite"))).toBe(false); + }, 60_000); +}); From 42d4fad80e37c7b68c5d5dc510d87988a86088a9 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Fri, 14 Aug 2026 15:57:13 -0400 Subject: [PATCH 2/4] fix: update CLI entry-point tests to use npm command based on platform --- test/cliEntrypoint.test.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/cliEntrypoint.test.ts b/test/cliEntrypoint.test.ts index 520a07b..882024d 100644 --- a/test/cliEntrypoint.test.ts +++ b/test/cliEntrypoint.test.ts @@ -21,8 +21,14 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; * run() in-process cannot observe the bug: Node reports the symlink path in * process.argv[1] but the real path in import.meta.url, and a comparison that * does not dereference both makes the CLI exit 0 without doing anything. + * + * The symlink suite is skipped on Windows: fs.symlink() there needs admin + * rights or developer mode, and npm does not symlink "bin" entries anyway (it + * generates .cmd/.ps1 shims), so the scenario under test does not exist. */ +const isWindows = process.platform === "win32"; +const npmCommand = isWindows ? "npm.cmd" : "npm"; const repoRoot = resolve(fileURLToPath(import.meta.url), "../.."); const cliPath = join(repoRoot, "dist", "cli.mjs"); const fixture = join(repoRoot, "test", "fixtures", "OfficialRelease-2014.cdb"); @@ -49,15 +55,12 @@ function runCli(args: string[]): { status: number; stdout: string } { } beforeAll(async () => { - execFileSync("npm", ["run", "build"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync(npmCommand, ["run", "build"], { + cwd: repoRoot, + stdio: "ignore", + }); workDir = await mkdtemp(join(tmpdir(), "cdb-converter-cli-")); - await mkdir(join(workDir, "bin")); - - // Mirrors what `npm install` creates for the "bin" entry. - linkPath = join(workDir, "bin", "cdb-converter"); - await symlink(cliPath, linkPath); - await copyFile(fixture, join(workDir, "input.cdb")); }, 120_000); @@ -67,7 +70,15 @@ afterAll(async () => { } }); -describe("CLI invoked through a symlink", () => { +describe.skipIf(isWindows)("CLI invoked through a symlink", () => { + beforeAll(async () => { + await mkdir(join(workDir, "bin")); + + // Mirrors what `npm install` creates for the "bin" entry. + linkPath = join(workDir, "bin", "cdb-converter"); + await symlink(cliPath, linkPath); + }); + it("converts a real .cdb and writes the output file", () => { const { status, stdout } = runCli(["input.cdb", "output.sqlite"]); From 845229d5c8be5170740f20778be8be3de297ac7a Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Fri, 14 Aug 2026 15:45:09 -0400 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/cliEntrypoint.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cliEntrypoint.test.ts b/test/cliEntrypoint.test.ts index 882024d..c763262 100644 --- a/test/cliEntrypoint.test.ts +++ b/test/cliEntrypoint.test.ts @@ -76,7 +76,7 @@ describe.skipIf(isWindows)("CLI invoked through a symlink", () => { // Mirrors what `npm install` creates for the "bin" entry. linkPath = join(workDir, "bin", "cdb-converter"); - await symlink(cliPath, linkPath); + await symlink(cliPath, linkPath, "file"); }); it("converts a real .cdb and writes the output file", () => { From 8a2ea23ec4afc0b6746644f819874fa387f862d9 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Fri, 14 Aug 2026 17:07:14 -0400 Subject: [PATCH 4/4] fix: clarify entry point behavior in documentation comment --- src/cli.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index bec8ad2..e592750 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -266,8 +266,9 @@ function samePath(a: string, b: string): boolean { } /** - * True when this module is the process entry point, false when it is imported - * as a library (`import { run } from "cdb-converter"`). + * True when this module is the process entry point, false when it is merely + * imported (by the test suite, or by anything that loads the file directly), + * in which case importing it must not run a conversion. * * Both sides must be dereferenced: npm installs the bin as a symlink * (node_modules/.bin/cdb-converter -> ../cdb-converter/dist/cli.mjs), and Node