const fs = require("fs-extra"); const path = require("node:path"); // Deno/Node compatibility: explicitly import process const process = require("node:process"); const { execFile } = require("node:child_process"); const { promisify } = require("node:util"); const execFileAsync = promisify(execFile); // Simple memoization across calls (keyed by realpath of startDir) const _cache = new Map(); async function _tryRun(cmd, args, cwd, timeoutMs = 500) { try { const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024, }); const out = String(stdout || "").trim(); return out || null; } catch { return null; } } async function _detectVcsTopLevel(startDir) { // Run common VCS root queries in parallel; ignore failures const gitP = _tryRun("git", ["rev-parse", "--show-toplevel"], startDir); const hgP = _tryRun("hg", ["root"], startDir); const svnP = (async () => { const show = await _tryRun("svn", ["info", "--show-item", "wc-root"], startDir); if (show) return show; const info = await _tryRun("svn", ["info"], startDir); if (info) { const line = info.split(/\r?\n/).find((l) => l.toLowerCase().startsWith("working copy root path:")); if (line) return line.split(":").slice(1).join(":").trim(); } return null; })(); const [git, hg, svn] = await Promise.all([gitP, hgP, svnP]); return git || hg || svn || null; } /** * Attempt to find the project root by walking up from startDir. * Uses a robust, prioritized set of ecosystem markers (VCS > workspaces/monorepo > lock/build > language config). * Also recognizes package.json with "workspaces" as a workspace root. * You can augment markers via env PROJECT_ROOT_MARKERS as a comma-separated list of file/dir names. * @param {string} startDir * @returns {Promise} project root directory or null if not found */ async function findProjectRoot(startDir) { try { // Resolve symlinks for robustness (e.g., when invoked from a symlinked path) let dir = path.resolve(startDir); try { dir = await fs.realpath(dir); } catch { // ignore if realpath fails; continue with resolved path } const startKey = dir; // preserve starting point for caching if (_cache.has(startKey)) return _cache.get(startKey); const fsRoot = path.parse(dir).root; // Helper to safely check for existence const exists = (p) => fs.pathExists(p); // Build checks: an array of { makePath: (dir) => string, weight } const checks = []; const add = (rel, weight) => { const makePath = (d) => Array.isArray(rel) ? path.join(d, ...rel) : path.join(d, rel); checks.push({ makePath, weight }); }; // Highest priority: explicit sentinel markers add(".project-root", 110); add(".workspace-root", 110); add(".repo-root", 110); // Highest priority: VCS roots add(".git", 100); add(".hg", 95); add(".svn", 95); // Monorepo/workspace indicators add("pnpm-workspace.yaml", 90); add("lerna.json", 90); add("turbo.json", 90); add("nx.json", 90); add("rush.json", 90); add("go.work", 90); add("WORKSPACE", 90); add("WORKSPACE.bazel", 90); add("MODULE.bazel", 90); add("pants.toml", 90); // Lockfiles and package-manager/top-level locks add("yarn.lock", 85); add("pnpm-lock.yaml", 85); add("package-lock.json", 85); add("bun.lockb", 85); add("Cargo.lock", 85); add("composer.lock", 85); add("poetry.lock", 85); add("Pipfile.lock", 85); add("Gemfile.lock", 85); // Build-system root indicators add("settings.gradle", 80); add("settings.gradle.kts", 80); add("gradlew", 80); add("pom.xml", 80); add("build.sbt", 80); add(["project", "build.properties"], 80); // Language/project config markers add("deno.json", 75); add("deno.jsonc", 75); add("pyproject.toml", 75); add("Pipfile", 75); add("requirements.txt", 75); add("go.mod", 75); add("Cargo.toml", 75); add("composer.json", 75); add("mix.exs", 75); add("Gemfile", 75); add("CMakeLists.txt", 75); add("stack.yaml", 75); add("cabal.project", 75); add("rebar.config", 75); add("pubspec.yaml", 75); add("flake.nix", 75); add("shell.nix", 75); add("default.nix", 75); add(".tool-versions", 75); add("package.json", 74); // generic Node project (lower than lockfiles/workspaces) // Changesets add([".changeset", "config.json"], 70); add(".changeset", 70); // Custom markers via env (comma-separated names) if (process.env.PROJECT_ROOT_MARKERS) { for (const name of process.env.PROJECT_ROOT_MARKERS.split(",").map((s) => s.trim()).filter(Boolean)) { add(name, 72); } } /** Check for package.json with "workspaces" */ const hasWorkspacePackageJson = async (d) => { const pkgPath = path.join(d, "package.json"); if (!(await exists(pkgPath))) return false; try { const raw = await fs.readFile(pkgPath, "utf8"); const pkg = JSON.parse(raw); return Boolean(pkg && pkg.workspaces); } catch { return false; } }; let best = null; // { dir, weight } // Try to detect VCS toplevel once up-front; treat as authoritative slightly above .git marker const vcsTop = await _detectVcsTopLevel(dir); if (vcsTop) { best = { dir: vcsTop, weight: 101 }; } while (true) { // Special check: package.json with "workspaces" if (await hasWorkspacePackageJson(dir)) { if (!best || 90 >= best.weight) best = { dir, weight: 90 }; } // Evaluate all other checks in parallel const results = await Promise.all( checks.map(async (c) => ({ c, ok: await exists(c.makePath(dir)) })), ); for (const { c, ok } of results) { if (!ok) continue; if (!best || c.weight >= best.weight) { best = { dir, weight: c.weight }; } } if (dir === fsRoot) break; dir = path.dirname(dir); } const out = best ? best.dir : null; _cache.set(startKey, out); return out; } catch { return null; } } module.exports = { findProjectRoot };