diff --git a/README.md b/README.md index 94e4b36..ad1c126 100644 --- a/README.md +++ b/README.md @@ -59,4 +59,4 @@ Moreover, deployment statuses are reported as successful even when builds are sk Then, it proceeds to check whether the given package or any of its dependencies were modified since the last commit with the use of `git diff "HEAD^" "HEAD" --quiet`. -Currently, only `pnpm` workspaces are supported but more is on the roadmap. +Currently, only `pnpm` and `yarn` workspaces are supported but more is on the roadmap. diff --git a/src/detectPackageManager.ts b/src/detectPackageManager.ts new file mode 100644 index 0000000..368f665 --- /dev/null +++ b/src/detectPackageManager.ts @@ -0,0 +1,16 @@ +import Path from "node:path"; +import { PackageManager } from "./types.js"; +import { isRootDir as isPnpmRootDir } from "./pnpmWorkspace.js"; +import { isRootDir as isYarnRootDir } from "./yarnWorkspace.js"; + +export function detectPackageManager(cwd: string): PackageManager { + const paths = cwd + .split(Path.sep) + .map((_, idx) => Path.join(cwd, "../".repeat(idx))); + + for (const path of paths) { + if (isPnpmRootDir(path)) return "pnpm"; + if (isYarnRootDir(path)) return "yarn"; + } + throw new Error("Package manager could not be detected"); +} diff --git a/src/index.ts b/src/index.ts index ec0ea69..a7fbbaa 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,15 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; import Path from "node:path"; import { promiseErrorToSettled } from "./utils.js"; -import { - readWorkspaceDirs, - readWorkspaceSettings, - resolveWorkspaceDeps, -} from "./pnpmWorkspace.js"; +import * as pnpm from "./pnpmWorkspace.js"; +import * as yarn from "./yarnWorkspace.js"; import { compare } from "./git.js"; import { debug } from "./debug.js"; import { parseArgs } from "./parseArgs.js"; +import { PackageManager } from "./types.js"; +import { detectPackageManager } from "./detectPackageManager.js"; const cwd = process.cwd(); @@ -30,6 +28,17 @@ const configuration = parseArgs({ const log = debug(configuration.values.verbose); +const packageManager: PackageManager = detectPackageManager(cwd); + +log({ packageManager }); + +const { + readWorkspaceDirs, + readWorkspaceSettings, + resolveWorkspaceDeps, + isRootDir, +} = packageManager === "pnpm" ? pnpm : yarn; + const [gitFromPointer = "HEAD^", gitToPointer = "HEAD"] = configuration.positionals; @@ -38,7 +47,7 @@ log({ gitFromPointer, gitToPointer }); const rootDir = cwd .split(Path.sep) .map((_, idx) => Path.join(cwd, "../".repeat(idx))) - .find((path) => existsSync(Path.join(path, "pnpm-workspace.yaml"))); + .find((path) => isRootDir(path)); log({ rootDir }); @@ -47,11 +56,16 @@ if (!rootDir) { } const workspaceSettings = await readWorkspaceSettings({ rootDir, cwd }); + +log(workspaceSettings); + const workspaceDeps = resolveWorkspaceDeps( workspaceSettings.workspaces, workspaceSettings.currentWorkspace, ); +log({ workspaceDeps }); + const workspaceDepsPaths = workspaceDeps .map((name) => workspaceSettings.workspaces[name]?.packagePath) .filter((path): path is string => typeof path === "string"); diff --git a/src/pnpmWorkspace.ts b/src/pnpmWorkspace.ts index a6581c4..bf21ff5 100644 --- a/src/pnpmWorkspace.ts +++ b/src/pnpmWorkspace.ts @@ -1,12 +1,13 @@ import { readFile, readdir } from "node:fs/promises"; import Path from "node:path"; +import { existsSync } from "node:fs"; import { fileExist, readJson } from "./utils.js"; -import { Ctx, Workspace, PackageJson } from "./types.js"; +import { Ctx, Workspace, PackageJson, WorkspaceSettings } from "./types.js"; -export async function readWorkspaceSettings({ rootDir, cwd }: Ctx): Promise<{ - workspaces: Record; - currentWorkspace: Workspace; -}> { +export async function readWorkspaceSettings({ + rootDir, + cwd, +}: Ctx): Promise { const workspaceDirs = await readWorkspaceDirs({ rootDir, cwd }); const workspaces = (await Promise.all(workspaceDirs.map(findPackagesInDir))) @@ -95,3 +96,7 @@ function getWorkspaceDeps(pkg: PackageJson) { .map(([name]) => name); return [...new Set(deps)]; } + +export function isRootDir(path: string) { + return existsSync(Path.join(path, "pnpm-workspace.yaml")); +} diff --git a/src/types.ts b/src/types.ts index 13a1148..278baf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,3 +14,10 @@ export type Workspace = { packagePath: string; dependsOn: string[]; }; + +export type WorkspaceSettings = { + workspaces: Record; + currentWorkspace: Workspace; +}; + +export type PackageManager = "yarn" | "npm" | "pnpm"; diff --git a/src/yarnWorkspace.ts b/src/yarnWorkspace.ts new file mode 100644 index 0000000..7468912 --- /dev/null +++ b/src/yarnWorkspace.ts @@ -0,0 +1,123 @@ +import { readFile, readdir } from "node:fs/promises"; +import Path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { fileExist, readJson } from "./utils.js"; +import { Ctx, Workspace, WorkspaceSettings, PackageJson } from "./types.js"; + +export async function readWorkspaceSettings({ + rootDir, + cwd, +}: Ctx): Promise { + const workspaceDirs = await readWorkspaceDirs({ rootDir, cwd }); + + const workspaces = (await Promise.all(workspaceDirs.map(findPackagesInDir))) + .flat() + .map((w) => [w.name, w] as const); + const currentWorkspace = workspaces.find( + ([, w]) => w.packagePath === cwd, + )?.[1]; + + if (!currentWorkspace) { + throw new Error(`Couldn't find currentWorkspace: ${cwd}`); + } + const { name } = currentWorkspace; + if (!name) { + throw new Error(`Workspace must have name: ${cwd}`); + } + + return withoutNodeModules({ + workspaces: Object.fromEntries(workspaces), + currentWorkspace: { ...currentWorkspace, name }, + }); +} + +export function resolveWorkspaceDeps( + allWorkspaces: Record, + { dependsOn }: Workspace, +): string[] { + return [ + ...new Set([ + ...dependsOn, + ...dependsOn.flatMap((d) => + allWorkspaces[d] + ? resolveWorkspaceDeps(allWorkspaces, allWorkspaces[d]!) + : [], + ), + ]), + ]; +} + +export async function readWorkspaceDirs({ rootDir }: Ctx) { + const workspaceSettingsPath = Path.join(rootDir, "package.json"); + const workspaceSettings = await readFile(workspaceSettingsPath, "utf-8"); + const workspaces: string[] = JSON.parse(workspaceSettings).workspaces || []; + + return ( + workspaces + .filter((glob): glob is string => typeof glob === "string") + // @todo support exclusions? + .filter((glob) => !glob.startsWith("!")) + .map((glob) => glob.replace(/\/\*{1,2}$/, "")) + .map((path) => Path.join(rootDir, path)) + ); +} + +async function findPackagesInDir(path: string) { + const directories = await readdir(path); + const packages = await Promise.all( + directories.map(async (dir) => { + const packagePath = Path.join(path, dir); + const packageJsonPath = Path.join(packagePath, "package.json"); + const exists = await fileExist(packageJsonPath); + return { packagePath, packageJsonPath, exists }; + }), + ); + + return await Promise.all( + packages + .filter(({ exists }) => exists) + .map(async ({ packagePath, packageJsonPath }) => { + const pkg = await readJson(packageJsonPath); + const dependsOn = getWorkspaceDeps(pkg); + return { dependsOn, name: pkg.name, packagePath }; + }), + ); +} + +function getWorkspaceDeps(pkg: PackageJson) { + const deps = [ + ...Object.entries(pkg.dependencies ?? {}), + ...Object.entries(pkg.devDependencies ?? {}), + ].map(([name]) => name); + return [...new Set(deps)]; +} + +function withoutNodeModules(settings: WorkspaceSettings): WorkspaceSettings { + const allowedDependencies = Object.keys(settings.workspaces); + + function withoutNodeModulesDeps(workspace: Workspace): Workspace { + return { + ...workspace, + dependsOn: workspace.dependsOn.filter((pkgName) => + allowedDependencies.includes(pkgName), + ), + }; + } + + const result: WorkspaceSettings = { + currentWorkspace: withoutNodeModulesDeps(settings.currentWorkspace), + workspaces: {}, + }; + for (const [name, workspace] of Object.entries(settings.workspaces)) { + result.workspaces[name] = withoutNodeModulesDeps(workspace); + } + return result; +} + +export function isRootDir(path: string) { + const packageJsonPath = Path.join(path, "package.json"); + if (!existsSync(packageJsonPath)) return false; + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (packageJson.workspaces) return true; + return false; +}