From c5d87fc9bb30973fb055143c92df7bc27de16554 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 19 Oct 2023 13:34:50 -0600 Subject: [PATCH] feat: better support yarn PnP --- src/config/plugin.ts | 161 +------------------------------------- src/config/util.ts | 4 - src/util/find-root.ts | 174 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 161 deletions(-) create mode 100644 src/util/find-root.ts diff --git a/src/config/plugin.ts b/src/config/plugin.ts index e9f833748..183ce2e1d 100644 --- a/src/config/plugin.ts +++ b/src/config/plugin.ts @@ -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' @@ -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(__dirname, '..', '..', 'package.json') @@ -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(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 { - for (const next of up(root)) { - let cur - if (name) { - cur = join(next, 'node_modules', name, 'package.json') - if (await safeReadJson(cur)) return dirname(cur) - - const pkg = await safeReadJson(join(next, 'package.json')) - if (pkg?.name === name) return next - } else { - cur = join(next, 'package.json') - if (await safeReadJson(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]) diff --git a/src/config/util.ts b/src/config/util.ts index bbcb2bed6..77b33da03 100644 --- a/src/config/util.ts +++ b/src/config/util.ts @@ -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) => { diff --git a/src/util/find-root.ts b/src/util/find-root.ts new file mode 100644 index 000000000..e8e5542fc --- /dev/null +++ b/src/util/find-root.ts @@ -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(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 { + for (const next of up(root)) { + let cur + if (name) { + cur = join(next, 'node_modules', name, 'package.json') + if (await safeReadJson(cur)) return dirname(cur) + + const pkg = await safeReadJson(join(next, 'package.json')) + if (pkg?.name === name) return next + } else { + cur = join(next, 'package.json') + if (await safeReadJson(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) +}