Skip to content

feat: add yarn support #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions src/detectPackageManager.ts
Original file line number Diff line number Diff line change
@@ -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");
}
28 changes: 21 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;

Expand All @@ -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 });

Expand All @@ -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");
Expand Down
15 changes: 10 additions & 5 deletions src/pnpmWorkspace.ts
Original file line number Diff line number Diff line change
@@ -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<string, Workspace>;
currentWorkspace: Workspace;
}> {
export async function readWorkspaceSettings({
rootDir,
cwd,
}: Ctx): Promise<WorkspaceSettings> {
const workspaceDirs = await readWorkspaceDirs({ rootDir, cwd });

const workspaces = (await Promise.all(workspaceDirs.map(findPackagesInDir)))
Expand Down Expand Up @@ -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"));
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ export type Workspace = {
packagePath: string;
dependsOn: string[];
};

export type WorkspaceSettings = {
workspaces: Record<string, Workspace>;
currentWorkspace: Workspace;
};

export type PackageManager = "yarn" | "npm" | "pnpm";
123 changes: 123 additions & 0 deletions src/yarnWorkspace.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceSettings> {
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<string, Workspace>,
{ 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<PackageJson>(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;
}