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

205 lines (202 loc) 8.11 kB
#!/usr/bin/env node import cac from "cac"; import dargs from "dargs"; import fs from "node:fs/promises"; import path from "node:path"; import proc from "node:process"; import { shell } from "@tunnckocore/execa"; import fastGlob from "fast-glob"; import picomatch from "picomatch"; //#region src/index.ts /** * 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 */ async function filter(wsGlobs, pattern, cwd) { 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) => `${workspaceGlob}/package.json`), { absolute: true, cwd }); const workspaces = {}; for await (const pkgJsonPath of stream) { const pkgRoot = path.dirname(pkgJsonPath); 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 }; workspaces[pkg.name] = { dir: path.relative(cwd, pkgRoot), ...pkg }; } if (pattern.find((p) => p.trim() === ".") || pattern.find((p) => p.trim() === "*")) return workspaces; const isMatch = picomatch(pattern.map((p) => p.replaceAll("/", "."))); return Object.fromEntries(Object.entries(workspaces).filter(([name, meta]) => { return pattern.some((p) => name.includes(p)) || pattern.some((p) => meta.dir.includes(p)) || isMatch(name.replaceAll("/", ".")) || isMatch(meta.dir.replaceAll("/", ".")); })); } /** * 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 */ async function runCommandOn(args, graph, options) { const opts = { cwd: proc.cwd(), isShell: false, onTestCallback: () => {}, onTestCallbackcwd: proc.cwd(), packageManager: "bun", ...options }; await Promise.all(Object.values(graph).map(async (x) => { 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) { console.error(err.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); try { await shell(cmd.join(" "), { cwd: pkgDir, stdout: "inherit" }); await opts.onTestCallback(null, true); } catch (err) { console.error(err.stack); await opts.onTestCallback(err, false); } })); return graph; } //#endregion //#region src/cli.ts const cli = cac("workspaces-filter").version("0.8.2"); cli.command("<pattern> [...command]", "Select by package name or workspace directory", { allowUnknownOptions: true }).example("workspaces-filter . build # run in all packages of all workspaces").example("workspaces-filter _ build # because the \"*\" would not work if raw").example("workspaces-filter '*' build # should be quoted to avoid shell globbing").example("").example("workspaces-filter \"*preset*\" build").example("workspaces-filter \"*preset*\" add foo-pkg barry-pkg").example("workspaces-filter \"*preset*\" add --dev typescript").example("").example("workspaces-filter \"./packages/foo\" -- echo \"Hello, World!\"").example("workspaces-filter \"./packages/*preset*\" -- pwd").example("").example("workspaces-filter \"*preset*\" --print names").example("workspaces-filter \"*preset*\" --print json").example("workspaces-filter \"*preset*\" --print dirs").example("").option("--print <mode>", "Print the names/folders of selected packages, without running command").option("--cwd <dir>", "Current working directory", { default: proc.cwd() }).option("--pm, --package-manager <pm>", "The package manager to use. Defaults to packageManager from root package.json, or Bun").action(async (pattern, command, options) => { const opts = { ...options }; if (opts["--"].length === 0 && command.length === 0 && !opts.print) { cli.outputHelp(); return; } const flags = { ...options }; delete flags.print; delete flags.cwd; delete flags.pm; delete flags["package-manager"]; delete flags.packageManager; const flagged = dargs(flags, { useEquals: false }); if (flagged[0] === "--") flagged.shift(); const rootPkgJson = JSON.parse(await fs.readFile(path.join(opts.cwd, "package.json"), "utf8")); opts.packageManager = opts.packageManager || rootPkgJson.packageManager?.split("@")?.[0] || "bun"; opts.pm = opts.packageManager; opts.isShell = command.length === 0; let workspaces = rootPkgJson.workspaces; workspaces = rootPkgJson.workspaces?.packages || rootPkgJson.workspace?.packages || workspaces; if (opts.packageManager === "pnpm") workspaces = (await import("yaml").then(({ parse }) => parse))(await fs.readFile(path.join(opts.cwd, "pnpm-workspace.yaml"), "utf8")).packages; if (!workspaces || workspaces.length === 0) { console.log("No workspaces found! Make sure you have 'workspaces' field in your package.json or 'packages' field in your pnpm-workspace.yaml"); return proc.exit(0); } if (opts.help) { cli.outputHelp(); return; } pattern = pattern === "." || pattern === "_" || pattern === "*" ? workspaces : pattern; const selected = await filter(workspaces, pattern, opts.cwd); if (opts.print) { if (opts.print === "json") { console.log(JSON.stringify(selected)); return; } if (opts.print === "names") { console.log(Object.keys(selected).join("\n")); return; } if (opts.print === "dirs") console.log(Object.values(selected).reduce((acc, x) => acc.concat(x.dir), []).join("\n")); return; } if (Object.keys(selected).length === 0) { console.log("No packages matching the filter."); proc.exit(0); } else await runCommandOn(opts.isShell ? flagged : command.concat(flagged).flat(), selected, opts); }); cli.help(); cli.parse(); //#endregion export { };