Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

run-pty

Package Overview
Dependencies
Maintainers
1
Versions
26
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

run-pty - npm Package Compare versions

Comparing version 3.0.0 to 4.0.0

27

package.json
{
"name": "run-pty",
"version": "3.0.0",
"version": "4.0.0",
"author": "Simon Lydell",

@@ -28,19 +28,20 @@ "license": "MIT",

"node-pty": "^0.10.1",
"tiny-decoders": "^6.0.1"
"tiny-decoders": "^7.0.1"
},
"devDependencies": {
"@types/jest": "27.4.1",
"@typescript-eslint/eslint-plugin": "5.13.0",
"@typescript-eslint/parser": "5.13.0",
"eslint": "8.10.0",
"eslint-plugin-jest": "26.1.1",
"jest": "27.5.1",
"jest-environment-node-single-context": "27.3.0",
"prettier": "2.5.1",
"typescript": "4.5.5"
"@types/jest": "28.1.8",
"@typescript-eslint/eslint-plugin": "5.35.1",
"@typescript-eslint/parser": "5.35.1",
"eslint": "8.23.0",
"eslint-plugin-jest": "26.8.7",
"jest": "29.0.1",
"jest-environment-node-single-context": "28.1.0",
"prettier": "2.7.1",
"typescript": "4.7.4"
},
"scripts": {
"start": "node run-pty.js % cat % false % echo hello world % ping localhost % node get-cursor-position.js % node test-keys.js % node signals.js % node slow-kill.js % node slow-kill.js 2000 \"Shutting down…\" % make watch % make signals % node test-clear-down.js % node colored-log.js",
"start": "node run-pty.js % cat % false % echo hello world % ping localhost % node get-cursor-position.js % node test-keys.js % node signals.js % node slow-kill.js % node slow-kill.js 2000 \"Shutting down…\" % make watch % make signals % node test-clear-down.js % node colored-log.js % node test-exit-in-middle.js",
"example": "node run-pty.js example.json",
"test": "prettier --check . && eslint . --report-unused-disable-directives && tsc && jest",
"auto-exit": "node run-pty.js --auto-exit=2 % sleep 3 % sleep 1 % sleep 2 % sleep 1 % sleep 1 && echo success",
"test": "node run-pty.js --auto-exit % prettier --check . % eslint . --report-unused-disable-directives % tsc % jest",
"prepublishOnly": "npm test"

@@ -47,0 +48,0 @@ },

# run-pty
`run-pty` is a command line tool that lets you run several commands _concurrently_ and _interactively._ Show output for one command at a time. Kill all at once. Nothing more, nothing less.
`run-pty` is a command line tool that lets you run several commands _concurrently_ and _interactively._ Show output for one command at a time. Kill all at once.

@@ -13,2 +13,4 @@ It’s like [concurrently] but the command outputs aren’t mixed, and you can restart commands individually and interact with them. I bet you can do the same with [tmux] if you – and your team mates – feel like installing and learning it. In `bash` you can use `command1 & command2` together with `fg`, `bg`, `jobs` and <kbd>ctrl+z</kbd> to achieve a similar result, but run-pty tries to be easier to use, and cross-platform.

Another use case is running a couple of commands in parallel, using [--auto-exit](#--auto-exit).
## Example

@@ -53,9 +55,6 @@

vite v2.8.4 dev server running at:
VITE v3.0.9 ready in 137 ms
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 136ms.
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

@@ -182,4 +181,4 @@ [ctrl+c] kill (pid 63096)

- The first string is used on all OS:es except Windows, unless the `NO_COLOR` environment variable is set. The string is drawn in 2 character slots in the terminal – if your string is longer, it will be cut off. Emojis usually need 2 character slots.
- The second string is used on Windows or if `NO_COLOR` is set. In `NO_COLOR` mode, [graphic renditions] are stripped as well. So you can use ANSI codes (in either string) to make your experience more colorful while still letting people have monochrome output if they prefer. Unlike the first string, the second string is drawn in **1** character slot in the terminal. (Windows does not support emojis in the terminal very well, and for `NO_COLOR` you might not want colored emojis, so a single character should do.)
- The first string is used primarily. The string is drawn in 2 character slots in the terminal – if your string is longer, it will be cut off. Emojis usually need 2 character slots.
- The second string is used on Windows (except if you use [Windows Terminal] instead of for example cmd.exe) or if the `NO_COLOR` environment variable is set. In `NO_COLOR` mode, [graphic renditions] are stripped as well. So you can use ANSI codes (in either string) to make your experience more colorful while still letting people have monochrome output if they prefer. Unlike the first string, the second string is drawn in **1** character slot in the terminal. (Windows – except the newer [Windows Terminal] – does not support emojis in the terminal very well, and for `NO_COLOR` you might not want colored emojis, so a single character should do.)
- `null` resets the indicator to the standard 🟢 one (_not_ `defaultStatus`).

@@ -191,4 +190,20 @@

Instead of JSON, you can also use [NDJSON] – one JSON object per line (blank lines are OK, too). This is handy if you generate the file on the fly using some primitive scripting language.
## --auto-exit
If you want to run a couple of commands in parallel and once they’re done continue with something else, use `--auto-exit`:
```bash
run-pty --auto-exit % npm ci % dotnet restore && node build.js
```
- You can enter the different commands while they are running to see their progress.
- Once all commands exit with code 0 (success), run-pty exits with code 0 as well.
- If some command fails, run-pty does _not_ exit, so you can inspect the failure, and re-run that command if you want.
- If you exit run-pty before all commands have exited with code 0, run-pty exits with code 1, so that if run-pty was part of a longer command chain, that chain is ended.
- In CI – where there is no TTY – the `--auto-exit` mode degrades to a simpler, non-interactive UI.
To limit how many commands run in parallel, use for example `--auto-exit=5`. Just `--auto-exit` is the same as `--auto-exit=auto`, which uses the number of logical CPU cores.
Note: `--auto-exit` is for conveniently running a couple of commands in parallel and get to know once they are done. I don’t want the feature to grow to [GNU Parallel] levels of complexity.
## Credits

@@ -215,6 +230,7 @@

[concurrently]: https://github.com/kimmobrunfeldt/concurrently
[gnu parallel]: https://www.gnu.org/software/parallel/
[graphic renditions]: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
[iterm2]: https://www.iterm2.com/
[microsoft/node-pty]: https://github.com/microsoft/node-pty
[ndjson]: https://github.com/ndjson/ndjson-spec
[tmux]: https://github.com/tmux/tmux
[windows terminal]: https://aka.ms/terminal

@@ -7,2 +7,3 @@ #!/usr/bin/env node

const path = require("path");
const os = require("os");
const pty = require("node-pty");

@@ -13,5 +14,6 @@ const Decode = require("tiny-decoders");

* @typedef {
| { tag: "Waiting" }
| { tag: "Running", terminal: import("node-pty").IPty }
| { tag: "Killing", terminal: import("node-pty").IPty, slow: boolean, lastKillPress: number | undefined }
| { tag: "Exit", exitCode: number }
| { tag: "Exit", exitCode: number, wasKilled: boolean }
} Status

@@ -26,2 +28,4 @@ *

const IS_WINDOWS = process.platform === "win32";
const IS_WINDOWS_TERMINAL = "WT_SESSION" in process.env; // https://github.com/microsoft/terminal/issues/1040
const SUPPORTS_EMOJI = !IS_WINDOWS || IS_WINDOWS_TERMINAL;

@@ -61,7 +65,4 @@ // https://github.com/sindresorhus/ansi-escapes/blob/2b3b59c56ff77a2afdee946bff96f1779d10d775/index.js#L5

up: "\x1B[A",
upVim: "k",
down: "\x1B[B",
downVim: "j",
enter: "\r",
enterVim: "o",
esc: "\x1B",

@@ -124,5 +125,11 @@ };

const waitingIndicator = NO_COLOR
? "■"
: !SUPPORTS_EMOJI
? `\x1B[93m■${RESET_COLOR}`
: "🥱";
const runningIndicator = NO_COLOR
? "›"
: IS_WINDOWS
: !SUPPORTS_EMOJI
? `\x1B[92m●${RESET_COLOR}`

@@ -133,6 +140,12 @@ : "🟢";

? "○"
: IS_WINDOWS
: !SUPPORTS_EMOJI
? `\x1B[91m○${RESET_COLOR}`
: "⭕";
const abortedIndicator = NO_COLOR
? "▲"
: !SUPPORTS_EMOJI
? `\x1B[91m▲${RESET_COLOR}`
: "⛔️";
/**

@@ -147,3 +160,3 @@ * @param {number} exitCode

? "●"
: IS_WINDOWS
: !SUPPORTS_EMOJI
? `\x1B[97m●${RESET_COLOR}`

@@ -153,7 +166,11 @@ : "⚪"

? "×"
: IS_WINDOWS
: !SUPPORTS_EMOJI
? `\x1B[91m●${RESET_COLOR}`
: "🔴";
const folder = NO_COLOR ? "⌂" : IS_WINDOWS ? `\x1B[2m⌂${RESET_COLOR}` : "📂";
const folder = NO_COLOR
? "⌂"
: !SUPPORTS_EMOJI
? `\x1B[2m⌂${RESET_COLOR}`
: "📂";

@@ -216,8 +233,9 @@ /**

const at = dim("@");
const et = dim("&&");
const [ICON_WIDTH, EMOJI_WIDTH_FIX] =
IS_WINDOWS || NO_COLOR ? [1, ""] : [2, cursorHorizontalAbsolute(3)];
!SUPPORTS_EMOJI || NO_COLOR ? [1, ""] : [2, cursorHorizontalAbsolute(3)];
/**
* @param {Array<string>} labels
* @param {Array<string | undefined>} labels
* @returns {string}

@@ -245,2 +263,10 @@ */

const autoExitHelp = `
--auto-exit=<number> auto exit when done, with at most <number> parallel processes
--auto-exit=auto uses the number of logical CPU cores
--auto-exit defaults to auto
`
.slice(1)
.trimEnd();
const help = `

@@ -260,6 +286,12 @@ Run several commands concurrently.

Alternatively, specify the commands in a JSON (or NDJSON) file:
Alternatively, specify the commands in a JSON file:
${runPty} run-pty.json
You can tell run-pty to exit once all commands have exited with status 0:
${runPty} --auto-exit ${pc} npm ci ${pc} dotnet restore ${et} node build.js
${autoExitHelp}
Keyboard shortcuts:

@@ -290,3 +322,3 @@

? `kill all ${dim("(double-press to force) ")}`
: commands.every((command) => command.status.tag === "Exit")
: commands.every((command) => !("terminal" in command.status))
? "exit"

@@ -297,14 +329,22 @@ : "kill all";

* @param {Array<Command>} commands
* @param {number} width
* @param {Selection} selection
* @param {{ width: number, useSeparateKilledIndicator: boolean }} options
* @returns {Array<{ line: string, length: number }>}
*/
const drawDashboardCommandLines = (commands, width, selection) => {
const drawDashboardCommandLines = (
commands,
selection,
{ width, useSeparateKilledIndicator }
) => {
const lines = commands.map((command) => {
const [icon, status] = statusText(command.status, command.statusFromRules);
const [icon, status] = statusText(command.status, {
statusFromRules: command.statusFromRules,
useSeparateKilledIndicator,
});
const { label = " " } = command;
return {
label: shortcut(command.label || " ", { pad: false }),
label: shortcut(label, { pad: false }),
icon,
status,
title: command.titleWithGraphicRenditions,
title: command.titlePossiblyWithGraphicRenditions,
};

@@ -349,17 +389,27 @@ });

/**
* @param {Array<Command>} commands
* @param {number} width
* @param {boolean} attemptedKillAll
* @param {Selection} selection
* @param {{
commands: Array<Command>,
width: number,
attemptedKillAll: boolean,
autoExit: AutoExit,
selection: Selection,
}} options
* @returns {string}
*/
const drawDashboard = (commands, width, attemptedKillAll, selection) => {
const done =
attemptedKillAll &&
commands.every((command) => command.status.tag === "Exit");
const drawDashboard = ({
commands,
width,
attemptedKillAll,
autoExit,
selection,
}) => {
const done = isDone({ commands, attemptedKillAll, autoExit });
const finalLines = drawDashboardCommandLines(
commands,
width,
done ? { tag: "Invisible", index: 0 } : selection
done ? { tag: "Invisible", index: 0 } : selection,
{
width,
useSeparateKilledIndicator: autoExit.tag === "AutoExit",
}
)

@@ -375,2 +425,4 @@ .map(({ line }) => line)

// Clicks might be supported in Windows 11, but not Windows 10.
// https://github.com/microsoft/terminal/issues/376
const click = IS_WINDOWS ? "" : ` ${dim("(or click)")}`;

@@ -385,3 +437,11 @@

pid === undefined
? commands.some((command) => command.status.tag === "Exit")
? autoExit.tag === "AutoExit"
? commands.some(
(command) =>
command.status.tag === "Exit" &&
(command.status.exitCode !== 0 || command.status.wasKilled)
)
? `${shortcut(KEYS.enter)} restart failed`
: ""
: commands.some((command) => command.status.tag === "Exit")
? `${shortcut(KEYS.enter)} restart exited`

@@ -393,2 +453,18 @@ : ""

const sessionEnds = "The session ends automatically once all commands are ";
const autoExitText =
autoExit.tag === "AutoExit"
? [
enter === "" ? undefined : "",
`At most ${autoExit.maxParallel} ${
autoExit.maxParallel === 1 ? "command runs" : "commands run"
} at a time.`,
`${sessionEnds}${exitIndicator(0)}${cursorHorizontalAbsolute(
sessionEnds.length + ICON_WIDTH + 1
)} ${bold("exit 0")}.`,
]
.filter((x) => x !== undefined)
.join("\n")
: "";
return `

@@ -401,2 +477,3 @@ ${finalLines}

${enter}
${autoExitText}
`.trim();

@@ -406,20 +483,67 @@ };

/**
* @param {Command} command
* @param {Array<Command>} commands
* @returns {string}
*/
const getPid = (command) => {
switch (command.status.tag) {
case "Running":
case "Killing":
return ` ${dim(`(pid ${command.status.terminal.pid})`)}`;
case "Exit":
return "";
}
const drawSummary = (commands) => {
const summary = commands.every(
(command) =>
command.status.tag === "Exit" &&
command.status.exitCode === 0 &&
!command.status.wasKilled
)
? "success"
: commands.some(
(command) =>
command.status.tag === "Exit" &&
command.status.exitCode !== 0 &&
!command.status.wasKilled
)
? "failure"
: "aborted";
const lines = commands.map((command) => {
const [indicator, status] = statusText(command.status, {
useSeparateKilledIndicator: true,
});
return `${indicator}${EMOJI_WIDTH_FIX} ${
status === undefined ? "" : `${status} `
}${command.titlePossiblyWithGraphicRenditions}${RESET_COLOR}`;
});
return `${bold(`Summary – ${summary}:`)}\n${lines.join("\n")}\n`;
};
/**
* @typedef {Pick<Command, "formattedCommandWithTitle" | "title" | "cwd">} CommandText
* @param {{
commands: Array<Command>,
attemptedKillAll: boolean,
autoExit: AutoExit,
}} options
* @returns {boolean}
*/
const isDone = ({ commands, attemptedKillAll, autoExit }) =>
// All commands are killed:
(attemptedKillAll &&
commands.every((command) => !("terminal" in command.status))) ||
// --auto-exit and all commands are “exit 0”:
(autoExit.tag === "AutoExit" &&
commands.every(
(command) =>
command.status.tag === "Exit" &&
command.status.exitCode === 0 &&
!command.status.wasKilled
));
/**
* @param {Command} command
* @returns {string}
*/
const getPid = (command) =>
"terminal" in command.status
? ` ${dim(`(pid ${command.status.terminal.pid})`)}`
: "";
/**
* @typedef {Pick<Command, "formattedCommandWithTitle" | "title" | "titlePossiblyWithGraphicRenditions" | "cwd" | "history">} CommandText
*/
/**
* @param {CommandText} command

@@ -429,3 +553,3 @@ * @returns {string}

const cwdText = (command) =>
path.resolve(command.cwd) === process.cwd() || command.cwd === command.title
path.resolve(command.cwd) === process.cwd()
? ""

@@ -435,11 +559,48 @@ : `${folder}${EMOJI_WIDTH_FIX} ${dim(command.cwd)}\n`;

/**
* @param {string} indicator
* @param {CommandText} command
* @returns {string}
*/
const historyStart = (command) =>
`${runningIndicator}${EMOJI_WIDTH_FIX} ${
command.formattedCommandWithTitle
}${RESET_COLOR}\n${cwdText(command)}`;
const historyStart = (indicator, command) =>
`${commandTitleWithIndicator(indicator, command)}\n${cwdText(command)}`;
/**
* Used in interactive mode.
*
* @param {string} indicator
* @param {CommandText} command
* @returns {string}
*/
const commandTitleWithIndicator = (indicator, command) =>
`${indicator}${EMOJI_WIDTH_FIX} ${command.formattedCommandWithTitle}${RESET_COLOR}`;
/**
* Similar to `commandTitleWithIndicator`. Used in non-interactive mode. This
* does not print the full command, only the title. In interactive mode, the
* dashboard only prints the title too – if you want the full thing, you need to
* enter that command, because the command can be very long. In non-interactive
* mode, it can be very spammy if a long command is printed once when started,
* once when exited and once in the summary – interesting output (such as
* errors) gets lost in a sea of command stuff.
*
* @param {string} indicator
* @param {CommandText} command
* @returns {string}
*/
const commandTitleOnlyWithIndicator = (indicator, command) =>
`${indicator}${EMOJI_WIDTH_FIX} ${command.titlePossiblyWithGraphicRenditions}${RESET_COLOR}`;
/**
* @param {Array<Command>} commands
* @returns {string}
*/
const waitingText = (commands) =>
`
Waiting for other commands to finish before starting.
${shortcut(KEYS.kill)} ${killAllLabel(commands)}
${shortcut(KEYS.dashboard)} dashboard
`.trim();
/**
* @param {number} pid

@@ -467,24 +628,60 @@ * @returns {string}

* @param {CommandText} command
* @param {number} exitCode
* @param {Extract<Status, {tag: "Exit"}>} status
* @param {AutoExit} autoExit
* @returns {string}
*/
const exitText = (commands, command, exitCode) =>
`
${exitIndicator(exitCode)}${EMOJI_WIDTH_FIX} ${
command.formattedCommandWithTitle
}${RESET_COLOR}
${cwdText(command)}exit ${exitCode}
const exitText = (commands, command, status, autoExit) => {
const titleWithIndicator = commandTitleWithIndicator(
status.wasKilled && autoExit.tag === "AutoExit"
? abortedIndicator
: exitIndicator(status.exitCode),
command
);
const restart =
autoExit.tag === "AutoExit" && status.exitCode === 0 && !status.wasKilled
? ""
: `${shortcut(KEYS.enter)} restart\n`;
return `
${titleWithIndicator}
${cwdText(command)}exit ${status.exitCode}
${shortcut(KEYS.restart)} restart
${shortcut(KEYS.kill)} ${killAllLabel(commands)}
${restart}${shortcut(KEYS.kill)} ${killAllLabel(commands)}
${shortcut(KEYS.dashboard)} dashboard
`.trim();
};
/**
* @param {{ command: CommandText, exitCode: number, numExited: number, numTotal: number }} options
* @returns {string}
*/
const exitTextAndHistory = ({ command, exitCode, numExited, numTotal }) => {
const lastLine = removeGraphicRenditions(getLastLine(command.history));
const newline =
// If the last line is empty, no extra newline is needed.
lastLine.trim() === "" ? "" : "\n";
return `
${commandTitleOnlyWithIndicator(exitIndicator(exitCode), command)}
${cwdText(command)}${command.history}${CLEAR_DOWN}${newline}${bold(
`exit ${exitCode}`
)} ${dim(`(${numExited}/${numTotal} exited)`)}
`.trimStart();
};
/**
* @param {Status} status
* @param {string | undefined} statusFromRules
* @param {{ statusFromRules?: string, useSeparateKilledIndicator?: boolean }} options
* @returns {[string, string | undefined]}
*/
const statusText = (status, statusFromRules = runningIndicator) => {
const statusText = (
status,
{
statusFromRules = runningIndicator,
useSeparateKilledIndicator = false,
} = {}
) => {
switch (status.tag) {
case "Waiting":
return [waitingIndicator, undefined];
case "Running":

@@ -497,3 +694,8 @@ return [statusFromRules, undefined];

case "Exit":
return [exitIndicator(status.exitCode), bold(`exit ${status.exitCode}`)];
return [
status.wasKilled && useSeparateKilledIndicator
? abortedIndicator
: exitIndicator(status.exitCode),
bold(`exit ${status.exitCode}`),
];
}

@@ -507,3 +709,3 @@ };

// - C, D: Cursor left/right. Should be safe! Parcel does this.
// - E, F: Cursor up/down, and to the start of the line.
// - E, F: Cursor down/up, and to the start of the line. Moving down should be safe.
// - G: Cursor absolute position within line. Should be safe! Again, Parcel.

@@ -522,6 +724,6 @@ // - H, f: Cursor absolute position, both x and y. Exception: Moving to the

const NOT_SIMPLE_LOG_ESCAPE =
/\x1B\[(?:\d*[AEFLMST]|[su]|(?!(?:[01](?:;[01])?)?[fH]\x1B\[[02]?J)(?:\d+(?:;\d+)?)?[fH])/;
/\x1B\[(?:\d*[AFLMST]|[su]|(?!(?:[01](?:;[01])?)?[fH]\x1B\[[02]?J)(?:\d+(?:;\d+)?)?[fH])/;
// These escapes should be printed when they first occur, but not when
// re-printing history. They result in getting a response on stdin. The
// re-printing history. They result in getting a response on stdin. The
// commands might not be in a state where they expect such stdin at the time we

@@ -536,3 +738,11 @@ // re-print history. For example, Vim asks for the terminal

// effectively wouldn’t start executing until focused. That’s solved by handling
// requests and responses this way.
// requests and responses this way. The pty then writes a cursor move like
// `5;1H`. The line (5) varies depending on how many lines have been printed so
// far. The column seems to always be 1. For some reason, replacing this cursor
// move with two newlines seems to always make the cursor end up where we want,
// in my testing. Note: The `5;1H` stuff seems to only be triggered when using
// `npm run`. `run-pty % npx prettier --check .` does not trigger it, but
// `run-pty % npm run prettier` (with `"prettier": "prettier --check ."` in
// package.json) does. `run-pty % timeout 3` also uses 6n and cursor moves, and
// should not be affected by this workaround.
//

@@ -545,7 +755,24 @@ // https://xfree86.org/current/ctlseqs.html

const ESCAPES_REQUEST =
/(\x1B\[(?:\??6n|\d*(?:;\d*){0,2}t)|\x1b\]1[01];\?\x07)/g;
/(\x1B\[(?:\??6n|\d*(?:;\d*){0,2}t)|\x1B\]1[01];\?\x07)/g;
const ESCAPES_RESPONSE =
/(\x1B\[(?:\??\d+;\d+R|\d*(?:;\d*){0,2}t)|\x1b\]1[01];[^\x07]+\x07)/g;
/(\x1B\[(?:\??\d+;\d+R|\d*(?:;\d*){0,2}t)|\x1B\]1[01];[^\x07]+\x07)/g;
const CURSOR_POSITION_RESPONSE = /(\x1B\[\??)\d+;\d+R/g;
const CONPTY_CURSOR_MOVE = /\x1B\[\d+;1H/;
const CONPTY_CURSOR_MOVE_REPLACEMENT = "\n\n";
/**
* @param {string} request
* @returns {string}
*/
const respondToRequestFake = (request) =>
request.endsWith("6n")
? "\x1B[1;1R"
: request.endsWith("t")
? "\x1B[3;0;0t"
: request.startsWith("\x1B]10;")
? "\x1B]10;rgb:ffff/ffff/ffff\x07"
: request.startsWith("\x1B]11;")
? "\x1B]11;rgb:0000/0000/0000\x07"
: "";
const GRAPHIC_RENDITIONS = /(\x1B\[(?:\d+(?:;\d+)*)?m)/g;

@@ -626,2 +853,4 @@

const AUTO_EXIT_REGEX = /^--auto-exit(?:=(\d+|auto))?$/;
/**

@@ -632,3 +861,3 @@ * @typedef {

| { tag: "Error", message: string }
| { tag: "Parsed", commands: Array<CommandDescription> }
| { tag: "Parsed", commands: Array<CommandDescription>, autoExit: AutoExit }
} ParseResult

@@ -644,2 +873,7 @@ *

}} CommandDescription
*
* @typedef {
| { tag: "NoAutoExit" }
| { tag: "AutoExit", maxParallel: number }
} AutoExit
*/

@@ -652,12 +886,46 @@

const parseArgs = (args) => {
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
if (args.length === 0) {
return { tag: "Help" };
}
if (args.length === 1) {
const [flags, restArgs] = partitionArgs(args);
/** @type {AutoExit} */
let autoExit = { tag: "NoAutoExit" };
for (const flag of flags) {
if (flag === "-h" || flag === "--help") {
return { tag: "Help" };
}
const match = AUTO_EXIT_REGEX.exec(flag);
if (match !== null) {
const maxParallel =
match[1] === undefined || match[1] === "auto"
? os.cpus().length
: Number(match[1]);
if (maxParallel === 0) {
return { tag: "Error", message: "--auto-exit=0 will never finish." };
}
autoExit = {
tag: "AutoExit",
maxParallel,
};
} else {
return {
tag: "Error",
message: [
`Bad flag: ${flag}`,
"Only these forms are accepted:",
autoExitHelp,
].join("\n"),
};
}
}
if (restArgs.length === 1) {
try {
const commands = parseInputFile(fs.readFileSync(args[0], "utf8"));
const commands = parseInputFile(fs.readFileSync(restArgs[0], "utf8"));
return commands.length === 0
? { tag: "NoCommands" }
: { tag: "Parsed", commands };
: { tag: "Parsed", commands, autoExit };
} catch (errorAny) {

@@ -687,3 +955,3 @@ /** @type {Error & {code?: string} | undefined} */

const delimiter = args[0];
const delimiter = restArgs[0];

@@ -693,3 +961,3 @@ let command = [];

for (const arg of args) {
for (const arg of restArgs) {
if (arg === delimiter) {

@@ -723,6 +991,21 @@ if (command.length > 0) {

})),
autoExit,
};
};
const LOOKS_LIKE_FLAG = /^--?\w/;
/**
* @param {Array<string>} args
* @returns {[Array<string>, Array<string>]}
*/
const partitionArgs = (args) => {
let index = 0;
while (index < args.length && LOOKS_LIKE_FLAG.test(args[index])) {
index++;
}
return [args.slice(0, index), args.slice(index)];
};
/**
* @param {string} string

@@ -732,41 +1015,8 @@ * @returns {Array<CommandDescription>}

const parseInputFile = (string) => {
const first = string.trimStart().slice(0, 1);
switch (first) {
case "[": {
try {
return Decode.array(commandDescriptionDecoder)(JSON.parse(string));
} catch (error) {
throw error instanceof Decode.DecoderError
? new Error(error.format())
: error;
}
}
case "": // An empty file is empty NDJSON.
case "{":
return string.split("\n").flatMap((line, lineIndex) => {
const trimmed = line.trim();
if (trimmed === "") {
return [];
}
try {
return commandDescriptionDecoder(JSON.parse(trimmed));
} catch (error) {
throw new Error(
`Line ${lineIndex + 1}: ${
error instanceof Decode.DecoderError
? error.format()
: error instanceof Error
? error.message
: "Unknown parse error"
}`
);
}
});
default:
throw new Error(
`Expected input to start with [ or { but got: ${first || "nothing"}`
);
try {
return Decode.array(commandDescriptionDecoder)(JSON.parse(string));
} catch (error) {
throw error instanceof Decode.DecoderError
? new Error(error.format())
: error;
}

@@ -851,10 +1101,14 @@ };

/**
* @typedef {Command} CommandTypeForTest
*/
class Command {
/**
* @param {{
label: string,
label: string | undefined,
addHistoryStart: boolean,
commandDescription: CommandDescription,
onData: (data: string, statusFromRulesChanged: boolean) => undefined,
onRequest: (data: string) => undefined,
onExit: () => undefined,
onExit: (exitCode: number) => undefined,
}} commandInit

@@ -864,2 +1118,3 @@ */

label,
addHistoryStart,
commandDescription: {

@@ -884,3 +1139,5 @@ title,

this.title = removeGraphicRenditions(title);
this.titleWithGraphicRenditions = title;
this.titlePossiblyWithGraphicRenditions = NO_COLOR
? removeGraphicRenditions(title)
: title;
this.formattedCommandWithTitle =

@@ -891,12 +1148,11 @@ title === formattedCommand

? `${removeGraphicRenditions(title)}: ${formattedCommand}`
: `${bold(`${title}${RESET_COLOR}:`)} ${formattedCommand}`;
: `${bold(title)}: ${formattedCommand}`;
this.onData = onData;
this.onRequest = onRequest;
this.onExit = onExit;
this.history = "";
this.historyAlternateScreen = "";
this.addHistoryStart = addHistoryStart;
this.isSimpleLog = true;
this.isOnAlternateScreen = false;
/** @type {Status} */
this.status = { tag: "Exit", exitCode: 0 };
this.status = { tag: "Waiting" };
/** @type {string | undefined} */

@@ -908,16 +1164,28 @@ this.statusFromRules = extractStatus(defaultStatus);

this.statusRules = statusRules;
this.start();
this.windowsConptyCursorMoveWorkaround = false;
// When adding --auto-exit, I first tried to always set `this.history = ""`
// and add `historyStart()` in `joinHistory`. However, that doesn’t work
// properly because `this.history` can be truncated based on `CLEAR_REGEX`
// and `MAX_HISTORY` – and that should include the `historyStart` bit.
// (We don’t want `historyStart` for --auto-exit.)
/** @type {string} */
this.history = addHistoryStart ? historyStart(waitingIndicator, this) : "";
this.historyAlternateScreen = "";
}
/**
* @param {{ needsToWait: boolean }} options
* @returns {void}
*/
start() {
if (this.status.tag !== "Exit") {
start({ needsToWait }) {
if ("terminal" in this.status) {
throw new Error(
`Cannot start pty with pid ${this.status.terminal.pid} because not exited for: ${this.title}`
`Cannot start command because the command is ${this.status.tag} with pid ${this.status.terminal.pid} for: ${this.title}`
);
}
this.history = historyStart(this);
this.history = this.addHistoryStart
? historyStart(needsToWait ? waitingIndicator : runningIndicator, this)
: "";
this.historyAlternateScreen = "";

@@ -927,3 +1195,10 @@ this.isSimpleLog = true;

this.statusFromRules = extractStatus(this.defaultStatus);
// See the comment for `CONPTY_CURSOR_MOVE`.
this.windowsConptyCursorMoveWorkaround = IS_WINDOWS;
if (needsToWait) {
this.status = { tag: "Waiting" };
return;
}
const [file, args] = IS_WINDOWS

@@ -951,3 +1226,14 @@ ? [

const disposeOnData = terminal.onData((data) => {
for (const [index, part] of data.split(ESCAPES_REQUEST).entries()) {
for (const [index, rawPart] of data.split(ESCAPES_REQUEST).entries()) {
let part = rawPart;
if (
this.windowsConptyCursorMoveWorkaround &&
CONPTY_CURSOR_MOVE.test(rawPart)
) {
part = rawPart.replace(
CONPTY_CURSOR_MOVE,
CONPTY_CURSOR_MOVE_REPLACEMENT
);
this.windowsConptyCursorMoveWorkaround = false;
}
if (index % 2 === 0) {

@@ -965,4 +1251,8 @@ const statusFromRulesChanged = this.pushHistory(part);

disposeOnExit.dispose();
this.status = { tag: "Exit", exitCode };
this.onExit();
this.status = {
tag: "Exit",
exitCode,
wasKilled: this.status.tag === "Killing",
};
this.onExit(exitCode);
});

@@ -1013,4 +1303,7 @@

case "Waiting":
case "Exit":
throw new Error(`Cannot kill already exited pty for: ${this.title}`);
throw new Error(
`Cannot kill ${this.status.tag} pty for: ${this.title}`
);
}

@@ -1102,3 +1395,3 @@ }

? removeGraphicRenditions(status[1])
: IS_WINDOWS
: !SUPPORTS_EMOJI
? status[1]

@@ -1132,5 +1425,6 @@ : status[0];

* @param {Array<CommandDescription>} commandDescriptions
* @param {AutoExit} autoExit
* @returns {void}
*/
const runCommands = (commandDescriptions) => {
const runInteractively = (commandDescriptions, autoExit) => {
/** @type {Current} */

@@ -1182,16 +1476,29 @@ let current = { tag: "Dashboard" };

const helper = (extraText) => {
if (command.isSimpleLog) {
const isBadWindows = IS_WINDOWS && !IS_WINDOWS_TERMINAL;
if (
command.isSimpleLog &&
(!isBadWindows ||
removeGraphicRenditions(getLastLine(command.history)) === "")
) {
const numLines = extraText.split("\n").length;
// `\f` is like `\n` except the cursor column is preserved on the new
// line. We print the `\f`s so that if we’re at the bottom of the
// `\x1BD` (IND) is like `\n` except the cursor column is preserved on
// the new line. We print the INDs so that if we’re at the bottom of the
// terminal window, empty space is created for `extraText`. However, if
// there’s currently a background color, the new lines will be colored.
// We can’t solve that with doing `RESET_COLOR` and `SAVE_CURSOR`
// earlier, because the `\f`s might cause scrolling but `SAVE_CURSOR`
// and `RESTORE_CURSOR` are relative to the screen, not the content. As
// a workaround we let the lines be colored, and later clear that using
// earlier, because the INDs might cause scrolling but `SAVE_CURSOR` and
// `RESTORE_CURSOR` are relative to the screen, not the content. As a
// workaround we let the lines be colored, and later clear that using
// `CLEAR_DOWN`. (There’s no text to clear at that point; only color.)
// Note: On Linux and macOS (at least in the terminals I’ve tested),
// `\f` works the same way as `\x1BD`. However, cmd.exe prints `\f` as
// “♀”, and Windows Terminal treats it as `\n`. Linux, macOS and Windows
// Terminal do support IND. I have not found any way to do this in cmd.exe
// and the old PowerShell app, so there we only print the extra text only if
// we’re at the start of a new line.
// https://github.com/microsoft/terminal/issues/3189
// https://github.com/microsoft/terminal/pull/3271/files#diff-6d7a2ad03ef14def98192607612a235f881368c3828b3b732abdf8f8ecf9b03bR4322
process.stdout.write(
data +
"\f".repeat(numLines) +
(isBadWindows ? "\n" : "\x1BD").repeat(numLines) +
cursorUp(numLines) +

@@ -1224,2 +1531,3 @@ SAVE_CURSOR +

case "Waiting":
case "Exit": {

@@ -1241,4 +1549,7 @@ const lastLine = removeGraphicRenditions(getLastLine(command.history));

disableAlternateScreen +
CLEAR_DOWN +
newlines +
exitText(commands, command, command.status.exitCode)
(command.status.tag === "Waiting"
? waitingText(commands)
: exitText(commands, command, command.status, autoExit))
);

@@ -1264,8 +1575,9 @@

CLEAR +
drawDashboard(
drawDashboard({
commands,
process.stdout.columns,
width: process.stdout.columns,
attemptedKillAll,
selection
)
autoExit,
selection,
})
);

@@ -1315,7 +1627,7 @@ };

const notExited = commands.filter(
(command) => command.status.tag !== "Exit"
(command) => "terminal" in command.status
);
if (notExited.length === 0) {
switchToDashboard();
process.exit(0);
process.exit(autoExit.tag === "AutoExit" ? 1 : 0);
} else {

@@ -1331,13 +1643,57 @@ for (const command of notExited) {

/**
* @param {number} index
* @param {Extract<Status, {tag: "Exit"}>} status
* @returns {void}
*/
const restart = (index, status) => {
const command = commands[index];
if (autoExit.tag === "AutoExit") {
if (!(status.exitCode === 0 && !status.wasKilled)) {
const numRunning = commands.filter(
(command2) => "terminal" in command2.status
).length;
command.start({
needsToWait: numRunning >= autoExit.maxParallel,
});
switchToCommand(index);
}
} else {
command.start({ needsToWait: false });
switchToCommand(index);
}
};
/**
* @returns {void}
*/
const restartExited = () => {
const exited = commands.filter((command) => command.status.tag === "Exit");
if (exited.length > 0) {
for (const command of exited) {
command.start();
if (autoExit.tag === "AutoExit") {
const exited = commands.filter(
(command) =>
command.status.tag === "Exit" &&
(command.status.exitCode !== 0 || command.status.wasKilled)
);
if (exited.length > 0) {
const numRunning = commands.filter(
(command2) => "terminal" in command2.status
).length;
for (const [index, command] of exited.entries()) {
command.start({
needsToWait: numRunning + index >= autoExit.maxParallel,
});
}
}
// Redraw dashboard.
switchToDashboard();
} else {
const exited = commands.filter(
(command) => command.status.tag === "Exit"
);
if (exited.length > 0) {
for (const command of exited) {
command.start({ needsToWait: false });
}
}
}
// Redraw dashboard.
switchToDashboard();
};

@@ -1365,3 +1721,4 @@

new Command({
label: ALL_LABELS[index] || "",
label: ALL_LABELS[index],
addHistoryStart: true,
commandDescription,

@@ -1378,5 +1735,6 @@ onData: (data, statusFromRulesChanged) => {

return undefined;
case "Waiting":
case "Exit":
throw new Error(
`Received unexpected output from already exited pty for: ${command.title}\n${data}`
`Received unexpected output from ${command.status.tag} pty for: ${command.title}\n${data}`
);

@@ -1401,11 +1759,24 @@ }

onExit: () => {
// Exit the whole program if all commands are killed.
if (
attemptedKillAll &&
commands.every((command2) => command2.status.tag === "Exit")
) {
// Exit the whole program if all commands have exited.
if (isDone({ commands, attemptedKillAll, autoExit })) {
switchToDashboard();
process.exit(0);
process.exit(
autoExit.tag === "AutoExit" && attemptedKillAll ? 1 : 0
);
}
const nextWaitingIndex = commands.findIndex(
(command) => command.status.tag === "Waiting"
);
if (nextWaitingIndex !== -1 && !attemptedKillAll) {
commands[nextWaitingIndex].start({ needsToWait: false });
// If starting the command we’re currently on, redraw to remove `waitingText`.
if (
current.tag === "Command" &&
current.index === nextWaitingIndex
) {
switchToCommand(current.index);
}
}
switch (current.tag) {

@@ -1432,3 +1803,3 @@ case "Command":

for (const command of commands) {
if (command.status.tag === "Running") {
if ("terminal" in command.status) {
command.status.terminal.resize(

@@ -1447,2 +1818,4 @@ process.stdout.columns,

setupSignalHandlers(commands, killAll);
process.stdin.setRawMode(true);

@@ -1480,2 +1853,3 @@

break;
case "Waiting":
case "Exit":

@@ -1497,2 +1871,3 @@ break;

killAll,
restart,
restartExited

@@ -1504,24 +1879,2 @@ );

// Clean up all commands if someone tries to kill run-pty.
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.on(signal, killAll);
}
// Don’t leave running processes behind in case of an unexpected error.
for (const event of ["uncaughtException", "unhandledRejection"]) {
process.on(event, (error) => {
console.error(error);
for (const command of commands) {
if (command.status.tag !== "Exit") {
if (IS_WINDOWS) {
command.status.terminal.kill();
} else {
command.status.terminal.kill("SIGKILL");
}
}
}
process.exit(1);
});
}
process.on("exit", () => {

@@ -1533,2 +1886,8 @@ process.stdout.write(

const maxParallel =
autoExit.tag === "AutoExit" ? autoExit.maxParallel : Infinity;
for (const [index, command] of commands.entries()) {
command.start({ needsToWait: index >= maxParallel });
}
if (commandDescriptions.length === 1) {

@@ -1550,2 +1909,3 @@ switchToCommand(0);

* @param {() => void} killAll
* @param {(index: number, status: Extract<Status, {tag: "Exit"}>) => void} restart
* @param {() => void} restartExited

@@ -1563,2 +1923,3 @@ * @returns {undefined}

killAll,
restart,
restartExited

@@ -1570,2 +1931,16 @@ ) => {

switch (command.status.tag) {
case "Waiting":
switch (data) {
case KEY_CODES.kill:
killAll();
return undefined;
case KEY_CODES.dashboard:
switchToDashboard();
return undefined;
default:
return undefined;
}
case "Running":

@@ -1602,4 +1977,3 @@ case "Killing":

case KEY_CODES.restart:
command.start();
switchToCommand(current.index);
restart(current.index, command.status);
return undefined;

@@ -1620,3 +1994,2 @@

case KEY_CODES.enter:
case KEY_CODES.enterVim:
if (selection.tag === "Invisible") {

@@ -1630,3 +2003,2 @@ restartExited();

case KEY_CODES.up:
case KEY_CODES.upVim:
setSelection({

@@ -1644,3 +2016,2 @@ tag: "Keyboard",

case KEY_CODES.down:
case KEY_CODES.downVim:
setSelection({

@@ -1725,6 +2096,13 @@ tag: "Keyboard",

const getCommandIndexFromMousePosition = (commands, { x, y }) => {
const lines = drawDashboardCommandLines(commands, process.stdout.columns, {
tag: "Invisible",
index: 0,
});
const lines = drawDashboardCommandLines(
commands,
{
tag: "Invisible",
index: 0,
},
{
width: process.stdout.columns,
useSeparateKilledIndicator: false,
}
);

@@ -1742,10 +2120,172 @@ if (y >= 0 && y < lines.length) {

/**
* @param {Array<CommandDescription>} commandDescriptions
* @param {number} maxParallel
* @returns {void}
*/
const runNonInteractively = (commandDescriptions, maxParallel) => {
let attemptedKillAll = false;
/**
* @returns {void}
*/
const killAll = () => {
attemptedKillAll = true;
const notExited = commands.filter(
(command) => "terminal" in command.status
);
// Pressing ctrl+c prints `^C` to the terminal. Move the cursor back so we
// overwrite that. We also need to clear since ⭕️ is see-through. `^C` will
// be seen in each command history.
process.stdout.write(`\r${CLEAR_RIGHT}`);
if (notExited.length === 0) {
process.stdout.write(drawSummary(commands));
process.exit(1);
} else {
for (const command of notExited) {
command.kill();
process.stdout.write(
`${commandTitleOnlyWithIndicator(killingIndicator, command)}\n\n`
);
}
}
};
/** @type {Array<Command>} */
const commands = commandDescriptions.map((commandDescription, index) => {
const thisCommand = new Command({
label: ALL_LABELS[index],
addHistoryStart: false,
commandDescription,
onData: () => undefined,
// `process.stdin.setRawMode(true)` is required to make real requests to
// the terminal, but that is not possible when `process.stdin.isTTY === false`.
// The best we can do is respond immediately with a fake response so
// programs don’t get stuck. This is important on Windows – see the
// comment for `ESCAPES_REQUEST`.
onRequest: (data) => {
if ("terminal" in thisCommand.status) {
thisCommand.status.terminal.write(respondToRequestFake(data));
}
return undefined;
},
onExit: (exitCode) => {
const numRunning = commands.filter(
(command) => "terminal" in command.status
).length;
const numExit = commands.filter(
(command) => command.status.tag === "Exit"
).length;
const numExit0 = commands.filter(
(command) =>
command.status.tag === "Exit" &&
command.status.exitCode === 0 &&
!command.status.wasKilled
).length;
process.stdout.write(
exitTextAndHistory({
command: thisCommand,
exitCode,
numExited: numExit,
numTotal: commands.length,
})
);
// Exit the whole program if all commands have exited.
if (
(attemptedKillAll && numRunning === 0) ||
numExit === commands.length
) {
process.stdout.write(drawSummary(commands));
process.exit(attemptedKillAll || numExit0 !== numExit ? 1 : 0);
}
const nextWaitingIndex = commands.findIndex(
(command) => command.status.tag === "Waiting"
);
if (nextWaitingIndex !== -1 && !attemptedKillAll) {
const command = commands[nextWaitingIndex];
command.start({ needsToWait: false });
process.stdout.write(
`${commandTitleOnlyWithIndicator(runningIndicator, command)}\n\n`
);
}
return undefined;
},
});
return thisCommand;
});
process.stdout.on("resize", () => {
for (const command of commands) {
if ("terminal" in command.status) {
command.status.terminal.resize(
process.stdout.columns,
process.stdout.rows
);
}
}
});
setupSignalHandlers(commands, killAll);
for (const [index, command] of commands.entries()) {
const needsToWait = index >= maxParallel;
command.start({ needsToWait });
process.stdout.write(
`${commandTitleOnlyWithIndicator(
needsToWait ? waitingIndicator : runningIndicator,
command
)}\n\n`
);
}
};
/**
* @param {Array<Command>} commands
* @param {() => void} killAll
* @returns {void}
*/
const setupSignalHandlers = (commands, killAll) => {
let lastSignalTimestamp = 0;
// Clean up all commands if someone tries to kill run-pty.
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.on(signal, () => {
const now = Date.now();
// When running via `npm run` or `npx`, one often gets two SIGINTs in a row
// when pressing ctrl+c. https://stackoverflow.com/a/60273973
if (now - lastSignalTimestamp > 10) {
killAll();
}
lastSignalTimestamp = now;
});
}
// Don’t leave running processes behind in case of an unexpected error.
for (const event of ["uncaughtException", "unhandledRejection"]) {
process.on(event, (error) => {
console.error(error);
for (const command of commands) {
if ("terminal" in command.status) {
if (IS_WINDOWS) {
command.status.terminal.kill();
} else {
command.status.terminal.kill("SIGKILL");
}
}
}
process.exit(1);
});
}
};
/**
* @returns {undefined}
*/
const run = () => {
if (!process.stdin.isTTY) {
console.error("run-pty requires stdin to be a TTY to run properly.");
process.exit(1);
}
const parseResult = parseArgs(process.argv.slice(2));

@@ -1762,3 +2302,15 @@

case "Parsed":
runCommands(parseResult.commands);
if (process.stdin.isTTY) {
runInteractively(parseResult.commands, parseResult.autoExit);
} else if (parseResult.autoExit.tag === "AutoExit") {
runNonInteractively(
parseResult.commands,
parseResult.autoExit.maxParallel
);
} else {
console.error(
"run-pty requires stdin to be a TTY to run properly (unless --auto-exit is used)."
);
process.exit(1);
}
return undefined;

@@ -1782,3 +2334,5 @@

drawDashboard,
drawSummary,
exitText,
exitTextAndHistory,
help,

@@ -1788,5 +2342,7 @@ historyStart,

parseArgs,
runningIndicator,
runningText,
summarizeLabels,
waitingText,
},
};
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc