printable-shell-command 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Makefile +7 -0
- package/README.md +78 -0
- package/biome.json +12 -0
- package/bun.lockb +0 -0
- package/package.json +17 -0
- package/src/index.ts +160 -0
- package/test/My video (slow-mo).mov +0 -0
- package/test/simple-test-bun.ts +12 -0
- package/test/simple-test-node.ts +14 -0
- package/tsconfig.json +8 -0
package/Makefile
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# `printable-shell-command`
|
|
2
|
+
|
|
3
|
+
A helper class to construct shell command in a way that allows printing them.
|
|
4
|
+
|
|
5
|
+
The goal is to make it easy to print commands that are bring run by a program, in a way that makes it easy and safe for a user to copy-and-paste.
|
|
6
|
+
|
|
7
|
+
Goals:
|
|
8
|
+
|
|
9
|
+
1. Security — the printed commands should be possible to use in all shells without injection vulnerabilities.
|
|
10
|
+
2. Fidelity — The printed command must match the arguments provided.
|
|
11
|
+
3.
|
|
12
|
+
|
|
13
|
+
Point 1 is difficult, and maybe even impossible. This library will do its best, but what you don't know can hurt you.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Construct a command by providing a command string and a list of arguments. Each argument can either be an individual string, or a "pair" list containing two strings (usually a command flag and its argument). Pairs do not affect the semantics of the command, but they affect pretty-printing.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { PrintableShellCommand } from "printable-shell-command";
|
|
21
|
+
|
|
22
|
+
const command = new PrintableShellCommand("ffmpeg", [
|
|
23
|
+
["-i", "./test/My video.mp4"],
|
|
24
|
+
["-filter:v", "setpts=2.0*PTS"],
|
|
25
|
+
["-filter:a", "atempo=0.5"],
|
|
26
|
+
"./test/My video (slow-mo).mov",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
command.print();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### In `node`
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { spawn } from "node:child_process";
|
|
36
|
+
|
|
37
|
+
// Note the `...`
|
|
38
|
+
const child_process = spawn(...command.toCommandWithFlatArgs());
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### With `bun`
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { spawn } from "bun";
|
|
45
|
+
|
|
46
|
+
await spawn({ cmd: command.toFlatCommand() }).exited;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Protections
|
|
50
|
+
|
|
51
|
+
Any command or argument containing the following characters is quoted and escaped:
|
|
52
|
+
|
|
53
|
+
- space character
|
|
54
|
+
- `"`
|
|
55
|
+
- `'`
|
|
56
|
+
- `\``
|
|
57
|
+
- `|`
|
|
58
|
+
- `$`
|
|
59
|
+
- `*`
|
|
60
|
+
- `?`
|
|
61
|
+
- `>`
|
|
62
|
+
- `<`
|
|
63
|
+
- `(`
|
|
64
|
+
- `)`
|
|
65
|
+
- `[`
|
|
66
|
+
- `]`
|
|
67
|
+
- `{`
|
|
68
|
+
- `}`
|
|
69
|
+
- `&`
|
|
70
|
+
- `\`
|
|
71
|
+
- `;`
|
|
72
|
+
|
|
73
|
+
Additionally, a command is escaped if it contains an `=`.
|
|
74
|
+
|
|
75
|
+
Escaping is done as follows:
|
|
76
|
+
|
|
77
|
+
- The command is single-quoted.
|
|
78
|
+
- Backslashes and single quotes are escaped.
|
package/biome.json
ADDED
package/bun.lockb
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "printable-shell-command",
|
|
3
|
+
"version": "v0.1.0",
|
|
4
|
+
"main": "./src/index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@biomejs/biome": "^1.9.4",
|
|
14
|
+
"@types/bun": "^1.1.14",
|
|
15
|
+
"@types/node": "^22.10.2"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const INLINE_SEPARATOR = " ";
|
|
2
|
+
const LINE_WRAP_SEPARATOR = " \\\n ";
|
|
3
|
+
|
|
4
|
+
// biome-ignore lint/suspicious/noExplicitAny: This is the correct type nere.
|
|
5
|
+
function isString(s: any): s is string {
|
|
6
|
+
return typeof s === "string";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// TODO: allow `.toString()`ables?
|
|
10
|
+
type SingleArgument = string;
|
|
11
|
+
type FlagArgumentPair = [string, string];
|
|
12
|
+
type ArgsEntry = SingleArgument | FlagArgumentPair;
|
|
13
|
+
type Args = ArgsEntry[];
|
|
14
|
+
|
|
15
|
+
export interface PrintOptions {
|
|
16
|
+
mainIndentation?: string; // Defaults to ""
|
|
17
|
+
argIndentation?: string; // Defaults to " "
|
|
18
|
+
// - "auto": Quote only arguments that need it for safety. This tries to be
|
|
19
|
+
// portable and safe across shells, but true safety and portability is hard
|
|
20
|
+
// to guarantee.
|
|
21
|
+
// - "extra-safe": Quote all arguments, even ones that don't need it. This is
|
|
22
|
+
// more likely to be safe under all circumstances.
|
|
23
|
+
quoting?: "auto" | "extra-safe";
|
|
24
|
+
lineWrap?: "none" | "by-entry";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// https://mywiki.wooledge.org/BashGuide/SpecialCharacters
|
|
28
|
+
const SPECIAL_SHELL_CHARACTERS = new Set([
|
|
29
|
+
" ",
|
|
30
|
+
'"',
|
|
31
|
+
"'",
|
|
32
|
+
"`",
|
|
33
|
+
"|",
|
|
34
|
+
"$",
|
|
35
|
+
"*",
|
|
36
|
+
"?",
|
|
37
|
+
">",
|
|
38
|
+
"<",
|
|
39
|
+
"(",
|
|
40
|
+
")",
|
|
41
|
+
"[",
|
|
42
|
+
"]",
|
|
43
|
+
"{",
|
|
44
|
+
"}",
|
|
45
|
+
"&",
|
|
46
|
+
"\\",
|
|
47
|
+
";",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// https://mywiki.wooledge.org/BashGuide/SpecialCharacters
|
|
51
|
+
const SPECIAL_SHELL_CHARACTERS_FOR_MAIN_COMMAND =
|
|
52
|
+
SPECIAL_SHELL_CHARACTERS.union(new Set(["="]));
|
|
53
|
+
|
|
54
|
+
export class PrintableShellCommand {
|
|
55
|
+
constructor(
|
|
56
|
+
private commandName: string,
|
|
57
|
+
private args: Args = [],
|
|
58
|
+
) {
|
|
59
|
+
if (!isString(commandName)) {
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: We want to print this, no matter what it is.
|
|
61
|
+
throw new Error("Command name is not a string:", commandName as any);
|
|
62
|
+
}
|
|
63
|
+
if (typeof args === "undefined") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(args)) {
|
|
67
|
+
throw new Error("Command arguments are not an array");
|
|
68
|
+
}
|
|
69
|
+
for (let i = 0; i < args.length; i++) {
|
|
70
|
+
const argEntry = args[i];
|
|
71
|
+
if (typeof argEntry === "string") {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
Array.isArray(argEntry) &&
|
|
76
|
+
argEntry.length === 2 &&
|
|
77
|
+
isString(argEntry[0]) &&
|
|
78
|
+
isString(argEntry[1])
|
|
79
|
+
) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Invalid arg entry at index: ${i}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public toFlatCommand(): string[] {
|
|
87
|
+
return [this.commandName, ...this.args.flat()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// For use with `node:child_process`
|
|
91
|
+
public toCommandWithFlatArgs(): [string, string[]] {
|
|
92
|
+
return [this.commandName, this.args.flat()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#escapeArg(
|
|
96
|
+
arg: string,
|
|
97
|
+
isMainCommand: boolean,
|
|
98
|
+
options: PrintOptions,
|
|
99
|
+
): string {
|
|
100
|
+
const argCharacters = new Set(arg);
|
|
101
|
+
const specialShellCharacters = isMainCommand
|
|
102
|
+
? SPECIAL_SHELL_CHARACTERS_FOR_MAIN_COMMAND
|
|
103
|
+
: SPECIAL_SHELL_CHARACTERS;
|
|
104
|
+
if (
|
|
105
|
+
options?.quoting === "extra-safe" ||
|
|
106
|
+
argCharacters.intersection(specialShellCharacters).size > 0
|
|
107
|
+
) {
|
|
108
|
+
// Use single quote to reduce the need to escape (and therefore reduce the chance for bugs/security issues).
|
|
109
|
+
const escaped = arg.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
|
110
|
+
return `'${escaped}'`;
|
|
111
|
+
}
|
|
112
|
+
return arg;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#smallIndent(s: string, options?: PrintOptions): string {
|
|
116
|
+
return (options?.mainIndentation ?? "") + s;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#bigIndent(s: string, options?: PrintOptions): string {
|
|
120
|
+
return this.#smallIndent((options?.argIndentation ?? " ") + s, options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public getPrintableCommand(options?: PrintOptions): string {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
|
|
126
|
+
lines.push(
|
|
127
|
+
this.#smallIndent(
|
|
128
|
+
this.#escapeArg(this.commandName, true, options),
|
|
129
|
+
options,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// let pendingNewlineAfterPart = options?.separateLines === "dash-heuristic";
|
|
134
|
+
for (let i = 0; i < this.args.length; i++) {
|
|
135
|
+
const argsEntry = this.args[i];
|
|
136
|
+
|
|
137
|
+
if (isString(argsEntry)) {
|
|
138
|
+
lines.push(
|
|
139
|
+
this.#bigIndent(this.#escapeArg(argsEntry, false, options), options),
|
|
140
|
+
);
|
|
141
|
+
} else {
|
|
142
|
+
const [part1, part2] = argsEntry;
|
|
143
|
+
|
|
144
|
+
lines.push(
|
|
145
|
+
this.#bigIndent(
|
|
146
|
+
this.#escapeArg(part1, false, options) +
|
|
147
|
+
INLINE_SEPARATOR +
|
|
148
|
+
this.#escapeArg(part2, false, options),
|
|
149
|
+
options,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return lines.join(LINE_WRAP_SEPARATOR);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public print(options?: PrintOptions): void {
|
|
158
|
+
console.log(this.getPrintableCommand(options));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { PrintableShellCommand } from "../src";
|
|
3
|
+
|
|
4
|
+
const command = new PrintableShellCommand("ffmpeg", [
|
|
5
|
+
["-i", "./test/My video.mp4"],
|
|
6
|
+
["-filter:v", "setpts=2.0*PTS"],
|
|
7
|
+
["-filter:a", "atempo=0.5"],
|
|
8
|
+
"./test/My video (slow-mo).mov",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
command.print();
|
|
12
|
+
await spawn({ cmd: command.toFlatCommand() }).exited;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { PrintableShellCommand } from "../src";
|
|
3
|
+
|
|
4
|
+
const command = new PrintableShellCommand("ffmpeg", [
|
|
5
|
+
["-i", "./test/My video.mp4"],
|
|
6
|
+
["-filter:v", "setpts=2.0*PTS"],
|
|
7
|
+
["-filter:a", "atempo=0.5"],
|
|
8
|
+
"./test/My video (slow-mo).mov",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
command.print();
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
spawn(...command.toCommandWithFlatArgs()).addListener("exit", resolve);
|
|
14
|
+
});
|