UNPKG

printable-shell-command

Version:

A helper class to construct shell commands in a way that allows printing them.

325 lines (324 loc) 9.3 kB
// src/index.ts import { styleText } from "node:util"; var DEFAULT_MAIN_INDENTATION = ""; var DEFAULT_ARG_INDENTATION = " "; var DEFAULT_ARGUMENT_LINE_WRAPPING = "by-entry"; var INLINE_SEPARATOR = " "; var LINE_WRAP_LINE_END = " \\\n"; function isString(s) { return typeof s === "string"; } var SPECIAL_SHELL_CHARACTERS = /* @__PURE__ */ new Set([ " ", '"', "'", "`", "|", "$", "*", "?", ">", "<", "(", ")", "[", "]", "{", "}", "&", "\\", ";" ]); var SPECIAL_SHELL_CHARACTERS_FOR_MAIN_COMMAND = ( // biome-ignore lint/suspicious/noExplicitAny: Workaround to make this package easier to use in a project that otherwise only uses ES2022.) SPECIAL_SHELL_CHARACTERS.union(/* @__PURE__ */ new Set(["="])) ); var PrintableShellCommand = class { constructor(commandName, args = []) { this.args = args; if (!isString(commandName)) { throw new Error("Command name is not a string:", commandName); } this.#commandName = commandName; if (typeof args === "undefined") { return; } if (!Array.isArray(args)) { throw new Error("Command arguments are not an array"); } for (let i = 0; i < args.length; i++) { const argEntry = args[i]; if (typeof argEntry === "string") { continue; } if (Array.isArray(argEntry) && argEntry.length === 2 && isString(argEntry[0]) && isString(argEntry[1])) { continue; } throw new Error(`Invalid arg entry at index: ${i}`); } } #commandName; get commandName() { return this.#commandName; } /** For use with `bun`. * * Usage example: * * ``` * import { PrintableShellCommand } from "printable-shell-command"; * import { spawn } from "bun"; * * const command = new PrintableShellCommand( … ); * await spawn(command.toFlatCommand()).exited; * ``` */ toFlatCommand() { return [this.commandName, ...this.args.flat()]; } /** * Convenient alias for `toFlatCommand()`. * * Usage example: * * ``` * import { PrintableShellCommand } from "printable-shell-command"; * import { spawn } from "bun"; * * const command = new PrintableShellCommand( … ); * await spawn(command.forBun()).exited; * ``` * * */ forBun() { return this.toFlatCommand(); } /** * For use with `node:child_process` * * Usage example: * * ``` * import { PrintableShellCommand } from "printable-shell-command"; * import { spawn } from "node:child_process"; * * const command = new PrintableShellCommand( … ); * const child_process = spawn(...command.toCommandWithFlatArgs()); // Note the `...` * ``` * */ toCommandWithFlatArgs() { return [this.commandName, this.args.flat()]; } /** * For use with `node:child_process` * * Usage example: * * ``` * import { PrintableShellCommand } from "printable-shell-command"; * import { spawn } from "node:child_process"; * * const command = new PrintableShellCommand( … ); * const child_process = spawn(...command.forNode()); // Note the `...` * ``` * * Convenient alias for `toCommandWithFlatArgs()`. */ forNode() { return this.toCommandWithFlatArgs(); } #escapeArg(arg, isMainCommand, options) { const argCharacters = new Set(arg); const specialShellCharacters = isMainCommand ? SPECIAL_SHELL_CHARACTERS_FOR_MAIN_COMMAND : SPECIAL_SHELL_CHARACTERS; if (options?.quoting === "extra-safe" || // biome-ignore lint/suspicious/noExplicitAny: Workaround to make this package easier to use in a project that otherwise only uses ES2022.) argCharacters.intersection(specialShellCharacters).size > 0) { const escaped = arg.replaceAll("\\", "\\\\").replaceAll("'", "\\'"); return `'${escaped}'`; } return arg; } #mainIndentation(options) { return options?.mainIndentation ?? DEFAULT_MAIN_INDENTATION; } #argIndentation(options) { return this.#mainIndentation(options) + (options?.argIndentation ?? DEFAULT_ARG_INDENTATION); } #lineWrapSeparator(options) { return LINE_WRAP_LINE_END + this.#argIndentation(options); } #argPairSeparator(options) { switch (options?.argumentLineWrapping ?? DEFAULT_ARGUMENT_LINE_WRAPPING) { case "by-entry": { return INLINE_SEPARATOR; } case "nested-by-entry": { return this.#lineWrapSeparator(options) + this.#argIndentation(options); } case "by-argument": { return this.#lineWrapSeparator(options); } case "inline": { return INLINE_SEPARATOR; } default: throw new Error("Invalid argument line wrapping argument."); } } #entrySeparator(options) { switch (options?.argumentLineWrapping ?? DEFAULT_ARGUMENT_LINE_WRAPPING) { case "by-entry": { return LINE_WRAP_LINE_END + this.#argIndentation(options); } case "nested-by-entry": { return LINE_WRAP_LINE_END + this.#argIndentation(options); } case "by-argument": { return LINE_WRAP_LINE_END + this.#argIndentation(options); } case "inline": { return INLINE_SEPARATOR; } default: throw new Error("Invalid argument line wrapping argument."); } } getPrintableCommand(options) { options ??= {}; const serializedEntries = []; serializedEntries.push( this.#mainIndentation(options) + this.#escapeArg(this.commandName, true, options) ); for (let i = 0; i < this.args.length; i++) { const argsEntry = this.args[i]; if (isString(argsEntry)) { serializedEntries.push(this.#escapeArg(argsEntry, false, options)); } else { const [part1, part2] = argsEntry; serializedEntries.push( this.#escapeArg(part1, false, options) + this.#argPairSeparator(options) + this.#escapeArg(part2, false, options) ); } } let text = serializedEntries.join(this.#entrySeparator(options)); if (options?.styleTextFormat) { text = styleText(options.styleTextFormat, text); } return text; } print(options) { console.log(this.getPrintableCommand(options)); return this; } /** * The returned child process includes a `.success` `Promise` field, per https://github.com/oven-sh/bun/issues/8313 */ spawnNode(options) { const { spawn } = process.getBuiltinModule("node:child_process"); const subprocess = spawn(...this.forNode(), options); Object.defineProperty(subprocess, "success", { get() { return new Promise( (resolve, reject) => this.addListener( "exit", (exitCode) => { if (exitCode === 0) { resolve(); } else { reject(`Command failed with non-zero exit code: ${exitCode}`); } } ) ); }, enumerable: false }); return subprocess; } /** A wrapper for `.spawnNode(…)` that sets stdio to `"inherit"` (common for * invoking commands from scripts whose output and interaction should be * surfaced to the user). */ spawnNodeInherit(options) { if (options && "stdio" in options) { throw new Error("Unexpected `stdio` field."); } return this.spawnNode({ ...options, stdio: "inherit" }); } /** Equivalent to: * * ``` * await this.print().spawnNodeInherit().success; * ``` */ async shellOutNode(options) { await this.print().spawnNodeInherit(options).success; } /** * The returned subprocess includes a `.success` `Promise` field, per https://github.com/oven-sh/bun/issues/8313 */ spawnBun(options) { if (options && "cmd" in options) { throw new Error("Unexpected `cmd` field."); } const { spawn } = process.getBuiltinModule("bun"); const subprocess = spawn({ ...options, cmd: this.forBun() }); Object.defineProperty(subprocess, "success", { get() { return new Promise( (resolve, reject) => this.exited.then((exitCode) => { if (exitCode === 0) { resolve(); } else { reject( new Error( `Command failed with non-zero exit code: ${exitCode}` ) ); } }).catch(reject) ); }, enumerable: false }); return subprocess; } /** * A wrapper for `.spawnBunInherit(…)` that sets stdio to `"inherit"` (common * for invoking commands from scripts whose output and interaction should be * surfaced to the user). */ spawnBunInherit(options) { if (options && "stdio" in options) { throw new Error("Unexpected `stdio` field."); } return this.spawnBun({ ...options, stdio: ["inherit", "inherit", "inherit"] }); } /** Equivalent to: * * ``` * new Response(this.spawnBun(options).stdout); * ``` */ spawnBunStdout(options) { return new Response(this.spawnBun(options).stdout); } /** Equivalent to: * * ``` * await this.print().spawnBunInherit().success; * ``` */ async shellOutBun(options) { await this.print().spawnBunInherit(options).success; } }; export { PrintableShellCommand }; //# sourceMappingURL=index.js.map