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
JavaScript
// 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