UNPKG

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
// 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; }