Skip to content

Commit

Permalink
feat: better support yarn PnP
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Oct 19, 2023
1 parent 914f148 commit c5d87fc
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 161 deletions.
161 changes: 4 additions & 157 deletions src/config/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
/* eslint-disable no-await-in-loop */
import type {PackageInformation, PackageLocator, getPackageInformation} from 'pnpapi'

import {sync} from 'globby'
import {basename, dirname, join, parse, relative, sep} from 'node:path'
import {join, parse, relative, sep} from 'node:path'
import {inspect} from 'node:util'

import {Command} from '../command'
Expand All @@ -14,10 +11,11 @@ import {Topic} from '../interfaces/topic'
import {loadWithData, loadWithDataFromManifest} from '../module-loader'
import {OCLIF_MARKER_OWNER, Performance} from '../performance'
import {cacheCommand} from '../util/cache-command'
import {readJson, requireJson, safeReadJson} from '../util/fs'
import {findRoot} from '../util/find-root'
import {readJson, requireJson} from '../util/fs'
import {compact, isProd, mapValues} from '../util/util'
import {tsPath} from './ts-node'
import {Debug, getCommandIdPermutations, resolvePackage} from './util'
import {Debug, getCommandIdPermutations} from './util'

const _pjson = requireJson<PJSON>(__dirname, '..', '..', 'package.json')

Expand All @@ -34,157 +32,6 @@ function topicsToArray(input: any, base?: string): Topic[] {
})
}

// essentially just "cd .."
function* up(from: string) {
while (dirname(from) !== from) {
yield from
from = dirname(from)
}

yield from
}

async function findPluginRoot(root: string, name?: string) {
// If we know the plugin name then we just need to traverse the file
// system until we find the directory that matches the plugin name.
if (name) {
for (const next of up(root)) {
if (next.endsWith(basename(name))) return next
}
}

// If there's no plugin name (typically just the root plugin), then we need
// to traverse the file system until we find a directory with a package.json
for (const next of up(root)) {
// Skip the bin directory
if (
basename(dirname(next)) === 'bin' &&
['dev', 'dev.cmd', 'dev.js', 'run', 'run.cmd', 'run.js'].includes(basename(next))
) {
continue
}

try {
const cur = join(next, 'package.json')
if (await safeReadJson<PJSON>(cur)) return dirname(cur)
} catch {}
}
}

/**
* Find package root for packages installed into node_modules. This will go up directories
* until it finds a node_modules directory with the plugin installed into it
*
* This is needed because some oclif plugins do not declare the `main` field in their package.json
* https://github.com/oclif/config/pull/289#issuecomment-983904051
*
* @returns string
* @param name string
* @param root string
*/
async function findRootLegacy(name: string | undefined, root: string): Promise<string | undefined> {
for (const next of up(root)) {
let cur
if (name) {
cur = join(next, 'node_modules', name, 'package.json')
if (await safeReadJson<PJSON>(cur)) return dirname(cur)

const pkg = await safeReadJson<PJSON>(join(next, 'package.json'))
if (pkg?.name === name) return next
} else {
cur = join(next, 'package.json')
if (await safeReadJson<PJSON>(cur)) return dirname(cur)
}
}
}

let pnp: {
getPackageInformation: typeof getPackageInformation
getLocator: (name: string, reference: string | [string, string]) => PackageLocator
getDependencyTreeRoots: () => PackageLocator[]
}

function maybeRequirePnpApi(root: string): unknown {
if (pnp) return pnp
// Require pnpapi directly from the plugin.
// The pnpapi module is only available if running in a pnp environment.
// Because of that we have to require it from the root.
// Solution taken from here: https://github.com/yarnpkg/berry/issues/1467#issuecomment-642869600
// eslint-disable-next-line node/no-missing-require
pnp = require(require.resolve('pnpapi', {paths: [root]}))

return pnp
}

const getKey = (locator: PackageLocator | string | [string, string] | undefined) => JSON.stringify(locator)
const isPeerDependency = (
pkg: PackageInformation | undefined,
parentPkg: PackageInformation | undefined,
name: string,
) => getKey(pkg?.packageDependencies.get(name)) === getKey(parentPkg?.packageDependencies.get(name))

/**
* Traverse PnP dependency tree to find package root
*
* Implementation taken from https://yarnpkg.com/advanced/pnpapi#traversing-the-dependency-tree
*/
function findPnpRoot(name: string, root: string): string | undefined {
maybeRequirePnpApi(root)
const seen = new Set()

const traverseDependencyTree = (locator: PackageLocator, parentPkg?: PackageInformation): string | undefined => {
// Prevent infinite recursion when A depends on B which depends on A
const key = getKey(locator)
if (seen.has(key)) return

const pkg = pnp.getPackageInformation(locator)

if (locator.name === name) {
return pkg.packageLocation
}

seen.add(key)

for (const [name, referencish] of pkg.packageDependencies) {
// Unmet peer dependencies
if (referencish === null) continue

// Avoid iterating on peer dependencies - very expensive
if (parentPkg !== null && isPeerDependency(pkg, parentPkg, name)) continue

const childLocator = pnp.getLocator(name, referencish)
const foundSomething = traverseDependencyTree(childLocator, pkg)
if (foundSomething) return foundSomething
}

// Important: This `delete` here causes the traversal to go over nodes even
// if they have already been traversed in another branch. If you don't need
// that, remove this line for a hefty speed increase.
seen.delete(key)
}

// Iterate on each workspace
for (const locator of pnp.getDependencyTreeRoots()) {
const foundSomething = traverseDependencyTree(locator)
if (foundSomething) return foundSomething
}
}

async function findRoot(name: string | undefined, root: string) {
if (name) {
let pkgPath
try {
pkgPath = resolvePackage(name, {paths: [root]})
} catch {}

if (pkgPath) return findPluginRoot(dirname(pkgPath), name)

return process.versions.pnp ? findPnpRoot(name, root) : findRootLegacy(name, root)
}

return findPluginRoot(root)
}

const cachedCommandCanBeUsed = (manifest: Manifest | undefined, id: string): boolean =>
Boolean(manifest?.commands[id] && 'isESM' in manifest.commands[id] && 'relativePath' in manifest.commands[id])

Expand Down
4 changes: 0 additions & 4 deletions src/config/util.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
const debug = require('debug')

export function resolvePackage(id: string, paths: {paths: string[]}): string {
return require.resolve(id, paths)
}

function displayWarnings() {
if (process.listenerCount('warning') > 1) return
process.on('warning', (warning: any) => {
Expand Down
174 changes: 174 additions & 0 deletions src/util/find-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* eslint-disable no-await-in-loop */
import type {PackageInformation, PackageLocator, getPackageInformation} from 'pnpapi'

import {basename, dirname, join} from 'node:path'

import {PJSON} from '../interfaces'
import {safeReadJson} from './fs'

// essentially just "cd .."
function* up(from: string) {
while (dirname(from) !== from) {
yield from
from = dirname(from)
}

yield from
}

/**
* Return the plugin root directory from a given file. This will `cd` up the file system until it finds
* a package.json and then return the dirname of that path.
*
* Example: node_modules/@oclif/plugin-version/dist/index.js -> node_modules/@oclif/plugin-version
*/
async function findPluginRoot(root: string, name?: string) {
// If we know the plugin name then we just need to traverse the file
// system until we find the directory that matches the plugin name.
if (name) {
for (const next of up(root)) {
if (next.endsWith(basename(name))) return next
}
}

// If there's no plugin name (typically just the root plugin), then we need
// to traverse the file system until we find a directory with a package.json
for (const next of up(root)) {
// Skip the bin directory
if (
basename(dirname(next)) === 'bin' &&
['dev', 'dev.cmd', 'dev.js', 'run', 'run.cmd', 'run.js'].includes(basename(next))
) {
continue
}

try {
const cur = join(next, 'package.json')
if (await safeReadJson<PJSON>(cur)) return dirname(cur)
} catch {}
}
}

/**
* Find plugin root directory for plugins installed into node_modules that don't have a `main` or `export`.
* This will go up directories until it finds a directory with the plugin installed into it.
*
* See https://github.com/oclif/config/pull/289#issuecomment-983904051
*/
async function findRootLegacy(name: string | undefined, root: string): Promise<string | undefined> {
for (const next of up(root)) {
let cur
if (name) {
cur = join(next, 'node_modules', name, 'package.json')
if (await safeReadJson<PJSON>(cur)) return dirname(cur)

const pkg = await safeReadJson<PJSON>(join(next, 'package.json'))
if (pkg?.name === name) return next
} else {
cur = join(next, 'package.json')
if (await safeReadJson<PJSON>(cur)) return dirname(cur)
}
}
}

let pnp: {
getPackageInformation: typeof getPackageInformation
getLocator: (name: string, reference: string | [string, string]) => PackageLocator
getDependencyTreeRoots: () => PackageLocator[]
}

/**
* The pnpapi module is only available if running in a pnp environment. Because of that
* we have to require it from the plugin.
*
* Solution taken from here: https://github.com/yarnpkg/berry/issues/1467#issuecomment-642869600
*/
function maybeRequirePnpApi(root: string): unknown {
if (pnp) return pnp
// eslint-disable-next-line node/no-missing-require
pnp = require(require.resolve('pnpapi', {paths: [root]}))
return pnp
}

const getKey = (locator: PackageLocator | string | [string, string] | undefined) => JSON.stringify(locator)
const isPeerDependency = (
pkg: PackageInformation | undefined,
parentPkg: PackageInformation | undefined,
name: string,
) => getKey(pkg?.packageDependencies.get(name)) === getKey(parentPkg?.packageDependencies.get(name))

/**
* Traverse PnP dependency tree to find plugin root directory.
*
* Implementation adapted from https://yarnpkg.com/advanced/pnpapi#traversing-the-dependency-tree
*/
function findPnpRoot(name: string, root: string): string | undefined {
maybeRequirePnpApi(root)
const seen = new Set()

const traverseDependencyTree = (locator: PackageLocator, parentPkg?: PackageInformation): string | undefined => {
// Prevent infinite recursion when A depends on B which depends on A
const key = getKey(locator)
if (seen.has(key)) return

const pkg = pnp.getPackageInformation(locator)

if (locator.name === name) {
return pkg.packageLocation
}

seen.add(key)

for (const [name, referencish] of pkg.packageDependencies) {
// Unmet peer dependencies
if (referencish === null) continue

// Avoid iterating on peer dependencies - very expensive
if (parentPkg !== null && isPeerDependency(pkg, parentPkg, name)) continue

const childLocator = pnp.getLocator(name, referencish)
const foundSomething = traverseDependencyTree(childLocator, pkg)
if (foundSomething) return foundSomething
}

// Important: This `delete` here causes the traversal to go over nodes even
// if they have already been traversed in another branch. If you don't need
// that, remove this line for a hefty speed increase.
seen.delete(key)
}

// Iterate on each workspace
for (const locator of pnp.getDependencyTreeRoots()) {
const foundSomething = traverseDependencyTree(locator)
if (foundSomething) return foundSomething
}
}

/**
* Returns the root directory of the plugin.
*
* It first attempts to use require.resolve to find the plugin root.
* If that returns a path, it will `cd` up the file system until if finds the package.json for the plugin
* Example: node_modules/@oclif/plugin-version/dist/index.js -> node_modules/@oclif/plugin-version
*
* If require.resolve throws an error, it will attempt to find the plugin root by traversing the file system.
* If we're in a PnP environment (determined by process.versions.pnp), it will use the pnpapi module to
* traverse the dependency tree. Otherwise, it will traverse the node_modules until it finds a package.json
* with a matching name.
*
* If no path is found, undefined is returned which will eventually result in a thrown Error from Plugin.
*/
export async function findRoot(name: string | undefined, root: string) {
if (name) {
let pkgPath
try {
pkgPath = require.resolve(name, {paths: [root]})
} catch {}

if (pkgPath) return findPluginRoot(dirname(pkgPath), name)

return process.versions.pnp ? findPnpRoot(name, root) : findRootLegacy(name, root)
}

return findPluginRoot(root)
}

0 comments on commit c5d87fc

Please sign in to comment.