diff --git a/.changeset/polite-keys-buy.md b/.changeset/polite-keys-buy.md new file mode 100644 index 0000000..19fba77 --- /dev/null +++ b/.changeset/polite-keys-buy.md @@ -0,0 +1,5 @@ +--- +"@webiny/stdlib": patch +--- + +Add WorkspaceTool for discovering workspaces from root package.json (drop-in replacement for get-yarn-workspaces) diff --git a/README.md b/README.md index a0b5546..88ba318 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ | `PackageJsonFileTool` / `PackageJsonFileToolFeature` | Read, validate, mutate, and write `package.json` files — [docs](src/node/features/PackageJsonFileTool/README.md) | | `HashFolderTool` / `HashFolderToolFeature` | Deterministic SHA-256 hash of a folder's contents — [docs](src/node/features/HashFolderTool/README.md) | | `ProcessEnvFeature` | `Env` implementation backed by `process.env` — [docs](src/node/features/ProcessEnv/README.md) | +| `WorkspaceTool` / `WorkspaceToolFeature` | Discover workspaces from root `package.json` (replaces `get-yarn-workspaces`) — [docs](src/node/features/WorkspaceTool/README.md) | --- diff --git a/__tests__/node/WorkspaceTool.test.ts b/__tests__/node/WorkspaceTool.test.ts new file mode 100644 index 0000000..e9cae15 --- /dev/null +++ b/__tests__/node/WorkspaceTool.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { Container } from "@webiny/di"; +import { PinoLoggerConfig, PinoLoggerFeature } from "../../src/node/features/PinoLogger/index.js"; +import { + WorkspaceTool, + WorkspaceToolFeature, + createWorkspaceTool, + listWorkspaces, + WorkspaceRootNotFoundError +} from "../../src/node/features/WorkspaceTool/index.js"; + +function makeContainer(): Container { + const container = new Container(); + container.registerInstance(PinoLoggerConfig, { + getConfig: () => ({ logLevel: "error" as const, transport: "json" as const }) + }); + PinoLoggerFeature.register(container); + WorkspaceToolFeature.register(container); + return container; +} + +function writePackageJson(dir: string, content: Record): void { + writeFileSync(join(dir, "package.json"), JSON.stringify(content, null, 2)); +} + +let tmpDir: string; + +beforeEach(() => { + tmpDir = join(tmpdir(), `wby-test-workspace-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + tmpDir = realpathSync(tmpDir); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("WorkspaceTool", () => { + let tool: WorkspaceTool.Interface; + + beforeEach(() => { + tool = makeContainer().resolve(WorkspaceTool); + }); + + describe("list", () => { + it("should list workspaces from flat array format", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const pkgA = join(tmpDir, "packages", "pkg-a"); + const pkgB = join(tmpDir, "packages", "pkg-b"); + mkdirSync(pkgA, { recursive: true }); + mkdirSync(pkgB, { recursive: true }); + writePackageJson(pkgA, { name: "@scope/pkg-a" }); + writePackageJson(pkgB, { name: "@scope/pkg-b" }); + + const result = tool.list({ cwd: tmpDir }); + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { name: "@scope/pkg-a", path: pkgA }, + { name: "@scope/pkg-b", path: pkgB } + ]) + ); + }); + + it("should list workspaces from { packages: [] } format", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: { packages: ["packages/*"] } + }); + + const pkgA = join(tmpDir, "packages", "pkg-a"); + mkdirSync(pkgA, { recursive: true }); + writePackageJson(pkgA, { name: "pkg-a" }); + + const result = tool.list({ cwd: tmpDir }); + + expect(result).toEqual([{ name: "pkg-a", path: pkgA }]); + }); + + it("should skip directories without package.json", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const withPkg = join(tmpDir, "packages", "has-pkg"); + const noPkg = join(tmpDir, "packages", "no-pkg"); + mkdirSync(withPkg, { recursive: true }); + mkdirSync(noPkg, { recursive: true }); + writePackageJson(withPkg, { name: "has-pkg" }); + + const result = tool.list({ cwd: tmpDir }); + + expect(result).toEqual([{ name: "has-pkg", path: withPkg }]); + }); + + it("should fall back to folder name when package.json has no name", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const pkg = join(tmpDir, "packages", "unnamed-pkg"); + mkdirSync(pkg, { recursive: true }); + writePackageJson(pkg, { version: "1.0.0" }); + + const result = tool.list({ cwd: tmpDir }); + + expect(result).toEqual([{ name: "unnamed-pkg", path: pkg }]); + }); + + it("should walk up to find root package.json with workspaces", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const pkgA = join(tmpDir, "packages", "pkg-a"); + mkdirSync(pkgA, { recursive: true }); + writePackageJson(pkgA, { name: "pkg-a" }); + + const nested = join(tmpDir, "packages", "pkg-a", "src"); + mkdirSync(nested, { recursive: true }); + + const result = tool.list({ cwd: nested }); + + expect(result).toEqual([{ name: "pkg-a", path: pkgA }]); + }); + + it("should handle multiple glob patterns", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*", "apps/*"] + }); + + const pkg = join(tmpDir, "packages", "lib"); + const app = join(tmpDir, "apps", "web"); + mkdirSync(pkg, { recursive: true }); + mkdirSync(app, { recursive: true }); + writePackageJson(pkg, { name: "lib" }); + writePackageJson(app, { name: "web" }); + + const result = tool.list({ cwd: tmpDir }); + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { name: "lib", path: pkg }, + { name: "web", path: app } + ]) + ); + }); + + it("should return empty array when workspaces pattern matches nothing", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["nonexistent/*"] + }); + + const result = tool.list({ cwd: tmpDir }); + + expect(result).toEqual([]); + }); + + it("should throw WorkspaceRootNotFoundError when no root with workspaces found", () => { + const isolated = join(tmpDir, "isolated"); + mkdirSync(isolated, { recursive: true }); + writePackageJson(isolated, { name: "no-workspaces" }); + + expect(() => tool.list({ cwd: isolated })).toThrow(WorkspaceRootNotFoundError); + }); + + it("should default cwd to process.cwd() when no params given", () => { + const originalCwd = process.cwd(); + try { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + const pkg = join(tmpDir, "packages", "my-pkg"); + mkdirSync(pkg, { recursive: true }); + writePackageJson(pkg, { name: "my-pkg" }); + + process.chdir(tmpDir); + + const result = tool.list(); + + expect(result).toEqual([{ name: "my-pkg", path: pkg }]); + } finally { + process.chdir(originalCwd); + } + }); + + it("should return absolute paths", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const pkg = join(tmpDir, "packages", "abs-test"); + mkdirSync(pkg, { recursive: true }); + writePackageJson(pkg, { name: "abs-test" }); + + const result = tool.list({ cwd: tmpDir }); + + for (const ws of result) { + expect(ws.path).toMatch(/^\//); + } + }); + }); +}); + +describe("createWorkspaceTool (factory)", () => { + it("should create a working tool instance", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const pkg = join(tmpDir, "packages", "factory-test"); + mkdirSync(pkg, { recursive: true }); + writePackageJson(pkg, { name: "factory-test" }); + + const tool = createWorkspaceTool(); + const result = tool.list({ cwd: tmpDir }); + + expect(result).toEqual([{ name: "factory-test", path: pkg }]); + }); +}); + +describe("listWorkspaces (standalone)", () => { + it("should work identically to DI version", () => { + writePackageJson(tmpDir, { + name: "root", + workspaces: ["packages/*"] + }); + + const pkg = join(tmpDir, "packages", "standalone-test"); + mkdirSync(pkg, { recursive: true }); + writePackageJson(pkg, { name: "standalone-test" }); + + const result = listWorkspaces({ cwd: tmpDir }); + + expect(result).toEqual([{ name: "standalone-test", path: pkg }]); + }); +}); diff --git a/src/node/features/WorkspaceTool/README.md b/src/node/features/WorkspaceTool/README.md new file mode 100644 index 0000000..6bce846 --- /dev/null +++ b/src/node/features/WorkspaceTool/README.md @@ -0,0 +1,47 @@ +# WorkspaceTool + +Discovers workspaces defined in the nearest root `package.json`. Walks up from a given directory to find a `package.json` with a `workspaces` field, then resolves the glob patterns to return workspace metadata. Supports both flat array (`workspaces: ["packages/*"]`) and Yarn's object format (`workspaces: { packages: ["packages/*"] }`). Drop-in replacement for the `get-yarn-workspaces` package. + +## Interface + +- `list(params?: ListWorkspacesParams): WorkspaceInfo[]` — List all workspace directories. Returns `{ name, path }` for each workspace. Throws `WorkspaceRootNotFoundError` if no root `package.json` with `workspaces` is found. + +### Types + +- `ListWorkspacesParams` — `{ cwd?: string }`. Directory to start searching from. Defaults to `process.cwd()`. +- `WorkspaceInfo` — `{ name: string, path: string }`. Package name from the workspace's `package.json` (falls back to folder name), and absolute path. +- `WorkspaceRootNotFoundError` — Thrown when no `package.json` with a `workspaces` field is found walking up from `cwd`. + +## Usage + +### DI container wiring + +```ts +import { Container } from "@webiny/di"; +import { WorkspaceTool, WorkspaceToolFeature } from "@webiny/stdlib/node"; + +const container = new Container(); +WorkspaceToolFeature.register(container); +const tool = container.resolve(WorkspaceTool); + +const workspaces = tool.list({ cwd: "/path/to/monorepo" }); +// [{ name: "@scope/pkg-a", path: "/path/to/monorepo/packages/pkg-a" }, ...] +``` + +### Factory function + +```ts +import { createWorkspaceTool } from "@webiny/stdlib/node"; + +const tool = createWorkspaceTool(); +const workspaces = tool.list(); +``` + +### Standalone function + +```ts +import { listWorkspaces } from "@webiny/stdlib/node"; + +const workspaces = listWorkspaces({ cwd: "/path/to/monorepo" }); +// [{ name: "@scope/pkg-a", path: "/path/to/monorepo/packages/pkg-a" }, ...] +``` diff --git a/src/node/features/WorkspaceTool/WorkspaceTool.ts b/src/node/features/WorkspaceTool/WorkspaceTool.ts new file mode 100644 index 0000000..552dc2e --- /dev/null +++ b/src/node/features/WorkspaceTool/WorkspaceTool.ts @@ -0,0 +1,105 @@ +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import fg from "fast-glob"; +import { + WorkspaceTool as WorkspaceToolAbstraction, + type ListWorkspacesParams, + type WorkspaceInfo +} from "./abstractions/WorkspaceTool.js"; +import { WorkspaceRootNotFoundError } from "./abstractions/errors.js"; + +function getWorkspacePatterns(workspaces: unknown): string[] | null { + if (Array.isArray(workspaces)) { + return workspaces; + } + if (workspaces !== null && typeof workspaces === "object" && "packages" in workspaces) { + const packages = (workspaces as Record)["packages"]; + if (Array.isArray(packages)) { + return packages as string[]; + } + } + return null; +} + +function findWorkspaceRoot(from: string): { root: string; patterns: string[] } { + let current = resolve(from); + + while (true) { + const pkgPath = join(current, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record; + const patterns = getWorkspacePatterns(pkg["workspaces"]); + if (patterns !== null) { + return { root: current, patterns }; + } + } catch { + // malformed package.json, keep walking + } + } + + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + throw new WorkspaceRootNotFoundError({ + message: `No package.json with a workspaces field found from "${from}"` + }); +} + +function readWorkspaceName(dir: string): string { + const pkgPath = join(dir, "package.json"); + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record; + const name = pkg["name"]; + if (typeof name === "string" && name.length > 0) { + return name; + } + } catch { + // fall through to folder name + } + return basename(dir); +} + +class WorkspaceToolImpl implements WorkspaceToolAbstraction.Interface { + public list(params?: ListWorkspacesParams): WorkspaceInfo[] { + const cwd = params?.cwd ?? process.cwd(); + const { root, patterns } = findWorkspaceRoot(cwd); + + const globPatterns = patterns.map(p => p.replace(/\\/g, "/")); + const matched = fg.sync(globPatterns, { + cwd: root, + onlyDirectories: true, + absolute: true + }); + + const workspaces: WorkspaceInfo[] = []; + for (const dir of matched) { + if (!existsSync(join(dir, "package.json"))) { + continue; + } + workspaces.push({ + name: readWorkspaceName(dir), + path: dir + }); + } + + return workspaces; + } +} + +export const WorkspaceTool = WorkspaceToolAbstraction.createImplementation({ + implementation: WorkspaceToolImpl, + dependencies: [] +}); + +export function createWorkspaceTool(): WorkspaceToolAbstraction.Interface { + return new WorkspaceToolImpl(); +} + +export function listWorkspaces(params?: ListWorkspacesParams): WorkspaceInfo[] { + return new WorkspaceToolImpl().list(params); +} diff --git a/src/node/features/WorkspaceTool/abstractions/WorkspaceTool.ts b/src/node/features/WorkspaceTool/abstractions/WorkspaceTool.ts new file mode 100644 index 0000000..3db4dea --- /dev/null +++ b/src/node/features/WorkspaceTool/abstractions/WorkspaceTool.ts @@ -0,0 +1,28 @@ +import { createAbstraction } from "~/common/index.js"; + +export interface ListWorkspacesParams { + /** Directory to start searching from. Walks up until a package.json with workspaces is found. Defaults to process.cwd(). */ + cwd?: string; +} + +export interface WorkspaceInfo { + /** Package name from the workspace's package.json, or the folder name if no name field exists. */ + name: string; + /** Absolute path to the workspace directory. */ + path: string; +} + +/** + * Discovers workspaces defined in the nearest root package.json. + * Supports both flat array and { packages: [] } workspace formats. + */ +export interface IWorkspaceTool { + /** List all workspace directories from the nearest root package.json with a workspaces field. */ + list(params?: ListWorkspacesParams): WorkspaceInfo[]; +} + +export const WorkspaceTool = createAbstraction("Node/WorkspaceTool"); + +export namespace WorkspaceTool { + export type Interface = IWorkspaceTool; +} diff --git a/src/node/features/WorkspaceTool/abstractions/errors.ts b/src/node/features/WorkspaceTool/abstractions/errors.ts new file mode 100644 index 0000000..866b3a9 --- /dev/null +++ b/src/node/features/WorkspaceTool/abstractions/errors.ts @@ -0,0 +1,9 @@ +import { BaseError } from "~/common/index.js"; +import type { ErrorInput } from "~/common/index.js"; + +export class WorkspaceRootNotFoundError extends BaseError { + public readonly code = "WORKSPACE_ROOT_NOT_FOUND" as const; + public constructor(input: ErrorInput) { + super(input); + } +} diff --git a/src/node/features/WorkspaceTool/abstractions/index.ts b/src/node/features/WorkspaceTool/abstractions/index.ts new file mode 100644 index 0000000..7668631 --- /dev/null +++ b/src/node/features/WorkspaceTool/abstractions/index.ts @@ -0,0 +1,2 @@ +export { WorkspaceTool, type ListWorkspacesParams, type WorkspaceInfo } from "./WorkspaceTool.js"; +export { WorkspaceRootNotFoundError } from "./errors.js"; diff --git a/src/node/features/WorkspaceTool/feature.ts b/src/node/features/WorkspaceTool/feature.ts new file mode 100644 index 0000000..f86121a --- /dev/null +++ b/src/node/features/WorkspaceTool/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "~/common/index.js"; +import { WorkspaceTool } from "./WorkspaceTool.js"; + +export const WorkspaceToolFeature = createFeature({ + name: "Node/WorkspaceToolFeature", + register(container) { + container.register(WorkspaceTool).inSingletonScope(); + } +}); diff --git a/src/node/features/WorkspaceTool/index.ts b/src/node/features/WorkspaceTool/index.ts new file mode 100644 index 0000000..30304c7 --- /dev/null +++ b/src/node/features/WorkspaceTool/index.ts @@ -0,0 +1,8 @@ +export { + WorkspaceTool, + type ListWorkspacesParams, + type WorkspaceInfo +} from "./abstractions/index.js"; +export { WorkspaceRootNotFoundError } from "./abstractions/index.js"; +export { WorkspaceToolFeature } from "./feature.js"; +export { createWorkspaceTool, listWorkspaces } from "./WorkspaceTool.js"; diff --git a/src/node/index.ts b/src/node/index.ts index 3db78c1..8417b68 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -66,3 +66,12 @@ export { type HashFolderOptions, type HashFolderResult } from "./features/HashFolderTool/index.js"; +export { + WorkspaceTool, + WorkspaceToolFeature, + createWorkspaceTool, + listWorkspaces, + WorkspaceRootNotFoundError, + type WorkspaceInfo, + type ListWorkspacesParams +} from "./features/WorkspaceTool/index.js";