diff --git a/packages/node-modules-tools/src/agent-entry/detect.ts b/packages/node-modules-tools/src/agent-entry/detect.ts index ed32b1f..4361f81 100644 --- a/packages/node-modules-tools/src/agent-entry/detect.ts +++ b/packages/node-modules-tools/src/agent-entry/detect.ts @@ -1,8 +1,16 @@ import type { AgentName } from 'package-manager-detector' import type { BaseOptions } from '../types' import { detect } from 'package-manager-detector' +import { isRushMonorepo } from '../utils/rush' export async function getPackageManager(options: BaseOptions): Promise { + // Rush monorepos use pnpm under the hood via `rush-pnpm`. + // Detect Rush first so the pnpm agent can pick the right executable. + if (isRushMonorepo(options.cwd)) { + options.rush = true + return 'pnpm' + } + const manager = await detect({ cwd: options.cwd, }) diff --git a/packages/node-modules-tools/src/agents/pnpm/list.ts b/packages/node-modules-tools/src/agents/pnpm/list.ts index 96eb675..c10388d 100644 --- a/packages/node-modules-tools/src/agents/pnpm/list.ts +++ b/packages/node-modules-tools/src/agents/pnpm/list.ts @@ -27,11 +27,16 @@ type PnpmDependencyHierarchy = Pick } +function resolveExecutable(options: ListPackageDependenciesOptions): string { + return options.rush ? 'rush-pnpm' : 'pnpm' +} + async function resolveRoot(options: ListPackageDependenciesOptions) { let raw: string | undefined + const pnpmBin = resolveExecutable(options) if (options.workspace === false) { try { - raw = (await x('pnpm', ['root'], { throwOnError: true, nodeOptions: { cwd: options.cwd } })) + raw = (await x(pnpmBin, ['root'], { throwOnError: true, nodeOptions: { cwd: options.cwd } })) .stdout .trim() } @@ -42,13 +47,13 @@ async function resolveRoot(options: ListPackageDependenciesOptions) { } else { try { - raw = (await x('pnpm', ['root', '-w'], { throwOnError: true, nodeOptions: { cwd: options.cwd } })) + raw = (await x(pnpmBin, ['root', '-w'], { throwOnError: true, nodeOptions: { cwd: options.cwd } })) .stdout .trim() } catch { try { - raw = (await x('pnpm', ['root'], { throwOnError: true, nodeOptions: { cwd: options.cwd } })) + raw = (await x(pnpmBin, ['root'], { throwOnError: true, nodeOptions: { cwd: options.cwd } })) .stdout .trim() } @@ -63,23 +68,25 @@ async function resolveRoot(options: ListPackageDependenciesOptions) { async function getPnpmVersion(options: ListPackageDependenciesOptions) { try { - const raw = await x('pnpm', ['--version'], { throwOnError: true, nodeOptions: { cwd: options.cwd } }) + const pnpmBin = resolveExecutable(options) + const raw = await x(pnpmBin, ['--version'], { throwOnError: true, nodeOptions: { cwd: options.cwd } }) return raw.stdout.trim() } catch (err) { - console.error('Failed to get pnpm version') + console.error(`Failed to get ${resolveExecutable(options)} version`) console.error(err) return undefined } } async function getDependenciesTree(options: ListPackageDependenciesOptions): Promise { + const pnpmBin = resolveExecutable(options) const args = ['ls', '--json', '--depth', String(options.depth)] if (options.monorepo) args.push('--recursive') if (options.workspace === false) args.push('--ignore-workspace') - const process = x('pnpm', args, { + const process = x(pnpmBin, args, { throwOnError: true, nodeOptions: { stdio: 'pipe', @@ -93,7 +100,7 @@ async function getDependenciesTree(options: ListPackageDependenciesOptions): Pro if (err instanceof JsonParseStreamError) { try { if (err.data.error?.message === 'Invalid string length') { - console.error(`pnpm ls output is too large to parse, please try using the --depth=${String(Math.ceil(options.depth / 3 * 2))} option to limit the depth of the dependency tree`) + console.error(`${pnpmBin} ls output is too large to parse, please try using the --depth=${String(Math.ceil(options.depth / 3 * 2))} option to limit the depth of the dependency tree`) } } catch {} @@ -102,7 +109,7 @@ async function getDependenciesTree(options: ListPackageDependenciesOptions): Pro }) if (!Array.isArray(json)) - throw new Error(`Failed to parse \`pnpm ls\` output, expected an array but got: ${String(json)}`) + throw new Error(`Failed to parse \`${pnpmBin} ls\` output, expected an array but got: ${String(json)}`) return json } diff --git a/packages/node-modules-tools/src/types/base.ts b/packages/node-modules-tools/src/types/base.ts index ee41611..b688a80 100644 --- a/packages/node-modules-tools/src/types/base.ts +++ b/packages/node-modules-tools/src/types/base.ts @@ -3,4 +3,10 @@ export interface BaseOptions { * Current working directory */ cwd: string + /** + * Whether the project is a Rush monorepo. + * When true, the pnpm agent will use `rush-pnpm` instead of `pnpm`. + * @internal + */ + rush?: boolean } diff --git a/packages/node-modules-tools/src/utils/index.ts b/packages/node-modules-tools/src/utils/index.ts index 6b24bf4..41dbe83 100644 --- a/packages/node-modules-tools/src/utils/index.ts +++ b/packages/node-modules-tools/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './filter' export * from './package-json' +export * from './rush' diff --git a/packages/node-modules-tools/src/utils/rush.test.ts b/packages/node-modules-tools/src/utils/rush.test.ts new file mode 100644 index 0000000..8ac8426 --- /dev/null +++ b/packages/node-modules-tools/src/utils/rush.test.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { findRushRoot, isRushMonorepo } from './rush' + +describe('rush detection', () => { + function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'rush-test-')) + } + + it('returns false when no rush.json exists', () => { + const dir = createTempDir() + expect(isRushMonorepo(dir)).toBe(false) + expect(findRushRoot(dir)).toBeUndefined() + }) + + it('detects rush.json at the current directory', () => { + const dir = createTempDir() + fs.writeFileSync(path.join(dir, 'rush.json'), '{}') + expect(isRushMonorepo(dir)).toBe(true) + expect(findRushRoot(dir)).toBe(dir) + }) + + it('detects rush.json in a parent directory', () => { + const root = createTempDir() + fs.writeFileSync(path.join(root, 'rush.json'), '{}') + const child = path.join(root, 'apps', 'web') + fs.mkdirSync(child, { recursive: true }) + + expect(isRushMonorepo(child)).toBe(true) + expect(findRushRoot(child)).toBe(root) + }) + + it('distinguishes rush.json from pnpm-workspace.yaml', () => { + const dir = createTempDir() + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'packages: []') + expect(isRushMonorepo(dir)).toBe(false) + }) +}) diff --git a/packages/node-modules-tools/src/utils/rush.ts b/packages/node-modules-tools/src/utils/rush.ts new file mode 100644 index 0000000..9b82af6 --- /dev/null +++ b/packages/node-modules-tools/src/utils/rush.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs' +import { dirname, join, parse } from 'pathe' + +/** + * Check if the given directory is inside a Rush monorepo. + * + * Rush monorepos are identified by the presence of a `rush.json` file + * at the monorepo root. This function traverses up from `cwd` to find it. + * + * @see https://rushjs.io/pages/maintainer/setup_new_repo/ + */ +export function isRushMonorepo(cwd: string): boolean { + return !!findRushRoot(cwd) +} + +/** + * Find the Rush monorepo root directory by traversing up from `cwd`. + * + * @returns The directory containing `rush.json`, or `undefined` if not in a Rush monorepo. + */ +export function findRushRoot(cwd: string): string | undefined { + let dir = cwd + const { root } = parse(dir) + + while (dir && dir !== root) { + if (fs.existsSync(join(dir, 'rush.json'))) + return dir + dir = dirname(dir) + } + + return undefined +}