#!/usr/bin/env node /* * Copyright (c) 2025 Klaus Reimer * SPDX-License-Identifier: MIT */ import { spawn } from "node:child_process"; import { access, constants, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { Readable, Stream } from "node:stream"; import { parseArgs } from "node:util"; import packageJSON from "../../package.json" with { type: "json" }; import { getColorLevel } from "./utils/colors.ts"; import { getErrorMessage, toError } from "./utils/error.ts"; /** * Options for {@link run}. */ interface RunOptions { /** Run commands in parallel instead of sequentially. */ parallel?: boolean; /** Set to true to pass --silent option to NPM. */ silent?: boolean; } export type WritableStream = Stream & NodeJS.WritableStream; /** * CLI IO interface with stdout and stderr. */ export interface IO { stdout: WritableStream; stderr: WritableStream; }; /** * Converts the given unresolved script name into a regular expression which can be used to match actual script names. * * @param command - The unresolved script name. * @returns The regular expression used to match actual script names. */ function commandToRegExp(command: string): RegExp { return new RegExp(`^${command.replaceAll(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll(/\*+/g, joker => joker.length === 1 ? "[^:]+" : ".*")}$`); } /** * @returns The nearest package.json file in current or parent directories. * * @returns The */ async function findNearestPackageJson(dir = process.cwd()): Promise { const candidate = join(dir, "package.json"); try { await access(candidate, constants.F_OK); return candidate; } catch { const parent = dirname(dir); if (parent === dir) { throw new Error("Unable to locate package.json"); } return findNearestPackageJson(parent); } } /** * Resolve wildcard in script names and returns the resolve script names. * * @param commands - The script names containing wildcards. * @returns The resolved script names. */ async function resolveCommands(commands: string[]): Promise { const packageJSONFile = await findNearestPackageJson(); const packageJSON = JSON.parse(await readFile(packageJSONFile, "utf8")) as { scripts: Record }; const existingCommands = Object.keys(packageJSON.scripts); return commands.flatMap(command => { if (command.includes("*")) { const regexp = commandToRegExp(command); return existingCommands.filter(existingCommand => existingCommand.match(regexp)); } else { return command; } }); } /** Set keeping track of which commands are currently running (in parallel mode) */ const runningCommands = new Set(); /** Buffers used to record outputs of commands in parallel mode. */ const buffers = new Map>(); /** * @returns The first command in the parallel executed command queue or null if queue is empty. */ function getPrimaryCommand(): string | null { const [ command ] = runningCommands; return command ?? null; } /** * Prints a text to given stream. This either happens directly if command is the primary command or is buffered otherwise. * * @param stream - The stream to write the text to. * @param text - The text to write. * @param command - The command which produces the text. */ function write(stream: WritableStream, text: string, command: string): void { if (getPrimaryCommand() === command) { // When command is the primary command then write it to target stream directly stream.write(text); } else { // Otherwise append text to buffer let buffer = buffers.get(command); if (buffer == null) { buffer = []; buffers.set(command, buffer); } buffer.push({ stream: stream, text }); } } /** * Flushes the buffer of the given command. * * @param command - The command for which to flush the buffer. */ function flush(command: string | null): void { if (command != null) { const buffer = buffers.get(command); if (buffer != null) { for (const data of buffer) { data.stream.write(data.text); } buffers.delete(command); } } } /** * Flush output of all remaining commands which have not yet been flushed. */ function flushAll(): void { for (const command of buffers.keys()) { flush(command); } } /** * Runs given command with given options. * * @param command - The command to run. * @param options - The run options. * @param io - stdout/stderr streams. */ async function runCommand(command: string, { parallel = false, silent }: RunOptions, io: IO): Promise { const npmOptions: string[] = []; if (silent) { npmOptions.push("-s"); } // Record command (only needed during parallel execution) runningCommands.add(command); return new Promise((resolve, reject) => { // Build environment based on current environment plus optionally added FORCE_COLOR variable for parallel execution. When command output is // buffered then programs can't check the output stream for color support. Setting FORCE_COLOR helps for most programs, but not all. const env = { ...process.env }; if (parallel) { env.FORCE_COLOR ??= String(getColorLevel()); } // Start NPM command const npmExecPath = process.env.npm_execpath; if (npmExecPath == null) { throw new Error("Environment variable 'npm_execpath' not found. Make sure to run this script through NPM"); } // When npmExecPath does not end with ".js" then use it directly as executable, other wise use node executable const executable = /\.m?js/.exec(npmExecPath) != null ? process.execPath : npmExecPath; const args = [ "run", ...npmOptions, command ]; if (npmExecPath !== executable) { args.unshift(npmExecPath); } const child = spawn(executable, args, { stdio: [ "inherit", (parallel || io.stdout as NodeJS.WriteStream !== process.stdout as NodeJS.WriteStream) ? "pipe" : "inherit", (parallel || io.stderr as NodeJS.WriteStream !== process.stderr as NodeJS.WriteStream) ? "pipe" : "inherit" ], env } ); // When parallel execution then handle buffered output if (parallel && child.stdout != null && child.stderr != null) { const bufferStream = (stream: Readable, target: WritableStream): void => { stream.on("data", (chunk: string) => { write(target, chunk, command); }); } bufferStream(child.stdout, io.stdout); bufferStream(child.stderr, io.stderr); } else { // Direct pipe to custom IO if used child.stdout?.pipe(io.stdout, { end: false }); child.stderr?.pipe(io.stderr, { end: false }); } child.on("error", reject); child.on("close", (code) => { // Remove command from list of running commands runningCommands.delete(command); // Flush buffer of current primary command so this command can continue writing output directly to console flush(getPrimaryCommand()); // Handle exit code if (code === 0) { resolve(); } else { reject(new Error(`${command} exited with code ${code}`)); } }); }); } /** * Runs the given commands. * * @param commands - The commands to run. Can use asterisk character as wildcard. * @param options - The run options. * @param io - stdout/stderr streams. */ async function run(commands: string[], options: RunOptions, io: IO): Promise { commands = await resolveCommands(commands); if (options.parallel && commands.length > 1) { const promises: Array> = []; const errors: Error[] = []; // Start all commands in parallel and gather errors for (const command of commands) { const promise = runCommand(command, options, io); promise.catch((error: unknown) => errors.push(toError(error))); promises.push(promise); } // Wait for all parallel script executions to be finished (success or error) await Promise.allSettled(promises); // Flush remaining buffered output flushAll(); // Handle gathered errors if (errors.length === 1) { throw errors[0]; } else if (errors.length > 1) { const messages = errors.map(getErrorMessage); throw new AggregateError(errors, `Execution of ${errors.length} commands failed\n ${messages.join("\n ")}`); } } else { // Run all commands sequentially for (const command of commands) { await runCommand(command, { ...options, parallel: false }, io); } } } /** The help text. */ const help = `Usage: run [OPTION]... [COMMAND]... Runs given package script commands. Options: --parallel, -p Run commands in parallel instead of sequentially. --silent, -s Passes --silent option to NPM. --help display this help and exit --version output version information and exit Report bugs to <${packageJSON.bugs}>. `; /** The version text. */ const version = `run ${packageJSON.version} Written by ${packageJSON.author.name} <${packageJSON.author.email}> `; /** * Main function. * * @param args - The command line arguments (`process.argv.slice(2)`) * @param io - Optional custom stdout/stderr streams. Defaults to Node.js `process`. * @returns The exit code */ export async function main(args: string[], io: IO = process): Promise { try { const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { parallel: { type: "boolean", short: "p", default: false }, silent: { type: "boolean", short: "s", default: false }, help: { type: "boolean", default: false }, version: { type: "boolean", default: false } } }); if (values.help) { io.stdout.write(help); return 0; } if (values.version) { io.stdout.write(version); return 0; } if (positionals.length === 0) { io.stderr.write("run: missing script name\nTry 'cmd --help' for more information.\n"); return 2; } const options: RunOptions = { parallel: values.parallel, silent: values.silent }; await run(positionals, options, io); return 0; } catch (error) { io.stderr.write(`run: ${getErrorMessage(error)}\n`); return 1; } } if (import.meta.main) { process.exitCode = await main(process.argv.slice(2)); }