Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/node-modules-tools/src/agent-entry/detect.ts
Original file line number Diff line number Diff line change
@@ -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<AgentName> {
// 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,
})
Expand Down
23 changes: 15 additions & 8 deletions packages/node-modules-tools/src/agents/pnpm/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ type PnpmDependencyHierarchy = Pick<PackageDependencyHierarchy, 'name' | 'versio
unsavedDependencies?: Record<string, PnpmPackageNode>
}

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()
}
Expand All @@ -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()
}
Expand All @@ -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<PnpmDependencyHierarchy[]> {
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',
Expand All @@ -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 {}
Expand All @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions packages/node-modules-tools/src/types/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions packages/node-modules-tools/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './filter'
export * from './package-json'
export * from './rush'
40 changes: 40 additions & 0 deletions packages/node-modules-tools/src/utils/rush.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
32 changes: 32 additions & 0 deletions packages/node-modules-tools/src/utils/rush.ts
Original file line number Diff line number Diff line change
@@ -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
}