diff --git a/bin/canadyarn.js b/bin/canadyarn.js new file mode 100644 index 0000000..93d757c --- /dev/null +++ b/bin/canadyarn.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require("../dist/canadyarn"); diff --git a/src/canadyarn.ts b/src/canadyarn.ts new file mode 100644 index 0000000..93f0289 --- /dev/null +++ b/src/canadyarn.ts @@ -0,0 +1,92 @@ +import {parse} from "@yarnpkg/lockfile"; +import {bold as cb} from "chalk"; +import {existsSync, readFileSync, statSync} from "fs"; +import {join} from "path"; +import {checkYarnLock} from "./check-yarn-lock"; +import {ERROR_UNKNOWN_EXIT_CODE, ExecutionError, MultipleVersionError} from "./error"; +import type {DependencyName, MultipleVersionErrorMessage, ParseResult} from "./types"; + +function execute(): void { + const checkSingleVersionDependencies: DependencyName[] = (function () { + const filepath = join(process.cwd(), "package.json"); + if (!(existsSync(filepath) && statSync(filepath).isFile())) { + throw new ExecutionError("cannot find package.json"); + } + const packageJson = JSON.parse(readFileSync(filepath, {encoding: "utf8"})); + if (!("checkSingleVersionDependencies" in packageJson && Array.isArray(packageJson.checkSingleVersionDependencies))) { + throw new ExecutionError("package.json#checkSingleVersionDependencies must be an array"); + } + return packageJson.checkSingleVersionDependencies; + })(); + const lockfile: ParseResult = (function () { + const filepath = join(process.cwd(), "yarn.lock"); + if (!(existsSync(filepath) && statSync(filepath).isFile())) { + throw new Error("[check-yarn-lock]: cannot find yarn.lock"); + } + return parse(readFileSync(filepath, {encoding: "utf8"})); + })(); + const errorMessages: MultipleVersionErrorMessage[] = checkYarnLock(checkSingleVersionDependencies, lockfile); + if (errorMessages.length > 0) { + throw new MultipleVersionError(errorMessages); + } +} + +try { + execute(); +} catch (error) { + const buffer: string[] = []; + let processExitCode: number; + buffer.push(`${cb.yellowBright("[check-yarn-lock-single-version]")}: `); + if (ExecutionError.isInstance(error)) { + const {exitCode, message} = error; + processExitCode = exitCode; + buffer.push("an execution error occurred ", cb.whiteBright(":(")); + buffer.push("\n"); + buffer.push(" > ", cb.whiteBright(message)); + buffer.push("\n"); + } else if (MultipleVersionError.isInstance(error)) { + const {exitCode, errorMessages} = error; + processExitCode = exitCode; + buffer.push(cb.whiteBright(`found ${cb.red(errorMessages.length)} modules with non-singular version resolutions`)); + buffer.push("\n"); + errorMessages.forEach(errorMessage => { + buffer.push(errorMessage); + buffer.push("\n"); + }); + buffer.push("\n"); + buffer.push(Array(40).fill("-").join("")); + buffer.push("\n"); + buffer.push("\n"); + buffer.push(`The above list ${cb.whiteBright(`might contain ${cb.redBright("false")} positives`)} `); + buffer.push("if the resolution of dependency version range is removed after upgrading, "); + buffer.push(`but yarn does not remove the resolution from ${cb.whiteBright("yarn.lock")}.`); + buffer.push("\n"); + buffer.push("\n"); + buffer.push(`Try running "${cb.blueBright("$ yarn install")}" again in the workspace root to update ${cb.whiteBright("yarn.lock")}.`); + buffer.push("\n"); + buffer.push("\n"); + buffer.push(`Run "${cb.blueBright("$ yarn why ")}" to find out where the duplicate dependencies come from.`); + buffer.push("\n"); + buffer.push("\n"); + buffer.push(`If you don't know what to do, `); + buffer.push(cb.whiteBright("ask the "), "🍁", cb.whiteBright("Canadian"), "🍁", cb.whiteBright(" guy"), " (he knows how to fix this) ", cb.whiteBright(":)")); + buffer.push("\n"); + } else { + processExitCode = ERROR_UNKNOWN_EXIT_CODE; + buffer.push(cb.yellowBright("[check-yarn-lock-single-version]")); + buffer.push(": an unknown error occurred"); + buffer.push("\n"); + let message: string; + if (typeof error === "string") { + message = error; + } else if (typeof error === "object" && "message" in error && typeof error.message === "string") { + message = error.message; + } + if (message) { + buffer.push(" > ", cb.whiteBright(error.mess)); + buffer.push("\n"); + } + } + console.warn(buffer.join("")); + process.exit(processExitCode); +} diff --git a/src/check-yarn-lock.ts b/src/check-yarn-lock.ts new file mode 100644 index 0000000..1e2b637 --- /dev/null +++ b/src/check-yarn-lock.ts @@ -0,0 +1,42 @@ +import {bold as cb} from "chalk"; +import {ExecutionError} from "./error"; +import type {CheckableDependencies, DependencyName, MultipleVersionErrorMessage, ParseResult} from "./types"; + +export function checkYarnLock(checkSingleVersionDependencies: ReadonlyArray, lockfile: Readonly): MultipleVersionErrorMessage[] { + const checkableDeps: CheckableDependencies = (function () { + const checkableDeps = {}; + checkSingleVersionDependencies.forEach(dep => { + checkableDeps[dep] = {}; + }); + return checkableDeps; + })(); + + if (lockfile.type !== "success") { + throw new ExecutionError("failed to parse yarn.lock"); + } + + const lockfileEntries = Object.entries(lockfile.object); + + lockfileEntries.forEach(([qualifiedName, resolution]) => { + const dep = checkSingleVersionDependencies.find(dep => qualifiedName.startsWith(`${dep}@`)); + if (dep !== undefined) { + checkableDeps[dep][resolution.version] = checkableDeps[dep][resolution.version] || []; + checkableDeps[dep][resolution.version].push(resolution); // only need resolution.version for now + } + }); + + const errorMessages: MultipleVersionErrorMessage[] = []; + + Object.entries(checkableDeps).forEach(([dep, versionMap]) => { + const versions: string[] = Object.keys(versionMap); + if (versions.length !== 1) { + const buffer: string[] = []; + buffer.push(" > ", `${cb.red(versions.length)} versions of ${cb.blueBright(`"${dep}"`)} detected`); + buffer.push("\n"); + buffer.push(" ", versions.join(", ")); + errorMessages.push(buffer.join("")); + } + }); + + return errorMessages; +} diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..a9d0d36 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,33 @@ +import {MultipleVersionErrorMessage} from "./types"; + +export const ERROR_EXECUTION_EXIT_CODE = 1; +export const ERROR_MULTIPLE_VERSION_EXIT_CODE = 2; +export const ERROR_UNKNOWN_EXIT_CODE = 3; + +export class ExecutionError extends Error { + static isInstance(error: unknown): error is ExecutionError { + return typeof error === "object" && "__type__" in error && error["__type__"] === "ExecutionError"; + } + + readonly exitCode = ERROR_EXECUTION_EXIT_CODE; + private readonly __type__ = "ExecutionError"; + + constructor(message?: string) { + super(message); + } +} + +export class MultipleVersionError extends Error { + static isInstance(error: unknown): error is MultipleVersionError { + return typeof error === "object" && "__type__" in error && error["__type__"] === "MultipleVersionError"; + } + + readonly exitCode = ERROR_MULTIPLE_VERSION_EXIT_CODE; + readonly errorMessages: MultipleVersionErrorMessage[]; + private readonly __type__ = "MultipleVersionError"; + + constructor(errorMessages: MultipleVersionErrorMessage[]) { + super(); + this.errorMessages = errorMessages; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e755101 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,21 @@ +export type DependencyName = ({__type__: "DependencyName"} & string) | string; + +export type MultipleVersionErrorMessage = ({__type__: "MultipleVersionErrorMessage"} & string) | string; + +export type QualifiedName = ({__type__: "QualifiedName"} & string) | string; + +export type Version = ({__type__: "Version"} & string) | string; + +export type DependencyResolution = { + version: string; + resolved: string; + integrity: string; + dependencies?: Record; +}; + +export type ParseResult = { + type: "merge" | "success" | "conflict"; + object: Record; +}; + +export type CheckableDependencies = Record>;