feat: automatically switch to the right pnpm version (#8363)

close #8360
This commit is contained in:
Zoltan Kochan
2024-08-06 21:59:43 +02:00
committed by GitHub
parent 87439cdf35
commit 26b065c193
29 changed files with 399 additions and 60 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/cli-utils": major
---
Removed packageManager field check.

View File

@@ -0,0 +1,12 @@
---
"@pnpm/config": minor
"pnpm": minor
---
Added pnpm version management to pnpm. If the `manage-package-manager-versions` setting is set to `true`, pnpm will switch to the version specified in the `packageManager` field of `package.json` [#8363](https://github.com/pnpm/pnpm/pull/8363). This is the same field used by Corepack. Example:
```json
{
"packageManager": "pnpm@9.3.0"
}
```

View File

@@ -0,0 +1,5 @@
---
"@pnpm/env.path": major
---
Initial release.

View File

@@ -0,0 +1,5 @@
---
"@pnpm/plugin-commands-script-runners": patch
---
Added usage of `@pnpm/env.path`.

View File

@@ -1,6 +1,5 @@
import { PnpmError } from '@pnpm/error'
import { isExecutedByCorepack, packageManager } from '@pnpm/cli-meta'
import { logger, globalWarn } from '@pnpm/logger'
import { packageManager } from '@pnpm/cli-meta'
import { logger } from '@pnpm/logger'
import { checkPackage, UnsupportedEngineError, type WantedEngine } from '@pnpm/package-is-installable'
import { type SupportedArchitectures } from '@pnpm/types'
@@ -24,30 +23,6 @@ export function packageIsInstallable (
const currentPnpmVersion = packageManager.name === 'pnpm'
? packageManager.version
: undefined
if (pkg.packageManager && !isExecutedByCorepack()) {
const [pmName, pmReference] = pkg.packageManager.split('@')
if (pmName && pmName !== 'pnpm') {
const msg = `This project is configured to use ${pmName}`
if (opts.packageManagerStrict) {
throw new PnpmError('OTHER_PM_EXPECTED', msg)
} else {
globalWarn(msg)
}
} else if (currentPnpmVersion && opts.packageManagerStrictVersion && !pmReference.includes(':')) {
// pmReference is semantic versioning, not URL
const [requiredPnpmVersion] = pmReference.split('+')
if (requiredPnpmVersion && requiredPnpmVersion !== currentPnpmVersion) {
const msg = `This project is configured to use v${requiredPnpmVersion} of pnpm. Your current pnpm is v${currentPnpmVersion}`
if (opts.packageManagerStrict) {
throw new PnpmError('BAD_PM_VERSION', msg, {
hint: 'If you want to bypass this version check, you can set the "package-manager-strict" configuration to "false" or set the "COREPACK_ENABLE_STRICT" environment variable to "0"',
})
} else {
globalWarn(msg)
}
}
}
}
const err = checkPackage(pkgPath, pkg, {
nodeVersion: opts.nodeVersion,
pnpmVersion: currentPnpmVersion,

View File

@@ -60,8 +60,22 @@ export async function parseCliArgs (
commandName = opts.fallbackCommand!
inputArgv.unshift(opts.fallbackCommand!)
// The run command has special casing for --help and is handled further below.
} else if (cmd !== 'run' && noptExploratoryResults['help']) {
return getParsedArgsForHelp()
} else if (cmd !== 'run') {
if (noptExploratoryResults['help']) {
return getParsedArgsForHelp()
}
if (noptExploratoryResults['version'] || noptExploratoryResults['v']) {
return {
argv: noptExploratoryResults.argv,
cmd: null,
options: {
version: true,
},
params: noptExploratoryResults.argv.remain,
unknownOptions: new Map(),
fallbackCommandUsed: false,
}
}
}
function getParsedArgsForHelp (): ParsedCliArgs {

View File

@@ -10,6 +10,11 @@ import type { Hooks } from '@pnpm/pnpmfile'
export type UniversalOptions = Pick<Config, 'color' | 'dir' | 'rawConfig' | 'rawLocalConfig'>
export interface WantedPackageManager {
name: string
version?: string
}
export interface Config {
allProjects?: Project[]
selectedProjectsGraph?: ProjectsGraph
@@ -74,6 +79,7 @@ export interface Config {
name: string
version: string
}
wantedPackageManager?: WantedPackageManager
preferOffline?: boolean
sideEffectsCache?: boolean // for backward compatibility
sideEffectsCacheReadonly?: boolean // for backward compatibility
@@ -199,6 +205,7 @@ export interface Config {
virtualStoreDirMaxLength: number
peersSuffixMaxLength?: number
strictStorePkgContentCheck: boolean
managePackageManagerVersions: boolean
}
export interface ConfigWithDeprecatedSettings extends Config {

View File

@@ -25,6 +25,7 @@ import {
type Config,
type ConfigWithDeprecatedSettings,
type UniversalOptions,
type WantedPackageManager,
} from './Config'
import { getWorkspaceConcurrency } from './concurrency'
import { readWorkspaceManifest } from '@pnpm/workspace.read-manifest'
@@ -35,7 +36,7 @@ export { types }
export { getOptionsFromRootManifest, type OptionsFromRootManifest } from './getOptionsFromRootManifest'
export * from './readLocalConfig'
export type { Config, UniversalOptions }
export type { Config, UniversalOptions, WantedPackageManager }
type CamelToKebabCase<S extends string> = S extends `${infer T}${infer U}`
? `${T extends Capitalize<T> ? '-' : ''}${Lowercase<T>}${CamelToKebabCase<U>}`
@@ -147,6 +148,7 @@ export async function getConfig (opts: {
'ignore-workspace-root-check': false,
'link-workspace-packages': false,
'lockfile-include-tarball-url': false,
'manage-package-manager-versions': false,
'modules-cache-max-age': 7 * 24 * 60, // 7 days
'dlx-cache-max-age': 24 * 60, // 1 day
'node-linker': 'isolated',
@@ -483,8 +485,13 @@ export async function getConfig (opts: {
}
pnpmConfig.rootProjectManifestDir = pnpmConfig.lockfileDir ?? pnpmConfig.workspaceDir ?? pnpmConfig.dir
pnpmConfig.rootProjectManifest = await safeReadProjectManifestOnly(pnpmConfig.rootProjectManifestDir) ?? undefined
if (pnpmConfig.rootProjectManifest?.workspaces?.length && !pnpmConfig.workspaceDir) {
warnings.push('The "workspaces" field in package.json is not supported by pnpm. Create a "pnpm-workspace.yaml" file instead.')
if (pnpmConfig.rootProjectManifest != null) {
if (pnpmConfig.rootProjectManifest.workspaces?.length && !pnpmConfig.workspaceDir) {
warnings.push('The "workspaces" field in package.json is not supported by pnpm. Create a "pnpm-workspace.yaml" file instead.')
}
if (pnpmConfig.rootProjectManifest.packageManager) {
pnpmConfig.wantedPackageManager = parsePackageManager(pnpmConfig.rootProjectManifest.packageManager)
}
}
if (pnpmConfig.workspaceDir != null) {
@@ -504,3 +511,15 @@ function getProcessEnv (env: string): string | undefined {
process.env[env.toUpperCase()] ??
process.env[env.toLowerCase()]
}
function parsePackageManager (packageManager: string): { name: string, version: string | undefined } {
const [name, pmReference] = packageManager.split('@')
// pmReference is semantic versioning, not URL
if (pmReference.includes(':')) return { name, version: undefined }
// Remove the integrity hash. Ex: "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903"
const [version] = pmReference.split('+')
return {
name,
version,
}
}

View File

@@ -50,6 +50,7 @@ export const types = Object.assign({
'lockfile-include-tarball-url': Boolean,
'lockfile-only': Boolean,
loglevel: ['silent', 'error', 'warn', 'info', 'debug'],
'manage-package-manager-versions': Boolean,
maxsockets: Number,
'modules-cache-max-age': Number,
'dlx-cache-max-age': Number,

15
env/path/README.md vendored Normal file
View File

@@ -0,0 +1,15 @@
# @pnpm/env.path
> Functions for changing PATH env variable
[![npm version](https://img.shields.io/npm/v/@pnpm/env.path.svg)](https://www.npmjs.com/package/@pnpm/env.path)
## Installation
```sh
pnpm add @pnpm/env.path
```
## License
MIT

3
env/path/jest.config.js vendored Normal file
View File

@@ -0,0 +1,3 @@
const config = require('../../jest.config.js')
module.exports = config

42
env/path/package.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@pnpm/env.path",
"version": "0.0.0",
"description": "Functions for changing PATH env variable",
"funding": "https://opencollective.com/pnpm",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib",
"!*.map"
],
"exports": {
".": "./lib/index.js"
},
"engines": {
"node": ">=18.12"
},
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"_test": "jest",
"test": "pnpm run compile && pnpm run _test",
"prepublishOnly": "pnpm run compile",
"compile": "tsc --build && pnpm run lint --fix"
},
"repository": "https://github.com/pnpm/pnpm/blob/main/env/path",
"keywords": [
"pnpm9",
"pnpm",
"env"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/env/path#readme",
"dependencies": {
"path-name": "catalog:"
},
"devDependencies": {
"@pnpm/env.path": "workspace:*"
}
}

12
env/path/src/index.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import path from 'path'
import PATH from 'path-name'
export function prependDirsToPath (prependDirs: string[], env = process.env): { name: string, value: string } {
return {
name: PATH,
value: [
...prependDirs,
...(env[PATH] != null ? [env[PATH]] : []),
].join(path.delimiter),
}
}

14
env/path/test/index.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import path from 'path'
import { prependDirsToPath } from '@pnpm/env.path'
import PATH from 'path-name'
test('prependDirsToPath', () => {
expect(prependDirsToPath(['foo'], {})).toStrictEqual({
name: PATH,
value: 'foo',
})
expect(prependDirsToPath(['foo'], { [PATH]: 'bar' })).toStrictEqual({
name: PATH,
value: `foo${path.delimiter}bar`,
})
})

17
env/path/test/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"rootDir": "."
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

12
env/path/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": []
}

8
env/path/tsconfig.lint.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -50,6 +50,7 @@
"@pnpm/config": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/crypto.base32-hash": "workspace:*",
"@pnpm/env.path": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/lifecycle": "workspace:*",
"@pnpm/log.group": "catalog:",
@@ -65,7 +66,6 @@
"didyoumean2": "catalog:",
"execa": "catalog:",
"p-limit": "catalog:",
"path-name": "catalog:",
"ramda": "catalog:",
"realpath-missing": "catalog:",
"render-help": "catalog:",

View File

@@ -9,7 +9,7 @@ import { sortPackages } from '@pnpm/sort-packages'
import { type Project, type ProjectsGraph, type ProjectRootDir, type ProjectRootDirRealPath } from '@pnpm/types'
import execa from 'execa'
import pLimit from 'p-limit'
import PATH from 'path-name'
import { prependDirsToPath } from '@pnpm/env.path'
import pick from 'ramda/src/pick'
import renderHelp from 'render-help'
import { existsInDir } from './existsInDir'
@@ -355,11 +355,10 @@ function isErrorCommandNotFound (command: string, error: CommandError, prependPa
// Windows
if (process.platform === 'win32') {
const prepend = prependPaths.join(path.delimiter)
const whichPath = process.env[PATH] ? `${prepend}${path.delimiter}${process.env[PATH] as string}` : prepend
const { value: path } = prependDirsToPath(prependPaths)
return !which.sync(command, {
nothrow: true,
path: whichPath,
path,
})
}

View File

@@ -1,6 +1,6 @@
import { PnpmError } from '@pnpm/error'
import { prependDirsToPath } from '@pnpm/env.path'
import path from 'path'
import PATH from 'path-name'
export interface Env extends NodeJS.ProcessEnv {
npm_config_user_agent: string
@@ -22,13 +22,11 @@ export function makeEnv (
throw new PnpmError('BAD_PATH_DIR', `Cannot add ${prependPath} to PATH because it contains the path delimiter character (${path.delimiter})`)
}
}
const pathEnv = prependDirsToPath(opts.prependPaths)
return {
...process.env,
...opts.extraEnv,
npm_config_user_agent: opts.userAgent ?? 'pnpm',
[PATH]: [
...opts.prependPaths,
process.env[PATH],
].join(path.delimiter),
[pathEnv.name]: pathEnv.value,
}
}

View File

@@ -27,6 +27,9 @@
{
"path": "../../config/config"
},
{
"path": "../../env/path"
},
{
"path": "../../env/plugin-commands-env"
},

22
pnpm-lock.yaml generated
View File

@@ -1700,6 +1700,16 @@ importers:
specifier: 'catalog:'
version: 7.5.3
env/path:
dependencies:
path-name:
specifier: 'catalog:'
version: 1.0.0
devDependencies:
'@pnpm/env.path':
specifier: workspace:*
version: 'link:'
env/plugin-commands-env:
dependencies:
'@pnpm/cli-utils':
@@ -2094,6 +2104,9 @@ importers:
'@pnpm/crypto.base32-hash':
specifier: workspace:*
version: link:../../packages/crypto.base32-hash
'@pnpm/env.path':
specifier: workspace:*
version: link:../../env/path
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
@@ -2142,9 +2155,6 @@ importers:
p-limit:
specifier: 'catalog:'
version: 3.1.0
path-name:
specifier: 'catalog:'
version: 1.0.0
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
@@ -5423,6 +5433,12 @@ importers:
'@pnpm/dependency-path':
specifier: workspace:*
version: link:../packages/dependency-path
'@pnpm/env.path':
specifier: workspace:*
version: link:../env/path
'@pnpm/error':
specifier: workspace:*
version: link:../packages/error
'@pnpm/filter-workspace-packages':
specifier: workspace:*
version: link:../workspace/filter-workspace-packages

View File

@@ -35,6 +35,8 @@
"@pnpm/crypto.base32-hash": "workspace:*",
"@pnpm/default-reporter": "workspace:*",
"@pnpm/dependency-path": "workspace:*",
"@pnpm/env.path": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/filter-workspace-packages": "workspace:*",
"@pnpm/find-workspace-dir": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",

View File

@@ -3,28 +3,28 @@ if (!global['pnpm__startedAt']) {
global['pnpm__startedAt'] = Date.now()
}
import loudRejection from 'loud-rejection'
import { packageManager } from '@pnpm/cli-meta'
import { packageManager, isExecutedByCorepack } from '@pnpm/cli-meta'
import { getConfig } from '@pnpm/cli-utils'
import {
type Config,
} from '@pnpm/config'
import { type Config, type WantedPackageManager } from '@pnpm/config'
import { executionTimeLogger, scopeLogger } from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
import { filterPackagesFromDir } from '@pnpm/filter-workspace-packages'
import { globalWarn, logger } from '@pnpm/logger'
import { type ParsedCliArgs } from '@pnpm/parse-cli-args'
import { prepareExecutionEnv } from '@pnpm/plugin-commands-env'
import { finishWorkers } from '@pnpm/worker'
import chalk from 'chalk'
import { checkForUpdates } from './checkForUpdates'
import { pnpmCmds, rcOptionsTypes } from './cmd'
import { formatUnknownOptionsError } from './formatError'
import { parseCliArgs } from './parseCliArgs'
import { initReporter, type ReporterType } from './reporter'
import { isCI } from 'ci-info'
import path from 'path'
import isEmpty from 'ramda/src/isEmpty'
import stripAnsi from 'strip-ansi'
import which from 'which'
import { checkForUpdates } from './checkForUpdates'
import { pnpmCmds, rcOptionsTypes } from './cmd'
import { formatUnknownOptionsError } from './formatError'
import { parseCliArgs } from './parseCliArgs'
import { initReporter, type ReporterType } from './reporter'
import { switchCliVersion } from './switchCliVersion'
export const REPORTER_INITIALIZED = Symbol('reporterInitialized')
@@ -98,6 +98,13 @@ export async function main (inputArgv: string[]): Promise<void> {
checkUnknownSetting: false,
ignoreNonAuthSettingsFromLocal: isDlxCommand,
}) as typeof config
if (!isExecutedByCorepack() && config.wantedPackageManager != null) {
if (config.managePackageManagerVersions) {
await switchCliVersion(config)
} else {
checkPackageManager(config.wantedPackageManager, config)
}
}
if (isDlxCommand) {
config.useStderr = true
}
@@ -119,6 +126,10 @@ export async function main (inputArgv: string[]): Promise<void> {
process.exitCode = 1
return
}
if (cmd == null && cliOptions.version) {
console.log(packageManager.version)
return
}
let write: (text: string) => void = process.stdout.write.bind(process.stdout)
// chalk reads the FORCE_COLOR env variable
@@ -325,3 +336,28 @@ function printError (message: string, hint?: string): void {
console.log(hint)
}
}
function checkPackageManager (pm: WantedPackageManager, config: Config): void {
if (!pm.name) return
if (pm.name !== 'pnpm') {
const msg = `This project is configured to use ${pm.name}`
if (config.packageManagerStrict) {
throw new PnpmError('OTHER_PM_EXPECTED', msg)
}
globalWarn(msg)
} else {
const currentPnpmVersion = packageManager.name === 'pnpm'
? packageManager.version
: undefined
if (currentPnpmVersion && config.packageManagerStrictVersion && pm.version && pm.version !== currentPnpmVersion) {
const msg = `This project is configured to use v${pm.version} of pnpm. Your current pnpm is v${currentPnpmVersion}`
if (config.packageManagerStrict) {
throw new PnpmError('BAD_PM_VERSION', msg, {
hint: 'If you want to bypass this version check, you can set the "package-manager-strict" configuration to "false" or set the "COREPACK_ENABLE_STRICT" environment variable to "0"',
})
} else {
globalWarn(msg)
}
}
}
}

View File

@@ -8,12 +8,6 @@ const argv = process.argv.slice(2)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
; (async () => {
switch (argv[0]) {
case '-v':
case '--version': {
const { version } = (await import('@pnpm/cli-meta')).packageManager
console.log(version)
break
}
// commands that are passed through to npm:
case 'access':
case 'adduser':

View File

@@ -0,0 +1,53 @@
import fs from 'fs'
import path from 'path'
import { type Config } from '@pnpm/config'
import { globalWarn } from '@pnpm/logger'
import { detectIfCurrentPkgIsExecutable, packageManager } from '@pnpm/cli-meta'
import { prependDirsToPath } from '@pnpm/env.path'
import spawn from 'cross-spawn'
import semver from 'semver'
import { pnpmCmds } from './cmd'
export async function switchCliVersion (config: Config): Promise<void> {
const pm = config.wantedPackageManager
if (pm == null || pm.name !== 'pnpm' || pm.version == null || pm.version === packageManager.version) return
if (!semver.valid(pm.version)) {
globalWarn(`Cannot switch to pnpm@${pm.version}: "${pm.version}" is not a valid version`)
return
}
const pkgName = detectIfCurrentPkgIsExecutable() ? getExePackageName() : 'pnpm'
const dir = path.join(config.pnpmHomeDir, '.tools', pkgName.replaceAll('/', '+'), pm.version)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'package.json'), '{}')
await pnpmCmds.add(
{
...config,
dir,
lockfileDir: dir,
bin: path.join(dir, 'bin'),
},
[`${pkgName}@${pm.version}`]
)
}
const pnpmEnv = prependDirsToPath([path.join(dir, 'bin')])
const { status } = spawn.sync('pnpm', process.argv.slice(2), {
stdio: 'inherit',
env: {
...process.env,
[pnpmEnv.name]: pnpmEnv.value,
},
})
process.exit(status ?? 0)
}
function getExePackageName () {
const platform = process.platform === 'win32'
? 'win'
: process.platform === 'darwin'
? 'macos'
: process.platform
const arch = platform === 'win' && process.arch === 'ia32' ? 'x86' : process.arch
return `@pnpm/${platform}-${arch}`
}

View File

@@ -0,0 +1,60 @@
import path from 'path'
import fs from 'fs'
import { prepare } from '@pnpm/prepare'
import { sync as writeJsonFile } from 'write-json-file'
import { execPnpmSync } from './utils'
test('switch to the pnpm version specified in the packageManager field of package.json, when manager-package-manager=versions is true', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
fs.writeFileSync('.npmrc', 'manage-package-manager-versions=true')
writeJsonFile('package.json', {
packageManager: 'pnpm@9.3.0',
})
const { stdout } = execPnpmSync(['help'], { env })
expect(stdout.toString()).toContain('Version 9.3.0')
})
test('do not switch to the pnpm version specified in the packageManager field of package.json', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
writeJsonFile('package.json', {
packageManager: 'pnpm@9.3.0',
})
const { stdout } = execPnpmSync(['help'], { env })
expect(stdout.toString()).not.toContain('Version 9.3.0')
})
test('do not switch to pnpm version that is specified not with a semver version', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
fs.writeFileSync('.npmrc', 'manage-package-manager-versions=true')
writeJsonFile('package.json', {
packageManager: 'pnpm@kevva/is-positive',
})
const { stdout } = execPnpmSync(['help'], { env })
expect(stdout.toString()).toContain('Cannot switch to pnpm@kevva/is-positive')
})
test('do not switch to pnpm version when a range is specified', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
fs.writeFileSync('.npmrc', 'manage-package-manager-versions=true')
writeJsonFile('package.json', {
packageManager: 'pnpm@^9.3.0',
})
const { stdout } = execPnpmSync(['help'], { env })
expect(stdout.toString()).toContain('Cannot switch to pnpm@^9.3.0')
})

View File

@@ -52,6 +52,9 @@
{
"path": "../../config/plugin-commands-config"
},
{
"path": "../../env/path"
},
{
"path": "../../env/plugin-commands-env"
},
@@ -82,6 +85,9 @@
{
"path": "../../packages/dependency-path"
},
{
"path": "../../packages/error"
},
{
"path": "../../packages/plugin-commands-doctor"
},

View File

@@ -55,6 +55,9 @@
{
"path": "../config/plugin-commands-config"
},
{
"path": "../env/path"
},
{
"path": "../env/plugin-commands-env"
},
@@ -85,6 +88,9 @@
{
"path": "../packages/dependency-path"
},
{
"path": "../packages/error"
},
{
"path": "../packages/plugin-commands-doctor"
},