diff --git a/CHANGELOG.md b/CHANGELOG.md index 0062701..13c6b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,15 @@ boolean aliases (`--json`/`--csv`/`--md`/`--xml`/`--files`) still work but are no longer in `--help`; prefer `--format`. +### Fixes + +- Launcher: source-mode runner selection now prefers Node + tsx over Bun when + both `package-lock.json` and `bun.lock` are present in the package root, + mirroring the dist-mode "npm priority" rule. Fixes pnpm-global installs that + copy the entire working tree (including `.git` and `bun.lock`) into the + install dir and previously routed through Bun, causing ABI mismatches with + the Node-built `better-sqlite3` / `sqlite-vec` native modules. + ### Docs - qmd skill: emphasize reading line ranges with `get`'s built-in diff --git a/bin/qmd b/bin/qmd index a5d583f..b76d20e 100755 --- a/bin/qmd +++ b/bin/qmd @@ -43,22 +43,52 @@ function hasBun() { // dist/ is often ignored and can be stale after git reset or branch switches. // Prefer source mode only for checkouts so ./bin/qmd reflects the checked-out // source without changing packaged/runtime behavior. +// +// Critical: source-mode detection must NOT trigger when a package manager +// installed us. `pnpm install -g .` (and `npm install -g .`) copy the entire +// working tree — including .git/, bun.lock, package-lock.json, src/, and even +// node_modules/ — into /node_modules/@tobilu/qmd/, so .git and a +// lockfile being present is not a reliable "this is a working tree" signal. +// What IS reliable: a package-manager install always lands the package +// directory inside a `node_modules/` segment; a bare working-tree checkout +// (with `bun link` or a direct path invocation) does not. Gate source mode +// on that. Allow QMD_SOURCE_MODE=1 / =0 as an explicit override for the +// rare case where the heuristic disagrees with the user. +const sourceOverride = process.env.QMD_SOURCE_MODE; +const looksInstalled = pkgDir.split("/").includes("node_modules"); +const sourceAllowed = sourceOverride === "1" + || (sourceOverride !== "0" && !looksInstalled); + let useSourceMode = false; let sourceRunner = null; let sourceArgs = []; -if (existsSync(resolve(pkgDir, ".git")) && existsSync(tsEntry)) { - if (existsSync(resolve(pkgDir, "bun.lock")) || existsSync(resolve(pkgDir, "bun.lockb"))) { - if (hasBun()) { - useSourceMode = true; - sourceRunner = "bun"; - sourceArgs = [tsEntry, ...process.argv.slice(2)]; - } - } - if (!useSourceMode && existsSync(resolve(pkgDir, "node_modules/tsx/dist/cli.mjs"))) { +if (sourceAllowed && existsSync(resolve(pkgDir, ".git")) && existsSync(tsEntry)) { + // Lockfile-driven runner selection — mirror the dist-mode logic below so + // source mode picks the same runtime the user's deps were installed for. + // package-lock.json wins over bun.lock when both are present: pnpm/npm + // installs ship the Node-ABI native modules (better-sqlite3, sqlite-vec), + // and running Bun against them produces ABI mismatches. This also fixes + // pnpm-global installs, which copy the whole working tree — including .git + // and bun.lock — into the install dir and used to route through Bun even + // when the user installed via npm/pnpm. + const hasNpmLock = existsSync(resolve(pkgDir, "package-lock.json")); + const hasBunLock = existsSync(resolve(pkgDir, "bun.lock")) || existsSync(resolve(pkgDir, "bun.lockb")); + const tsxEntry = resolve(pkgDir, "node_modules/tsx/dist/cli.mjs"); + const tsxAvailable = existsSync(tsxEntry); + + if (hasNpmLock && tsxAvailable) { useSourceMode = true; sourceRunner = "node"; - sourceArgs = [resolve(pkgDir, "node_modules/tsx/dist/cli.mjs"), tsEntry, ...process.argv.slice(2)]; + sourceArgs = [tsxEntry, tsEntry, ...process.argv.slice(2)]; + } else if (hasBunLock && hasBun()) { + useSourceMode = true; + sourceRunner = "bun"; + sourceArgs = [tsEntry, ...process.argv.slice(2)]; + } else if (tsxAvailable) { + useSourceMode = true; + sourceRunner = "node"; + sourceArgs = [tsxEntry, tsEntry, ...process.argv.slice(2)]; } } diff --git a/test/bin-wrapper.test.ts b/test/bin-wrapper.test.ts index f3c508f..4e7eef8 100644 --- a/test/bin-wrapper.test.ts +++ b/test/bin-wrapper.test.ts @@ -226,6 +226,21 @@ describe("bin/qmd package wrapper", () => { expect(result.args).toEqual([realpathSync(join(packageRoot, "src", "cli", "qmd.ts")), "--version"]); }); + test("source checkout with both bun.lock and package-lock.json prefers node+tsx", () => { + // Mirrors the dist-mode "npm priority" rule: a working tree that has both + // lockfiles (because the user ran `npm install` against a repo that also + // ships bun.lock) installed native modules for Node's ABI, so source mode + // must route through tsx to avoid better-sqlite3 / sqlite-vec mismatches. + const { root, runtimeBin, capturePath } = makeTempFixture(); + const packageRoot = makePackage(root, "qmd", ["bun.lock", "package-lock.json"], { source: true, tsx: true, git: true }); + + const result = runWrapper(join(packageRoot, "bin", "qmd"), runtimeBin, capturePath); + + expect(result.runtime).toBe("node"); + expect(result.scriptPath).toBe(realpathSync(join(packageRoot, "node_modules", "tsx", "dist", "cli.mjs"))); + expect(result.args).toEqual([realpathSync(join(packageRoot, "src", "cli", "qmd.ts")), "--version"]); + }); + test("explains how to build when dist is missing and source cannot run", () => { const { root, runtimeBin } = makeTempFixture(); const packageRoot = makePackage(root, "qmd", [], { dist: false });