workspaces-filter
Version:
A companion for filtering monorepo workspaces, by package name or package dir. Because all package manager's are weird. Useful for running scripts on a subset of workspaces. The primary reason is because Bun's "--filter" feature is buggy, but it's also us
223 lines (197 loc) • 6.2 kB
text/typescript
// SPDX-License-Identifier: MIT
import { shell } from '@tunnckocore/execa';
import fastGlob from 'fast-glob';
import fs from 'node:fs/promises';
import path from 'node:path';
import proc from 'node:process';
import picomatch from 'picomatch';
export interface GraphValue {
dir: string;
name: string;
version: string;
license: string;
exports: Record<string, string>;
scripts: Record<string, string>;
dependencies: Record<string, string>;
}
export type Graph = Record<string, GraphValue>;
/**
* Filters workspace packages based on provided glob patterns and search patterns.
*
* @example
* import { filter } from 'workspaces-filter';
*
* // Filter workspaces matching 'pkg-*' pattern
* const graph = await filter(['packages/*'], 'pkg-*');
*
* // Filter multiple patterns
* const graph = await filter(['packages/*'], ['pkg-1', 'pkg-2']);
*
* // Filter with package dirs
* const graph = await filter(['packages/*'], ['packages/foo']);
*
* // Filter with custom working directory
* const graph = await filter(['packages/*'], '*', '/path/to/project');
*
*
* @param {string|string[]} wsGlobs - Array of workspace glob patterns to search for package.json files.
* @param {string|string[]} pattern - String or array of strings to filter workspaces by name or directory.
* @param {string} [cwd] - Optional current working directory (defaults to `process.cwd()`).
*
* @throws {Error} When no workspace globs are provided.
* @throws {Error} When no pattern is provided.
*
* @returns {Promise<Graph>} Resolving to a Graph object containing filtered workspace metadata.
* @public
*/
export async function filter(
wsGlobs: string[],
pattern: string | string[],
cwd?: string,
): Promise<Graph> {
if (!wsGlobs || (wsGlobs && Array.isArray(wsGlobs) && wsGlobs.length === 0)) {
throw new Error('No workspace globs provided.');
}
pattern = Array.isArray(pattern) ? pattern : [pattern];
pattern = pattern.filter(Boolean);
if (pattern.length === 0) {
throw new Error('No pattern provided.');
}
cwd = cwd || proc.cwd();
const stream = fastGlob.stream(
wsGlobs.map((workspaceGlob: string) => `${workspaceGlob}/package.json`),
{ absolute: true, cwd },
);
const workspaces: Record<string, GraphValue> = {};
for await (const pkgJsonPath of stream) {
const pkgRoot = path.dirname(pkgJsonPath as string);
const pkgJsonStr = await fs.readFile(pkgJsonPath, 'utf8');
const {
dependencies = {},
exports = {},
license = '',
name,
scripts = {},
version = '0.0.0',
} = JSON.parse(pkgJsonStr);
const pkg = {
dependencies,
exports,
license,
name,
scripts,
version,
} as {
name: string;
version: string;
license: string;
exports: Record<string, string>;
scripts: Record<string, string>;
dependencies: Record<string, string>;
};
workspaces[pkg.name] = {
dir: path.relative(cwd, pkgRoot),
...pkg,
};
}
if (
pattern.find((p) => p.trim() === '.')
|| pattern.find((p) => p.trim() === '*')
) {
return workspaces as Graph;
}
const isMatch = picomatch(pattern.map((p) => p.replaceAll('/', '.')));
const graph = Object.fromEntries(
Object.entries(workspaces).filter(([name, meta]: [string, GraphValue]) => {
const hasMatch
= pattern.some((p) => name.includes(p))
|| pattern.some((p) => meta.dir.includes(p))
|| isMatch(name.replaceAll('/', '.'))
|| isMatch(meta.dir.replaceAll('/', '.'));
return hasMatch;
}),
);
return graph as Graph;
}
export interface RunCommandOnOptions {
cwd: string;
isShell: boolean;
packageManager: string;
onTestCallback: (_err: any, _ok: any) => void | Promise<void>;
}
/**
* Executes a shell command or a package script in the context of each package in the graph.
*
* @example
* import { filter, runCommandOn } from 'workspaces-filter';
*
* const graph = await filter(['packages/*'], ['@scope/*']);
* console.log(graph);
*
* type RunCommandOnOptions = {
* cwd?: string;
* isShell?: boolean;
* packageManager?: string;
* onTestCallback?: (_err: any, _ok: any) => void | Promise<void>;
* };
*
* // Run a shell command in each package
* await runCommandOn(['echo', 'Hello, World!'], graph, { isShell: true } as RunCommandOnOptions);
*
* // Run a package script in each package
* await runCommandOn(['build'], graph);
*
* @param {string[]} args - Arguments to pass to the command.
* @param {Graph} graph - Graph object containing package metadata.
* @param {RunCommandOnOptions} options - Optional configuration for running the command.
* @returns {Promise<Graph>} Resolving to the input graph object.
* @public
*/
export async function runCommandOn(
args: string[],
graph: Graph,
options?: Partial<RunCommandOnOptions>,
): Promise<Graph> {
const opts = {
cwd: proc.cwd(),
isShell: false,
onTestCallback: () => {},
onTestCallbackcwd: proc.cwd(),
packageManager: 'bun',
...options,
};
await Promise.all(
Object.values(graph).map(async (x) => {
// const name = x[0];
const meta = x;
const pkgDir = path.join(opts.cwd, meta.dir);
if (opts.isShell) {
try {
await shell(args.join(' '), { cwd: pkgDir, stdout: 'inherit' });
await opts.onTestCallback(null, true);
} catch (err: unknown) {
console.error((err as Error).stack);
await opts.onTestCallback(err, false);
}
return;
}
const [script, ...argz] = args;
const isScript = Boolean(meta.scripts[script || '_____$$$__']);
const cmd = [
opts.packageManager,
isScript ? 'run' : '',
script,
...argz,
].filter(Boolean);
// console.log('Running in "%s" (%s)', meta.dir, meta.name);
try {
await shell(cmd.join(' '), { cwd: pkgDir, stdout: 'inherit' });
await opts.onTestCallback(null, true);
} catch (err: unknown) {
console.error((err as Error).stack);
await opts.onTestCallback(err, false);
}
}),
);
return graph;
}