UNPKG

@manypkg/cli

Version:

Manypkg is a linter for `package.json` files in Yarn, npm, Lerna, pnpm or Rush monorepos.

809 lines (790 loc) 26.7 kB
import pc from 'picocolors'; import util from 'node:util'; import { getPackages } from '@manypkg/get-packages'; import * as semver from 'semver'; import semver__default, { validRange } from 'semver'; import { highest, upperBoundOfRangeAWithinBoundsOfB } from 'sembear'; import validateNpmPackageName from 'validate-npm-package-name'; import parseGithubUrl from 'parse-github-url'; import normalizePath from 'normalize-path'; import fs from 'node:fs/promises'; import path from 'node:path'; import { exec } from 'tinyexec'; import detectIndent from 'detect-indent'; import pLimit from 'p-limit'; function format(args, messageType, scope) { let prefix = { error: pc.red("error"), success: pc.green("success"), info: pc.cyan("info") }[messageType]; let fullPrefix = "☔️ " + prefix + (scope === undefined ? "" : " " + scope); return fullPrefix + util.format("", ...args).split("\n").join("\n" + fullPrefix + " "); } function error(message, scope) { console.error(format([message], "error", scope)); } function success(message, scope) { console.log(format([message], "success", scope)); } function info(message, scope) { console.log(format([message], "info", scope)); } const NORMAL_DEPENDENCY_TYPES = ["dependencies", "devDependencies", "optionalDependencies"]; const DEPENDENCY_TYPES = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; function sortObject(prevObj) { let newObj = {}; for (let key of Object.keys(prevObj).sort()) { newObj[key] = prevObj[key]; } return newObj; } function sortDeps(pkg) { for (let depType of DEPENDENCY_TYPES) { let prevDeps = pkg.packageJson[depType]; if (prevDeps) { pkg.packageJson[depType] = sortObject(prevDeps); } } } function weakMemoize(func) { let cache = new WeakMap(); return arg => { if (cache.has(arg)) { return cache.get(arg); } let ret = func(arg); cache.set(arg, ret); return ret; }; } let getMostCommonRangeMap = weakMemoize(function getMostCommonRanges(allPackages) { let dependencyRangesMapping = new Map(); for (let [pkgName, pkg] of allPackages) { for (let depType of NORMAL_DEPENDENCY_TYPES) { let deps = pkg.packageJson[depType]; if (deps) { for (let depName in deps) { const depSpecifier = deps[depName]; if (!allPackages.has(depName)) { if (!semver.validRange(deps[depName])) { continue; } let dependencyRanges = dependencyRangesMapping.get(depName) || {}; const specifierCount = dependencyRanges[depSpecifier] || 0; dependencyRanges[depSpecifier] = specifierCount + 1; dependencyRangesMapping.set(depName, dependencyRanges); } } } } } let mostCommonRangeMap = new Map(); for (let [depName, specifierMap] of dependencyRangesMapping) { const specifierMapEntryArray = Object.entries(specifierMap); const [first] = specifierMapEntryArray; const maxValue = specifierMapEntryArray.reduce((acc, value) => { if (acc[1] === value[1]) { // If all dependency ranges occurances are equal, pick the highest. // It's impossible to infer intention of the developer // when all ranges occur an equal amount of times const highestRange = highest([acc[0], value[0]]); return [highestRange, acc[1]]; } if (acc[1] > value[1]) { return acc; } return value; }, first); mostCommonRangeMap.set(depName, maxValue[0]); } return mostCommonRangeMap; }); function versionRangeToRangeType(versionRange) { if (versionRange.charAt(0) === "^") return "^"; if (versionRange.charAt(0) === "~") return "~"; return ""; } function isArrayEqual(arrA, arrB) { for (var i = 0; i < arrA.length; i++) { if (arrA[i] !== arrB[i]) { return false; } } return true; } function makeCheck(check) { return check; } var EXTERNAL_MISMATCH = makeCheck({ validate: (workspace, allWorkspace) => { let errors = []; let mostCommonRangeMap = getMostCommonRangeMap(allWorkspace); for (let depType of NORMAL_DEPENDENCY_TYPES) { let deps = workspace.packageJson[depType]; if (deps) { for (let depName in deps) { let range = deps[depName]; let mostCommonRange = mostCommonRangeMap.get(depName); if (mostCommonRange !== undefined && mostCommonRange !== range && validRange(range)) { errors.push({ type: "EXTERNAL_MISMATCH", workspace, dependencyName: depName, dependencyRange: range, mostCommonDependencyRange: mostCommonRange }); } } } } return errors; }, fix: error => { for (let depType of NORMAL_DEPENDENCY_TYPES) { let deps = error.workspace.packageJson[depType]; if (deps && deps[error.dependencyName]) { deps[error.dependencyName] = error.mostCommonDependencyRange; } } return { requiresInstall: true }; }, print: error => `${error.workspace.packageJson.name} has a dependency on ${error.dependencyName}@${error.dependencyRange} but the most common range in the repo is ${error.mostCommonDependencyRange}, the range should be set to ${error.mostCommonDependencyRange}`, type: "all" }); var INTERNAL_MISMATCH = makeCheck({ validate: (workspace, allWorkspaces) => { let errors = []; for (let depType of NORMAL_DEPENDENCY_TYPES) { let deps = workspace.packageJson[depType]; if (deps) { for (let depName in deps) { let range = deps[depName]; let dependencyWorkspace = allWorkspaces.get(depName); if (dependencyWorkspace !== undefined && !range.startsWith("npm:") && !range.startsWith("workspace:") && !semver__default.satisfies(dependencyWorkspace.packageJson.version, range)) { errors.push({ type: "INTERNAL_MISMATCH", workspace, dependencyWorkspace, dependencyRange: range }); } } } } return errors; }, fix: error => { for (let depType of NORMAL_DEPENDENCY_TYPES) { let deps = error.workspace.packageJson[depType]; if (deps && deps[error.dependencyWorkspace.packageJson.name]) { deps[error.dependencyWorkspace.packageJson.name] = versionRangeToRangeType(deps[error.dependencyWorkspace.packageJson.name]) + error.dependencyWorkspace.packageJson.version; } } return { requiresInstall: true }; }, print: error => `${error.workspace.packageJson.name} has a dependency on ${error.dependencyWorkspace.packageJson.name}@${error.dependencyRange} but the version of ${error.dependencyWorkspace.packageJson.name} in the repo is ${error.dependencyWorkspace.packageJson.version} which is not within range of the depended on version, please update the dependency version`, type: "all" }); var INVALID_DEV_AND_PEER_DEPENDENCY_RELATIONSHIP = makeCheck({ type: "all", validate: (workspace, allWorkspaces) => { let errors = []; let peerDeps = workspace.packageJson.peerDependencies; let devDeps = workspace.packageJson.devDependencies || {}; if (peerDeps) { for (let depName in peerDeps) { if (!devDeps[depName]) { let highestRanges = getMostCommonRangeMap(allWorkspaces); let idealDevVersion = highestRanges.get(depName); let isInternalDependency = allWorkspaces.has(depName); if (isInternalDependency) { idealDevVersion = "*"; } else if (idealDevVersion === undefined) { idealDevVersion = peerDeps[depName]; } errors.push({ type: "INVALID_DEV_AND_PEER_DEPENDENCY_RELATIONSHIP", workspace, peerVersion: peerDeps[depName], dependencyName: depName, devVersion: null, idealDevVersion }); } else if (semver__default.validRange(devDeps[depName]) && // TODO: we should probably error when a peer dep has an invalid range (in a seperate rule) // (also would be good to do a bit more validation instead of just ignoring invalid ranges for normal dep types) semver__default.validRange(peerDeps[depName]) && !upperBoundOfRangeAWithinBoundsOfB(devDeps[depName], peerDeps[depName])) { let highestRanges = getMostCommonRangeMap(allWorkspaces); let idealDevVersion = highestRanges.get(depName); if (idealDevVersion === undefined) { idealDevVersion = peerDeps[depName]; } errors.push({ type: "INVALID_DEV_AND_PEER_DEPENDENCY_RELATIONSHIP", workspace, dependencyName: depName, peerVersion: peerDeps[depName], devVersion: devDeps[depName], idealDevVersion }); } } } return errors; }, fix: error => { if (!error.workspace.packageJson.devDependencies) { error.workspace.packageJson.devDependencies = {}; } error.workspace.packageJson.devDependencies[error.dependencyName] = error.idealDevVersion; return { requiresInstall: true }; }, print: error => { if (error.devVersion === null) { return `${error.workspace.packageJson.name} has a peerDependency on ${error.dependencyName} but it is not also specified in devDependencies, please add it there.`; } return `${error.workspace.packageJson.name} has a peerDependency on ${error.dependencyName} but the range specified in devDependency is not greater than or equal to the range specified in peerDependencies`; } }); var INVALID_PACKAGE_NAME = makeCheck({ type: "all", validate: workspace => { if (!workspace.packageJson.name) { return [{ type: "INVALID_PACKAGE_NAME", workspace, errors: ["name cannot be undefined"] }]; } let validationErrors = validateNpmPackageName(workspace.packageJson.name); let errors = [...(validationErrors.errors || []), ...(validationErrors.warnings || [])]; if (errors.length) { return [{ type: "INVALID_PACKAGE_NAME", workspace, errors }]; } return []; }, print: error => { if (!error.workspace.packageJson.name) { return `The package at ${JSON.stringify(error.workspace.relativeDir)} does not have a name`; } return `${error.workspace.packageJson.name} is an invalid package name for the following reasons:\n${error.errors.join("\n")}`; } }); var MULTIPLE_DEPENDENCY_TYPES = makeCheck({ validate: (workspace, allWorkspaces) => { let dependencies = new Set(); let errors = []; if (workspace.packageJson.dependencies) { for (let depName in workspace.packageJson.dependencies) { dependencies.add(depName); } } for (let depType of ["devDependencies", "optionalDependencies"]) { let deps = workspace.packageJson[depType]; if (deps) { for (let depName in deps) { if (dependencies.has(depName)) { errors.push({ type: "MULTIPLE_DEPENDENCY_TYPES", dependencyType: depType, dependencyName: depName, workspace }); } } } } return errors; }, type: "all", fix: error => { let deps = error.workspace.packageJson[error.dependencyType]; if (deps) { delete deps[error.dependencyName]; if (Object.keys(deps).length === 0) { delete error.workspace.packageJson[error.dependencyType]; } } return { requiresInstall: true }; }, print: error => `${error.workspace.packageJson.name} has a dependency and a ${error.dependencyType === "devDependencies" ? "devDependency" : "optionalDependency"} on ${error.dependencyName}, this is unnecessary, it should be removed from ${error.dependencyType}` }); var ROOT_HAS_PROD_DEPENDENCIES = makeCheck({ type: "root", validate: rootWorkspace => { if (rootWorkspace.packageJson.dependencies) { return [{ type: "ROOT_HAS_PROD_DEPENDENCIES", workspace: rootWorkspace }]; } return []; }, fix: error => { error.workspace.packageJson.devDependencies = sortObject({ ...error.workspace.packageJson.devDependencies, ...error.workspace.packageJson.dependencies }); delete error.workspace.packageJson.dependencies; }, print: () => { return `the root package.json contains ${pc.yellow("dependencies")}, this is disallowed as ${pc.yellow("dependencies")} vs ${pc.green("devDependencies")} in a private package does not affect anything and creates confusion.`; } }); var UNSORTED_DEPENDENCIES = makeCheck({ type: "all", validate: workspace => { for (let depType of DEPENDENCY_TYPES) { let deps = workspace.packageJson[depType]; if (deps && !isArrayEqual(Object.keys(deps), Object.keys(deps).sort())) { return [{ type: "UNSORTED_DEPENDENCIES", workspace }]; } } return []; }, fix: error => { sortDeps(error.workspace); }, print: error => `${error.workspace.packageJson.name}'s dependencies are unsorted, this can cause large diffs when packages are added, resulting in dependencies being sorted` }); var INCORRECT_REPOSITORY_FIELD = makeCheck({ type: "all", validate: (workspace, allWorkspaces, rootWorkspace, options) => { let rootRepositoryField = rootWorkspace?.packageJson?.repository; if (typeof rootRepositoryField === "string") { let result = parseGithubUrl(rootRepositoryField); if (result !== null && (result.host === "github.com" || result.host === "dev.azure.com")) { let baseRepositoryUrl = ""; if (result.host === "github.com") { baseRepositoryUrl = `${result.protocol}//${result.host}/${result.owner}/${result.name}`; } else if (result.host === "dev.azure.com") { baseRepositoryUrl = `${result.protocol}//${result.host}/${result.owner}/${result.name}/_git/${result.filepath}`; } if (workspace === rootWorkspace) { let correctRepositoryField = baseRepositoryUrl; if (rootRepositoryField !== correctRepositoryField) { return [{ type: "INCORRECT_REPOSITORY_FIELD", workspace, currentRepositoryField: rootRepositoryField, correctRepositoryField }]; } } else { let correctRepositoryField = ""; if (result.host === "github.com") { correctRepositoryField = `${baseRepositoryUrl}/tree/${options.defaultBranch}/${normalizePath(workspace.relativeDir)}`; } else if (result.host === "dev.azure.com") { correctRepositoryField = `${baseRepositoryUrl}?path=${normalizePath(workspace.relativeDir)}&version=GB${options.defaultBranch}&_a=contents`; } let currentRepositoryField = workspace.packageJson.repository; if (correctRepositoryField !== currentRepositoryField) { return [{ type: "INCORRECT_REPOSITORY_FIELD", workspace, currentRepositoryField, correctRepositoryField }]; } } } } return []; }, fix: error => { error.workspace.packageJson.repository = error.correctRepositoryField; }, print: error => { if (error.currentRepositoryField === undefined) { return `${error.workspace.packageJson.name} does not have a repository field when it should be ${JSON.stringify(error.correctRepositoryField)}`; } return `${error.workspace.packageJson.name} has a repository field of ${JSON.stringify(error.currentRepositoryField)} when it should be ${JSON.stringify(error.correctRepositoryField)}`; } }); var WORKSPACE_REQUIRED = makeCheck({ validate: (workspace, allWorkspaces, root, opts) => { if (opts.workspaceProtocol !== "require") return []; let errors = []; for (let depType of NORMAL_DEPENDENCY_TYPES) { let deps = workspace.packageJson[depType]; if (deps) { for (let depName in deps) { if (allWorkspaces.has(depName) && !deps[depName].startsWith("workspace:")) { errors.push({ type: "WORKSPACE_REQUIRED", workspace, depName, depType }); } } } } return errors; }, fix: error => { let deps = error.workspace.packageJson[error.depType]; if (deps && deps[error.depName]) { deps[error.depName] = "workspace:^"; } return { requiresInstall: true }; }, print: error => `${error.workspace.packageJson.name} has a dependency on ${error.depName} without using the workspace: protocol but this project requires using the workspace: protocol, please change it to workspace:^ or etc.`, type: "all" }); let checks = { EXTERNAL_MISMATCH, INTERNAL_MISMATCH, INVALID_DEV_AND_PEER_DEPENDENCY_RELATIONSHIP, INVALID_PACKAGE_NAME, MULTIPLE_DEPENDENCY_TYPES, ROOT_HAS_PROD_DEPENDENCIES, UNSORTED_DEPENDENCIES, INCORRECT_REPOSITORY_FIELD, WORKSPACE_REQUIRED }; class ExitError extends Error { constructor(code) { super(`The process should exit with code ${code}`); this.code = code; } } async function writePackage(pkg) { let pkgRaw = await fs.readFile(path.join(pkg.dir, "package.json"), "utf-8"); let indent = detectIndent(pkgRaw).indent || " "; return fs.writeFile(path.join(pkg.dir, "package.json"), JSON.stringify(pkg.packageJson, null, indent) + (pkgRaw.endsWith("\n") ? "\n" : "")); } async function install(toolType, cwd) { const cliRunners = { lerna: "lerna", npm: "npm", pnpm: "pnpm", root: "yarn", rush: "rushx", yarn: "yarn" }; await exec(cliRunners[toolType], toolType === "npm" || toolType === "pnpm" ? ["install"] : toolType === "lerna" ? ["bootstrap", "--since", "HEAD"] : [], { nodeOptions: { cwd, stdio: "inherit" } }); } async function runCmd(args, cwd) { let { packages } = await getPackages(cwd); const exactMatchingPackage = packages.find(pkg => { return pkg.packageJson.name === args[0] || pkg.relativeDir === args[0]; }); if (exactMatchingPackage) { const { exitCode } = await exec("yarn", args.slice(1), { nodeOptions: { cwd: exactMatchingPackage.dir, stdio: "inherit" } }); throw new ExitError(exitCode ?? 1); } const matchingPackages = packages.filter(pkg => { return pkg.packageJson.name.includes(args[0]) || pkg.relativeDir.includes(args[0]); }); if (matchingPackages.length > 1) { error(`an identifier must only match a single package but "${args[0]}" matches the following packages: \n${matchingPackages.map(x => x.packageJson.name).join("\n")}`); throw new ExitError(1); } else if (matchingPackages.length === 0) { error("No matching packages found"); throw new ExitError(1); } else { const { exitCode } = await exec("yarn", args.slice(1), { nodeOptions: { cwd: matchingPackages[0].dir, stdio: "inherit" } }); throw new ExitError(exitCode ?? 1); } } async function upgradeDependency([name, tag = "latest"]) { // handle no name is missing let { packages, tool, rootPackage, rootDir } = await getPackages(process.cwd()); let isScope = name.startsWith("@") && !name.includes("/"); let newVersion = semver__default.validRange(tag) ? tag : null; let packagesToUpdate = new Set(); let filteredPackages = packages.filter(({ packageJson }) => { let requiresUpdate = false; DEPENDENCY_TYPES.forEach(t => { let deps = packageJson[t]; if (!deps) return; let packageNames = Object.keys(deps); packageNames.forEach(pkgName => { if (isScope && pkgName.startsWith(`${name}/`) || pkgName === name) { requiresUpdate = true; packagesToUpdate.add(pkgName); } }); }); return requiresUpdate; }); if (rootPackage) { let rootRequiresUpdate = false; DEPENDENCY_TYPES.forEach(t => { let deps = rootPackage.packageJson[t]; if (!deps) return; let packageNames = Object.keys(deps); packageNames.forEach(pkgName => { if (isScope && pkgName.startsWith(`${name}/`) || pkgName === name) { rootRequiresUpdate = true; packagesToUpdate.add(pkgName); } }); if (rootRequiresUpdate) { filteredPackages.push(rootPackage); } }); } let newVersions = await Promise.all([...packagesToUpdate].map(async pkgName => { if (!newVersion) { let info = await getPackageInfo(pkgName); let distTags = info["dist-tags"]; let version = distTags[tag]; return { pkgName, version }; } else { return { pkgName, version: newVersion }; } })); filteredPackages.forEach(({ packageJson }) => { DEPENDENCY_TYPES.forEach(t => { let deps = packageJson[t]; if (deps) { newVersions.forEach(({ pkgName, version }) => { if (deps[pkgName] && version) { if (!newVersion) { deps[pkgName] = `${versionRangeToRangeType(deps[pkgName])}${version}`; } else { deps[pkgName] = version; } } }); } }); }); await Promise.all([...filteredPackages].map(writePackage)); await install(tool.type, rootDir); } const npmRequestLimit = pLimit(40); function getPackageInfo(pkgName) { return npmRequestLimit(async () => { const getPackageJson = (await import('package-json')).default; return getPackageJson(pkgName, { allVersions: true }); }); } let npmLimit = pLimit(40); function getCorrectRegistry() { let registry = process.env.npm_config_registry === "https://registry.yarnpkg.com" ? undefined : process.env.npm_config_registry; return registry; } async function tagApackage(packageJson, tag, otpCode) { // Due to a super annoying issue in yarn, we have to manually override this env variable // See: https://github.com/yarnpkg/yarn/issues/2935#issuecomment-355292633 const envOverride = { npm_config_registry: getCorrectRegistry() }; let flags = []; if (otpCode) { flags.push("--otp", otpCode); } return await exec("npm", ["dist-tag", "add", `${packageJson.name}@${packageJson.version}`, tag, ...flags], { nodeOptions: { stdio: "inherit", env: envOverride } }); } async function npmTagAll([tag, _, otp]) { let { packages } = await getPackages(process.cwd()); await Promise.all(packages.filter(({ packageJson }) => packageJson.private !== true).map(({ packageJson }) => npmLimit(() => tagApackage(packageJson, tag, otp)))); } let defaultOptions = { defaultBranch: "main" }; let runChecks = (allWorkspaces, rootWorkspace, shouldFix, options) => { let hasErrored = false; let requiresInstall = false; let ignoredRules = new Set(options.ignoredRules || []); for (let [ruleName, check] of Object.entries(checks)) { if (ignoredRules.has(ruleName)) { continue; } if (check.type === "all") { for (let [, workspace] of allWorkspaces) { let errors = check.validate(workspace, allWorkspaces, rootWorkspace, options); if (shouldFix && check.fix !== undefined) { for (let error of errors) { let output = check.fix(error, options) || { requiresInstall: false }; if (output.requiresInstall) { requiresInstall = true; } } } else { for (let error$1 of errors) { hasErrored = true; error(check.print(error$1, options)); } } } } if (check.type === "root" && rootWorkspace) { let errors = check.validate(rootWorkspace, allWorkspaces, options); if (shouldFix && check.fix !== undefined) { for (let error of errors) { let output = check.fix(error, options) || { requiresInstall: false }; if (output.requiresInstall) { requiresInstall = true; } } } else { for (let error$1 of errors) { hasErrored = true; error(check.print(error$1, options)); } } } } return { requiresInstall, hasErrored }; }; let execLimit = pLimit(4); async function execCmd(args) { let { packages } = await getPackages(process.cwd()); let highestExitCode = 0; await Promise.all(packages.map(pkg => { return execLimit(async () => { const { exitCode } = await exec(args[0], args.slice(1), { nodeOptions: { cwd: pkg.dir, stdio: "inherit" } }); highestExitCode = Math.max(exitCode ?? 1, highestExitCode); }); })); throw new ExitError(highestExitCode); } (async () => { let things = process.argv.slice(2); if (things[0] === "exec") { return execCmd(things.slice(1)); } if (things[0] === "run") { return runCmd(things.slice(1), process.cwd()); } if (things[0] === "upgrade") { return upgradeDependency(things.slice(1)); } if (things[0] === "npm-tag") { return npmTagAll(things.slice(1)); } if (things[0] !== "check" && things[0] !== "fix") { error(`command ${things[0]} not found, only check, exec, run, upgrade, npm-tag and fix exist`); throw new ExitError(1); } let shouldFix = things[0] === "fix"; let { tool, packages, rootPackage, rootDir } = await getPackages(process.cwd()); let options = { ...defaultOptions, ...rootPackage?.packageJson.manypkg }; let packagesByName = new Map(packages.map(x => [x.packageJson.name, x])); if (rootPackage) { packagesByName.set(rootPackage.packageJson.name, rootPackage); } let { hasErrored, requiresInstall } = runChecks(packagesByName, rootPackage, shouldFix, options); if (shouldFix) { await Promise.all([...packagesByName].map(async ([pkgName, workspace]) => { writePackage(workspace); })); if (requiresInstall) { await install(tool.type, rootDir); } success(`fixed workspaces!`); } else if (hasErrored) { info(`the above errors may be fixable with yarn manypkg fix`); throw new ExitError(1); } else { success(`workspaces valid!`); } })().catch(err => { if (err instanceof ExitError) { process.exit(err.code); } else { error(err); process.exit(1); } });