Skip to content
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

feat: workspace utils #118

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,42 @@ import { findWorkspaceDir } from "pkg-types";
const workspaceDir = await findWorkspaceDir(".");
```

### `resolveWorkspace`

Find monorepo workspace config (support: `pnpm`, `yarn`, `npm`, `lerna`), will return `root` path, `type` of monorepo manager, `workspaces` config.

If fails, throws an error.

```js
import { resolveWorkspace } from 'pkg-types'
const {
root,
type,
workspaces,
} = await resolveWorkspace('.')
```

### `resolveWorkspacePkgs`

Find monorepo workspace packages and read each package `package.json`

```js
import { resolveWorkspacePkgs } from 'pkg-types'
const { type, root, packages } = await resolveWorkspacePkgs('.')
console.log(root) // { dir: 'fully/resolved/root/path', packageJson: { ... } }
console.log(packages) // [ { dir: 'fully/resolved/foo/path', packageJson: { ... } } ]
```

### `resolveWorkspacePkgsGraph`

Find monorepo workspace packages graph sort by `devDependency` and `dependency`

```js
import { resolveWorkspacePkgsGraph } from 'pkg-types'
const pkgsGraph = await resolveWorkspacePkgsGraph('.')
console.log(pkgsGraph) // [['foo'], ['bar']]
```

## Types

**Note:** In order to make types working, you need to install `typescript` as a devDependency.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.2"
"pathe": "^2.0.2",
"tinyglobby": "^0.2.10"
},
"devDependencies": {
"@types/node": "^22.12.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

193 changes: 187 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { promises as fsp } from "node:fs";
import { dirname, resolve, isAbsolute } from "pathe";
import { type ResolveOptions as _ResolveOptions, resolvePath } from "mlly";
import { findFile, type FindFileOptions, findNearestFile } from "./utils";
import type { PackageJson, TSConfig } from "./types";
import { parseJSONC, parseJSON, stringifyJSON, stringifyJSONC } from "confbox";

import {
findFile,
type FindFileOptions,
findNearestFile,
existsFile,
} from "./utils";
import type { PackageJson, TSConfig, Workspace, WorkspaceType } from "./types";
import {
parseJSONC,
parseJSON,
stringifyJSON,
stringifyJSONC,
parseYAML,
} from "confbox";
import { glob } from "tinyglobby";
export * from "./types";
export * from "./utils";

Expand Down Expand Up @@ -60,7 +71,7 @@
? options.cache
: FileCache;
if (options.cache && cache.has(resolvedPath)) {
return cache.get(resolvedPath)!;
return cache.get(resolvedPath) as PackageJson;

Check warning on line 74 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L74

Added line #L74 was not covered by tests
}
const blob = await fsp.readFile(resolvedPath, "utf8");
let parsed: PackageJson;
Expand Down Expand Up @@ -101,7 +112,7 @@
? options.cache
: FileCache;
if (options.cache && cache.has(resolvedPath)) {
return cache.get(resolvedPath)!;
return cache.get(resolvedPath) as TSConfig;

Check warning on line 115 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L115

Added line #L115 was not covered by tests
}
const text = await fsp.readFile(resolvedPath, "utf8");
const parsed = parseJSONC(text) as TSConfig;
Expand Down Expand Up @@ -229,3 +240,173 @@

throw new Error("Cannot detect workspace root from " + id);
}

const workspaceConfigFiles = [
{
type: "pnpm",
lockFile: "pnpm-lock.yaml",
config: "pnpm-workspace.yaml",
},
{
type: "lerna",
lockFile: "lerna.json",
config: "lerna.json",
},
{
type: "yarn",
lockFile: "yarn.lock",
config: "package.json",
},
{
type: "npm",
config: "package.json",
},
] as const;

export async function resolveWorkspace(
id: string = process.cwd(),
options: ResolveOptions = {},
): Promise<Workspace> {
const resolvedPath = isAbsolute(id) ? id : await resolvePath(id, options);

for (const item of workspaceConfigFiles) {
const configFilePath = await findNearestFile(item.config, {
startingFrom: resolvedPath,
test: (filePath) => {
const dir = dirname(filePath);
if ("lockFile" in item) {
const detectPath = resolve(dir, item.lockFile);
if (!existsFile(detectPath)) {
return false;
}
}

const configPath = resolve(dir, item.config);
return existsFile(configPath);
},
...options,
}).catch(() => undefined);

if (!configFilePath) {
continue;
}

const rootDir = dirname(configFilePath);
const configString = await fsp.readFile(configFilePath, "utf8");

switch (item.type) {
case "pnpm": {
const content = parseYAML(configString) as any;
return {
type: item.type,
root: rootDir,
workspaces: content.packages,
};
}
case "lerna": {
const content = JSON.parse(configString);
if (content.useWorkspaces === true) {
// If lerna set `useWorkspaces`, fallback to yarn or npm
continue;
}
return {
type: item.type,
root: rootDir,
workspaces: content.packages || ["packages/*"], // is lerna default workspaces
};
}
case "yarn":
case "npm": {
const content = JSON.parse(configString);
if (!("workspaces" in content)) {
continue;
}

Check warning on line 323 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L322-L323

Added lines #L322 - L323 were not covered by tests

const workspaces = Array.isArray(content.workspaces)
? content.workspaces
: content.workspaces.packages;

Check warning on line 327 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L327

Added line #L327 was not covered by tests

if (!Array.isArray(workspaces)) {
continue;
}

Check warning on line 331 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L330-L331

Added lines #L330 - L331 were not covered by tests

return {
type: item.type,
root: rootDir,
workspaces,
};
}
}
}

throw new Error(`Cannot dected workspace from ${id}`);
}

Check warning on line 343 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L342-L343

Added lines #L342 - L343 were not covered by tests

export async function resolveWorkspacePkgs(
id: string | Awaited<ReturnType<typeof resolveWorkspace>>,
options: ResolveOptions = {},
): Promise<{
type: WorkspaceType;
root: {
dir: string;
packageJson: PackageJson;
};
packages: {
dir: string;
packageJson: PackageJson;
}[];
}> {
const config =
typeof id === "string" ? await resolveWorkspace(id, options) : id;
const pkgDirs: string[] = await glob(config.workspaces, {
cwd: config.root,
onlyDirectories: true,
expandDirectories: false,
ignore: ["**/node_modules"],
});
const pkgAbsoluteDirs = pkgDirs.map((p) => resolve(config.root, p)).sort();

return {
type: config.type,
root: {
dir: config.root,
packageJson: await readPackageJSON(config.root, options),
},
packages: await Promise.all(
pkgAbsoluteDirs.map(async (dir) => ({
dir,
packageJson: await readPackageJSON(dir, options),
})),
),
};
}

export async function resolveWorkspacePkgsGraph(
id:
| string
| Awaited<ReturnType<typeof resolveWorkspace>>
| Awaited<ReturnType<typeof resolveWorkspacePkgs>>,
options: ResolveOptions = {},
): Promise<string[][]> {
const resolvedPkgs =
typeof id === "object" && "packages" in id
? id

Check warning on line 393 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L393

Added line #L393 was not covered by tests
: await resolveWorkspacePkgs(id, options);

const pkgGraph = {} as any;
for (const pkg of resolvedPkgs.packages) {
const { name, dependencies, devDependencies } = pkg.packageJson;
if (!name) {
continue;
}

Check warning on line 401 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L400-L401

Added lines #L400 - L401 were not covered by tests

pkgGraph[name] = {
dependencies: [
...Object.keys(dependencies ?? {}),
...Object.keys(devDependencies ?? {}),
],
};
}

return pkgGraph;
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./tsconfig";
export * from "./packagejson";
export * from "./workspace";
18 changes: 18 additions & 0 deletions src/types/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type WorkspaceType = "npm" | "yarn" | "pnpm" | "lerna";

export interface Workspace {
/**
* The root directory of the workspace.
*/
root: string;

/**
* The type of the workspace.
*/
type: WorkspaceType;

/**
* Paths to the workspaces.
*/
workspaces: string[];
}
20 changes: 11 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,7 @@ const defaultFindOptions: Required<FindFileOptions> = {
startingFrom: ".",
rootPattern: /^node_modules$/,
reverse: false,
test: (filePath: string) => {
try {
if (statSync(filePath).isFile()) {
return true;
}
} catch {
// Ignore
}
},
test: existsFile,
};

/**
Expand Down Expand Up @@ -125,3 +117,13 @@ export function findFarthestFile(
): Promise<string> {
return findFile(filename, { ..._options, reverse: true });
}

export function existsFile(filePath: string) {
try {
if (statSync(filePath).isFile()) {
return true;
}
} catch {
// Ignore
}
}
4 changes: 4 additions & 0 deletions test/fixture/monorepo/lerna-use-npm-workspaces/lerna.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"useWorkspaces": true,
"packages": ["failed-packages/*"]
}
7 changes: 7 additions & 0 deletions test/fixture/monorepo/lerna-use-npm-workspaces/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "monorepo-lerna-use-npm-workspaces",
"private": true,
"workspaces": [
"packages/*"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "bar",
"version": "0.1.0",
"dependencies": {
"foo": "0.1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "foo",
"version": "0.1.0"
}
4 changes: 4 additions & 0 deletions test/fixture/monorepo/lerna-use-yarn-workspaces/lerna.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"useWorkspaces": true,
"packages": ["failed-packages/*"]
}
7 changes: 7 additions & 0 deletions test/fixture/monorepo/lerna-use-yarn-workspaces/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "monorepo-lerna-use-yarn-workspaces",
"private": true,
"workspaces": [
"packages/*"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "bar",
"version": "0.1.0",
"dependencies": {
"foo": "0.1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "foo",
"version": "0.1.0"
}
Empty file.
3 changes: 3 additions & 0 deletions test/fixture/monorepo/lerna/lerna.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"packages": ["packages/*"]
}
7 changes: 7 additions & 0 deletions test/fixture/monorepo/lerna/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "monorepo-lerna",
"private": true,
"workspaces": [
"packages/*"
]
}
Loading