-
Notifications
You must be signed in to change notification settings - Fork 73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: better support yarn PnP #839
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
8e217a6
feat: support pnp plugins
mdonnalley 914f148
refactor: improve implementation
mdonnalley c5d87fc
feat: better support yarn PnP
mdonnalley eb15072
Merge branch 'main' into mdonnalley/pnp-plugins
mdonnalley a721d88
Merge branch 'main' into mdonnalley/pnp-plugins
mdonnalley 6baa39f
chore(release): 3.4.1-dev.0 [skip ci]
svc-cli-bot d5dc324
Merge branch 'main' into mdonnalley/pnp-plugins
mdonnalley c2fc8d2
fix: safely fail if pnpapi cannot be found
mdonnalley File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
/* 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 | ||
try { | ||
// eslint-disable-next-line node/no-missing-require | ||
pnp = require(require.resolve('pnpapi', {paths: [root]})) | ||
return pnp | ||
} catch {} | ||
} | ||
|
||
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) | ||
|
||
if (!pnp) return | ||
|
||
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what I referenced with
In #806
And I'm not sure if the issue is fixed?
Suppose that someone has two workspaces and they each contain a different version of the oclif plugin, this wouldn't necessarily take the oclif plugin in the correct workspace, but rather the first one found. This might lead to frustrating issues where someone updates their plugin version (in the workspace they have that oclif version installed in) but it doesn't do anything because oclif picks a workspace that has another version.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@danieltroger This might be a scenario where I prefer to wait until someone actually has this issue. Also I see this code path as a fallback for when people do not specify a
main
orexports
, which is something we strongly advise against because it's much more performant to do arequire.resolve
than it is to traverse the dependency tree for a matching plugin.So if someone were to have such an issue, I would first suggest that they define
main
orexports
in their package.json.