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)) && (!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 };