fix(launcher): prefer Node+tsx over Bun in source mode when both lockfiles exist

Source-mode runner selection now mirrors the dist-mode 'npm priority' rule:
if both package-lock.json and bun.lock are present in the package root,
use Node + tsx instead of Bun. pnpm/npm installs ship Node-ABI native
modules (better-sqlite3, sqlite-vec), and routing through Bun produces
ABI mismatches.

This also fixes pnpm-global installs, which copy the entire working tree
(including .git and bun.lock) into <prefix>/node_modules/@tobilu/qmd/.
The old logic saw .git + bun.lock + bun-on-PATH and routed to Bun
against the Node-installed native modules.

Adds a regression test covering the both-lockfiles source-checkout case.
This commit is contained in:
Tobi Lutke 2026-05-28 11:57:30 -07:00
parent 3de3162e1a
commit 0d7fdb7589
No known key found for this signature in database
3 changed files with 64 additions and 10 deletions

View File

@ -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

50
bin/qmd
View File

@ -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 <prefix>/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)];
}
}

View File

@ -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 });