diff --git a/.pnp.cjs b/.pnp.cjs index 1a769d037466..0ca97ca7f16b 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -186,6 +186,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "name": "@yarnpkg/shell", "reference": "workspace:packages/yarnpkg-shell" }, + { + "name": "@yarnpkg/tools", + "reference": "workspace:packages/yarnpkg-tools" + }, { "name": "pkg-tests-core", "reference": "workspace:packages/acceptance-tests/pkg-tests-core" @@ -242,6 +246,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@yarnpkg/pnpify", ["workspace:packages/yarnpkg-pnpify"]], ["@yarnpkg/sdks", ["workspace:packages/yarnpkg-sdks"]], ["@yarnpkg/shell", ["workspace:packages/yarnpkg-shell"]], + ["@yarnpkg/tools", ["workspace:packages/yarnpkg-tools"]], ["acceptance-tests-229a13", ["workspace:packages/acceptance-tests"]], ["pkg-tests-core", ["workspace:packages/acceptance-tests/pkg-tests-core"]], ["pkg-tests-fixtures", ["workspace:packages/acceptance-tests/pkg-tests-fixtures"]], @@ -14885,6 +14890,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "SOFT", }] ]], + ["@yarnpkg/tools", [ + ["workspace:packages/yarnpkg-tools", { + "packageLocation": "./packages/yarnpkg-tools/", + "packageDependencies": [ + ["@yarnpkg/tools", "workspace:packages/yarnpkg-tools"] + ], + "linkType": "SOFT", + }] + ]], ["@zkochan/cmd-shim", [ ["npm:5.1.0", { "packageLocation": "./.yarn/cache/@zkochan-cmd-shim-npm-5.1.0-3bdda00327-7be69cfede.zip/node_modules/@zkochan/cmd-shim/", diff --git a/packages/yarnpkg-tools/package.json b/packages/yarnpkg-tools/package.json new file mode 100644 index 000000000000..01553bf5f86c --- /dev/null +++ b/packages/yarnpkg-tools/package.json @@ -0,0 +1,27 @@ +{ + "name": "@yarnpkg/tools", + "version": "1.0.0", + "license": "BSD-2-Clause", + "main": "./sources/index.ts", + "scripts": { + "postpack": "rm -rf lib", + "prepack": "run build:compile \"$(pwd)\"", + "release": "yarn npm publish", + "test": "run test:unit \"$(pwd)\"" + }, + "publishConfig": { + "main": "./lib/index.js", + "typings": "./lib/index.d.ts" + }, + "files": [ + "/lib/**/*" + ], + "repository": { + "type": "git", + "url": "ssh://git@github.com/yarnpkg/berry.git", + "directory": "packages/yarnpkg-tools" + }, + "engines": { + "node": ">=12 <14 || 14.2 - 14.9 || >14.10.0" + } +} diff --git a/packages/yarnpkg-tools/sources/index.ts b/packages/yarnpkg-tools/sources/index.ts new file mode 100644 index 000000000000..028706f20817 --- /dev/null +++ b/packages/yarnpkg-tools/sources/index.ts @@ -0,0 +1,293 @@ +import fs from 'fs'; +import path from 'path'; + +const MANIFEST_FILENAME = `package.json`; +const NODE_MODULES_FILENAME = `node_modules`; +const PNP_JS_FILENAME = `.pnp.js`; + +const nodeModulesRegExp = /[\\/]node_modules[\\/](@[^\\/]*[\\/])?([^@\\/][^\\/]*)$/; + +class AmbiguousPackageManagerChoiceError extends Error { + constructor(message: string, cwd: string) { + super(`${message} Please specify your package manager choice using the "packageManager" field in ${path.join(cwd, MANIFEST_FILENAME)}.`); + this.name = `AmbiguousPackageManagerChoiceError`; + } +} + +export enum PackageManager { + YARN = `yarn`, + PNPM = `pnpm`, + NPM = `npm`, +} + +export const Lockfile = { + [PackageManager.YARN]: `yarn.lock`, + [PackageManager.PNPM]: `pnpm-lock.yaml`, + [PackageManager.NPM]: `package-lock.json`, +}; + +type BasePackageManagerSpec = { + name: T; + reason: string; +}; + +type MakePackageManagerSpec = BasePackageManagerSpec & O; + +export type PackageManagerSpec = + | MakePackageManagerSpec + | MakePackageManagerSpec + | MakePackageManagerSpec; + +/** + * Tries to detect the package manager from the user-agent. + */ +function detectPackageManagerFromUserAgent(env: NodeJS.ProcessEnv): PackageManagerSpec | null { + const userAgent = env.npm_config_user_agent; + if (typeof userAgent === `undefined`) + return null; + + const fields = userAgent.split(` `); + + for (const field of fields) { + const [name, value] = field.split(`/`); + + if (name === PackageManager.YARN) { + return { + name: PackageManager.YARN, + reason: `Found "${field}" in npm_config_user_agent`, + isClassic: value.startsWith(`0`) || value.startsWith(`1`), + }; + } + + for (const pm of [PackageManager.PNPM, PackageManager.NPM] as const) { + if (name === pm) { + return { + name, + reason: `Found "${field}" in npm_config_user_agent`, + }; + } + } + } + + return null; +} + +/** + * Tries to detect the package manager by going up the directory tree and searching for install artifacts. + * + * Yarn modern can't hit this codepath because it always generates a lockfile. + */ +function detectPackageManagerFromInstallArtifacts(initialCwd: string): PackageManagerSpec | null { + let nextCwd = initialCwd; + let currCwd = ``; + + const packageManagerSpecs: Array = []; + + while (nextCwd !== currCwd && packageManagerSpecs.length === 0) { + currCwd = nextCwd; + nextCwd = path.dirname(currCwd); + + if (nodeModulesRegExp.test(currCwd)) + continue; + + const nodeModulesFolder = path.join(currCwd, NODE_MODULES_FILENAME); + if (fs.existsSync(nodeModulesFolder)) { + // Only generated by Yarn classic when operating in NM mode + if (fs.existsSync(path.join(nodeModulesFolder, `.yarn-integrity`))) { + packageManagerSpecs.push({ + name: PackageManager.YARN, + reason: `Found .yarn-integrity in ${nodeModulesFolder}`, + isClassic: true, + }); + } + + // Only generated by PNPM when operating in any mode + if (fs.existsSync(path.join(nodeModulesFolder, `.modules.yaml`))) { + packageManagerSpecs.push({ + name: PackageManager.PNPM, + reason: `Found .modules.yaml in ${nodeModulesFolder}`, + }); + } + + // Only generated by npm@7 + if (fs.existsSync(path.join(nodeModulesFolder, `.package-lock.json`))) { + packageManagerSpecs.push({ + name: PackageManager.NPM, + reason: `Found .package-lock.json in ${nodeModulesFolder}`, + }); + } + } + + // - Yarn modern can't hit this codepath because it always has a lockfile + // - PNPM can't hit this codepath because it always generates node_modules/.modules.yaml + // - Because of this, it's safe to assume it's Yarn classic operating in PnP mode + if (fs.existsSync(path.join(currCwd, PNP_JS_FILENAME)) && !fs.existsSync(path.join(nodeModulesFolder, `.modules.yaml`))) { + packageManagerSpecs.push({ + name: PackageManager.YARN, + reason: `Found .pnp.js in ${currCwd}`, + isClassic: true, + }); + } + } + + + if (packageManagerSpecs.length === 0) + return null; + + if (packageManagerSpecs.length > 1) + throw new AmbiguousPackageManagerChoiceError(`Multiple install artifacts corresponding to the following package managers found: ${packageManagerSpecs.map(({name}) => JSON.stringify(name)).join(`, `)}`, currCwd); + + return packageManagerSpecs[0]; +} + +/** + * Tries to detect the package manager by going up the directory tree and searching for lockfiles. + */ +function detectPackageManagerFromLockfiles(initialCwd: string): PackageManagerSpec | null { + let nextCwd = initialCwd; + let currCwd = ``; + + const packageManagerSpecs: Array = []; + + while (nextCwd !== currCwd && packageManagerSpecs.length === 0) { + currCwd = nextCwd; + nextCwd = path.dirname(currCwd); + + if (nodeModulesRegExp.test(currCwd)) + continue; + + if (fs.existsSync(path.join(currCwd, Lockfile[PackageManager.YARN]))) { + const lockfile = fs.readFileSync(path.join(currCwd, Lockfile[PackageManager.YARN]), `utf8`); + // Modern lockfiles always have a "__metadata" key. + if (lockfile.match(/^__metadata:$/m)) { + packageManagerSpecs.push({ + name: PackageManager.YARN, + isClassic: false, + reason: `Found ${Lockfile[PackageManager.YARN]} containing "__metadata:" key in ${currCwd}`, + }); + } else { + packageManagerSpecs.push({ + name: PackageManager.YARN, + isClassic: true, + reason: `Found ${Lockfile[PackageManager.YARN]} not containing "__metadata:" key in ${currCwd}`, + }); + } + } + + for (const pm of [PackageManager.PNPM, PackageManager.NPM] as const) { + if (fs.existsSync(path.join(currCwd, Lockfile[pm]))) { + packageManagerSpecs.push({ + name: pm, + reason: `Found ${Lockfile[pm]} in ${currCwd}`, + }); + } + } + } + + if (packageManagerSpecs.length === 0) + return null; + + if (packageManagerSpecs.length > 1) + throw new AmbiguousPackageManagerChoiceError(`Multiple lockfiles found: ${packageManagerSpecs.map(({name}) => JSON.stringify(Lockfile[name])).join(`, `)}`, currCwd); + + return packageManagerSpecs[0]; +} + +/** + * Tries to detect the package manager from the closest manifest going up the directory tree by reading the "packageManager" field. + */ +function detectPackageManagerFromManifest(initialCwd: string): PackageManagerSpec | null { + let nextCwd = initialCwd; + let currCwd = ``; + + let selection: { + data: any; + manifestPath: string; + } | null = null; + + while (nextCwd !== currCwd && (!selection || !selection.data.packageManager)) { + currCwd = nextCwd; + nextCwd = path.dirname(currCwd); + + if (nodeModulesRegExp.test(currCwd)) + continue; + + const manifestPath = path.join(currCwd, MANIFEST_FILENAME); + if (!fs.existsSync(manifestPath)) + continue; + + const content = fs.readFileSync(manifestPath, `utf8`); + + let data; + try { + data = JSON.parse(content); + } catch {} + + if (typeof data !== `object` || data === null) + throw new Error(`Invalid package.json in ${path.relative(initialCwd, manifestPath)}`); + + selection = {data, manifestPath}; + } + + if (selection === null) + return null; + + const rawPmSpec = selection.data.packageManager; + if (typeof rawPmSpec !== `string`) + return null; + + const [name, value] = rawPmSpec.split(`@`); + if (name === PackageManager.YARN) { + return { + name: PackageManager.YARN, + reason: `Found "${rawPmSpec}" in the "packageManager" field of ${selection.manifestPath}`, + isClassic: value.startsWith(`0`) || value.startsWith(`1`), + }; + } + + for (const pm of [PackageManager.PNPM, PackageManager.NPM] as const) { + if (name === pm) { + return { + name, + reason: `Found "${rawPmSpec}" in the "packageManager" field of ${selection.manifestPath}`, + }; + } + } + + return null; +} + +/** + * Tries to detect the package manager from the current project. + * + * _**Important:**_ You likely want to use `detectPackageManager` instead. + * + * @internal + */ +export function detectPackageManagerFromProject(cwd: string): PackageManagerSpec | null { + const manifestPm = detectPackageManagerFromManifest(cwd); + if (manifestPm !== null) + return manifestPm; + + const lockfilePm = detectPackageManagerFromLockfiles(cwd); + if (lockfilePm !== null) + return lockfilePm; + + const installArtifactPm = detectPackageManagerFromInstallArtifacts(cwd); + if (installArtifactPm !== null) + return installArtifactPm; + + return null; +} + +export function detectPackageManager(cwd: string, env: NodeJS.ProcessEnv = process.env): PackageManagerSpec | null { + const projectPm = detectPackageManagerFromProject(cwd); + if (projectPm !== null) + return projectPm; + + const userAgentPm = detectPackageManagerFromUserAgent(env); + if (userAgentPm !== null) + return userAgentPm; + + return null; +} diff --git a/yarn.lock b/yarn.lock index 84821b3e5010..2ce7806c6f30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6121,6 +6121,12 @@ __metadata: languageName: unknown linkType: soft +"@yarnpkg/tools@workspace:packages/yarnpkg-tools": + version: 0.0.0-use.local + resolution: "@yarnpkg/tools@workspace:packages/yarnpkg-tools" + languageName: unknown + linkType: soft + "@zkochan/cmd-shim@npm:^5.1.0": version: 5.1.0 resolution: "@zkochan/cmd-shim@npm:5.1.0"