fix: detect package manager from monorepo root lockfile

Walk up to 5 directories to find pnpm-lock.yaml or yarn.lock so
sub-packages in monorepos get the correct pm. Also fix typecheck
detection to prefer scripts.typecheck over tsconfig.json presence.
This commit is contained in:
Pouiiro committed 2026-04-04 13:16:11 +02:00
1 parent 1aab99abe4
commit 2b305e036e
2 files changed
+129 -17

No files matched your search

+41 -17
View File
@@ -1,5 +1,5 @@
import { access, readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import type { ToolResult } from "../../types/index.js";
import { compressShellOutputFull } from "./shell-compress.js";
import { saveTee, truncateWithTee } from "./tee.js";
@@ -75,11 +75,11 @@ export async function detectProfile(cwd: string): Promise<ProjectProfile> {
profile.test = scripts.test ?? "bun test";
profile.build = scripts.build ? `bun run build` : null;
profile.lint = scripts.lint ? "bun run lint" : await detectJsLinter(cwd, "bunx");
profile.typecheck = (await has("tsconfig.json"))
? scripts.typecheck
? "bun run typecheck"
: "bunx tsc --noEmit"
: null;
profile.typecheck = scripts.typecheck
? "bun run typecheck"
: (await has("tsconfig.json"))
? "bunx tsc --noEmit"
: null;
profile.run = scripts.dev ? "bun run dev" : scripts.start ? "bun run start" : null;
profile.format = scripts.format ? "bun run format" : await detectJsFormatter(cwd, "bunx");
profile.formatAndLint = await detectFormatAndLint(cwd, "bunx");
@@ -99,16 +99,16 @@ export async function detectProfile(cwd: string): Promise<ProjectProfile> {
// JS/TS — pnpm/yarn/npm
if (await has("package.json")) {
const pm = (await has("pnpm-lock.yaml")) ? "pnpm" : (await has("yarn.lock")) ? "yarn" : "npm";
const pm = await detectJsPm(cwd);
const run = pm === "npm" ? "npm run" : pm;
profile.test = scripts.test ? `${run} test` : null;
profile.build = scripts.build ? `${run} build` : null;
profile.lint = scripts.lint ? `${run} lint` : await detectJsLinter(cwd, "npx");
profile.typecheck = (await has("tsconfig.json"))
? scripts.typecheck
? `${run} typecheck`
: "npx tsc --noEmit"
: null;
profile.typecheck = scripts.typecheck
? `${run} typecheck`
: (await has("tsconfig.json"))
? "npx tsc --noEmit"
: null;
profile.run = scripts.dev ? `${run} dev` : scripts.start ? `${run} start` : null;
profile.format = scripts.format ? `${run} format` : await detectJsFormatter(cwd, "npx");
profile.formatAndLint = await detectFormatAndLint(cwd, "npx");
@@ -360,6 +360,27 @@ export async function detectProfile(cwd: string): Promise<ProjectProfile> {
return profile;
}
/** Detect JS package manager by walking up to find a lockfile (handles monorepo sub-packages). */
async function detectJsPm(cwd: string): Promise<"pnpm" | "yarn" | "npm"> {
const lockfiles: [string, "pnpm" | "yarn"][] = [
["pnpm-lock.yaml", "pnpm"],
["yarn.lock", "yarn"],
];
let dir = cwd;
for (let i = 0; i < 5; i++) {
for (const [file, pm] of lockfiles) {
try {
await access(join(dir, file));
return pm;
} catch {}
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return "npm";
}
async function readPackageScripts(cwd: string): Promise<Record<string, string>> {
try {
const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf-8"));
@@ -452,8 +473,8 @@ async function detectJsTypecheck(cwd: string): Promise<string | null> {
if (!(await has("tsconfig.json"))) return null;
if ((await has("bun.lock")) || (await has("bun.lockb"))) return "bunx tsc --noEmit";
if ((await has("deno.json")) || (await has("deno.lock"))) return "deno check .";
if (await has("pnpm-lock.yaml")) return "pnpm tsc --noEmit";
return "npx tsc --noEmit";
const pm = await detectJsPm(cwd);
return pm === "pnpm" ? "pnpm tsc --noEmit" : "npx tsc --noEmit";
}
/**
@@ -473,12 +494,15 @@ export async function detectNativeChecks(cwd: string): Promise<string | null> {
const cmds: string[] = [];
// JS/TS — detect runner for local tool resolution
const pm = await detectJsPm(cwd);
const runner =
(await has("bun.lock")) || (await has("bun.lockb"))
? "bunx"
: (await has("package.json"))
? "npx"
: "";
: pm === "pnpm"
? "pnpm exec"
: (await has("package.json"))
? "npx"
: "";
const jsLint = await detectJsLinter(cwd, runner);
if (jsLint) cmds.push(jsLint);
const jsTc = await detectJsTypecheck(cwd);
+88
View File
@@ -413,6 +413,94 @@ describe(".NET project", () => {
});
});
// ── Monorepo (Turbo/pnpm/yarn) ──────────────────────────────
describe("pnpm monorepo (turbo)", () => {
it("detects typecheck from scripts even without tsconfig.json in root", async () => {
const cwd = makeProject("pnpm-turbo-root", {
"package.json": JSON.stringify({
scripts: { build: "turbo build", lint: "turbo lint", typecheck: "turbo typecheck", dev: "turbo dev" },
}),
"pnpm-lock.yaml": "",
"pnpm-workspace.yaml": "packages:\n - 'apps/*'\n - 'packages/*'",
"turbo.json": "{}",
});
const p = await detectProfile(cwd);
expect(p.typecheck).toBe("pnpm typecheck");
expect(p.build).toBe("pnpm build");
expect(p.lint).toBe("pnpm lint");
expect(p.run).toBe("pnpm dev");
});
it("sub-package detects pnpm from root lockfile", async () => {
const root = makeProject("pnpm-turbo-sub", {
"package.json": JSON.stringify({ scripts: { dev: "turbo dev" } }),
"pnpm-lock.yaml": "",
"pnpm-workspace.yaml": "packages:\n - 'apps/*'",
"apps/web/package.json": JSON.stringify({
scripts: { build: "next build", dev: "next dev", lint: "eslint", typecheck: "tsc --noEmit" },
}),
"apps/web/tsconfig.json": "{}",
});
const p = await detectProfile(join(root, "apps/web"));
expect(p.build).toBe("pnpm build");
expect(p.lint).toBe("pnpm lint");
expect(p.typecheck).toBe("pnpm typecheck");
expect(p.run).toBe("pnpm dev");
});
it("sub-package without scripts falls back to pnpm tsc", async () => {
const root = makeProject("pnpm-turbo-sub-noscript", {
"pnpm-lock.yaml": "",
"apps/api/package.json": JSON.stringify({ scripts: { dev: "wrangler dev" } }),
"apps/api/tsconfig.json": "{}",
});
const p = await detectProfile(join(root, "apps/api"));
expect(p.typecheck).toBe("npx tsc --noEmit");
expect(p.run).toBe("pnpm dev");
});
});
describe("yarn monorepo", () => {
it("sub-package detects yarn from root lockfile", async () => {
const root = makeProject("yarn-mono-sub", {
"package.json": JSON.stringify({ workspaces: ["packages/*"] }),
"yarn.lock": "",
"packages/ui/package.json": JSON.stringify({
scripts: { build: "tsc", test: "jest", lint: "eslint ." },
}),
"packages/ui/tsconfig.json": "{}",
});
const p = await detectProfile(join(root, "packages/ui"));
expect(p.test).toBe("yarn test");
expect(p.build).toBe("yarn build");
expect(p.lint).toBe("yarn lint");
});
it("sub-package without lockfile does not fall back to npm", async () => {
const root = makeProject("yarn-mono-no-npm", {
"yarn.lock": "",
"packages/core/package.json": JSON.stringify({ scripts: { build: "tsc" } }),
});
const p = await detectProfile(join(root, "packages/core"));
expect(p.build).toBe("yarn build");
});
});
describe("bun monorepo", () => {
it("detects typecheck from scripts without tsconfig.json", async () => {
const cwd = makeProject("bun-turbo-root", {
"bun.lock": "",
"package.json": JSON.stringify({
scripts: { typecheck: "turbo typecheck", build: "turbo build" },
}),
});
const p = await detectProfile(cwd);
expect(p.typecheck).toBe("bun run typecheck");
expect(p.build).toBe("bun run build");
});
});
// ── Edge Cases ──────────────────────────────────────────────
describe("edge cases", () => {