printable-shell-command 0.1.3 → 0.2.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.
@@ -0,0 +1,14 @@
1
+ name: CI
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: oven-sh/setup-bun@v1
12
+ - run: make setup
13
+ - run: make lint
14
+ - run: make test-js
package/Makefile CHANGED
@@ -1,3 +1,6 @@
1
+ .PHONY: test
2
+ test: lint test-js
3
+
1
4
  .PHONY: lint
2
5
  lint: setup
3
6
  bun x @biomejs/biome check
@@ -6,6 +9,10 @@ lint: setup
6
9
  format: setup
7
10
  bun x @biomejs/biome check --write
8
11
 
12
+ .PHONY: test-js
13
+ test-js: setup
14
+ bun test
15
+
9
16
  # https://github.com/lgarron/repo
10
17
  REPO_COMMANDS = publish
11
18
 
@@ -18,3 +25,11 @@ ${REPO_COMMANDS}:
18
25
  .PHONY: setup
19
26
  setup:
20
27
  bun install --frozen-lockfile
28
+
29
+ .PHONY: clean
30
+ clean:
31
+ # No-op
32
+
33
+ .PHONY: reset
34
+ reset: clean
35
+ rm -rf ./node_modules
package/README.md CHANGED
@@ -34,10 +34,10 @@ This prints:
34
34
 
35
35
  ```shell
36
36
  ffmpeg \
37
- -i './test/My video.mp4' \
38
- -filter:v 'setpts=2.0*PTS' \
39
- -filter:a atempo=0.5 \
40
- './test/My video (slow-mo).mov'
37
+ -i './test/My video.mp4' \
38
+ -filter:v 'setpts=2.0*PTS' \
39
+ -filter:a atempo=0.5 \
40
+ './test/My video (slow-mo).mov'
41
41
  ```
42
42
 
43
43
  ### Spawn a process in `node`
package/bun.lock CHANGED
@@ -3,10 +3,12 @@
3
3
  "workspaces": {
4
4
  "": {
5
5
  "name": "printable-shell-command",
6
+ "dependencies": {
7
+ "@types/bun": "^1.2.11",
8
+ "@types/node": "^22.15.3",
9
+ },
6
10
  "devDependencies": {
7
11
  "@biomejs/biome": "^1.9.4",
8
- "@types/bun": "^1.1.14",
9
- "@types/node": "^22.10.2",
10
12
  },
11
13
  },
12
14
  },
@@ -29,14 +31,16 @@
29
31
 
30
32
  "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
31
33
 
32
- "@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
34
+ "@types/bun": ["@types/bun@1.2.11", "", { "dependencies": { "bun-types": "1.2.11" } }, "sha512-ZLbbI91EmmGwlWTRWuV6J19IUiUC5YQ3TCEuSHI3usIP75kuoA8/0PVF+LTrbEnVc8JIhpElWOxv1ocI1fJBbw=="],
35
+
36
+ "@types/node": ["@types/node@22.15.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw=="],
33
37
 
34
- "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
38
+ "bun-types": ["bun-types@1.2.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-dbkp5Lo8HDrXkLrONm6bk+yiiYQSntvFUzQp0v3pzTAsXk6FtgVMjdQ+lzFNVAmQFUkPQZ3WMZqH5tTo+Dp/IA=="],
35
39
 
36
- "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
40
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
37
41
 
38
- "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
42
+ "bun-types/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
39
43
 
40
- "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
44
+ "bun-types/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
41
45
  }
42
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "printable-shell-command",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "main": "./src/index.ts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,9 +9,11 @@
9
9
  "import": "./src/index.ts"
10
10
  }
11
11
  },
12
+ "dependencies": {
13
+ "@types/bun": "^1.2.11",
14
+ "@types/node": "^22.15.3"
15
+ },
12
16
  "devDependencies": {
13
- "@biomejs/biome": "^1.9.4",
14
- "@types/bun": "^1.1.14",
15
- "@types/node": "^22.10.2"
17
+ "@biomejs/biome": "^1.9.4"
16
18
  }
17
19
  }
package/src/index.ts CHANGED
@@ -1,5 +1,22 @@
1
+ import type {
2
+ ChildProcess as NodeChildProcess,
3
+ SpawnOptionsWithStdioTuple as NodeSpawnOptionsWithStdioTuple,
4
+ StdioNull as NodeStdioNull,
5
+ StdioPipe as NodeStdioPipe,
6
+ SpawnOptions as NodeSpawnOptions,
7
+ SpawnOptionsWithoutStdio as NodeSpawnOptionsWithoutStdio,
8
+ } from "node:child_process";
9
+ import type {
10
+ SpawnOptions as BunSpawnOptions,
11
+ Subprocess as BunSubprocess,
12
+ } from "bun";
13
+
14
+ const DEFAULT_MAIN_INDENTATION = "";
15
+ const DEFAULT_ARG_INDENTATION = " ";
16
+ const DEFAULT_ARGUMENT_LINE_WRAPPING = "by-entry";
17
+
1
18
  const INLINE_SEPARATOR = " ";
2
- const LINE_WRAP_SEPARATOR = " \\\n ";
19
+ const LINE_WRAP_LINE_END = " \\\n";
3
20
 
4
21
  // biome-ignore lint/suspicious/noExplicitAny: This is the correct type nere.
5
22
  function isString(s: any): s is string {
@@ -21,7 +38,12 @@ export interface PrintOptions {
21
38
  // - "extra-safe": Quote all arguments, even ones that don't need it. This is
22
39
  // more likely to be safe under all circumstances.
23
40
  quoting?: "auto" | "extra-safe";
24
- lineWrap?: "none" | "by-entry";
41
+ // Line wrapping to use between arguments. Defaults to `"by-entry"`.
42
+ argumentLineWrapping?:
43
+ | "by-entry"
44
+ | "nested-by-entry"
45
+ | "by-argument"
46
+ | "inline";
25
47
  }
26
48
 
27
49
  // https://mywiki.wooledge.org/BashGuide/SpecialCharacters
@@ -150,7 +172,7 @@ export class PrintableShellCommand {
150
172
  #escapeArg(
151
173
  arg: string,
152
174
  isMainCommand: boolean,
153
- options?: PrintOptions,
175
+ options: PrintOptions,
154
176
  ): string {
155
177
  const argCharacters = new Set(arg);
156
178
  const specialShellCharacters = isMainCommand
@@ -169,49 +191,134 @@ export class PrintableShellCommand {
169
191
  return arg;
170
192
  }
171
193
 
172
- #smallIndent(s: string, options?: PrintOptions): string {
173
- return (options?.mainIndentation ?? "") + s;
194
+ #mainIndentation(options: PrintOptions): string {
195
+ return options?.mainIndentation ?? DEFAULT_MAIN_INDENTATION;
196
+ }
197
+
198
+ #argIndentation(options: PrintOptions): string {
199
+ return (
200
+ this.#mainIndentation(options) +
201
+ (options?.argIndentation ?? DEFAULT_ARG_INDENTATION)
202
+ );
203
+ }
204
+
205
+ #lineWrapSeparator(options: PrintOptions): string {
206
+ return LINE_WRAP_LINE_END + this.#argIndentation(options);
174
207
  }
175
208
 
176
- #bigIndent(s: string, options?: PrintOptions): string {
177
- return this.#smallIndent((options?.argIndentation ?? " ") + s, options);
209
+ #argPairSeparator(options: PrintOptions): string {
210
+ switch (options?.argumentLineWrapping ?? DEFAULT_ARGUMENT_LINE_WRAPPING) {
211
+ case "by-entry": {
212
+ return INLINE_SEPARATOR;
213
+ }
214
+ case "nested-by-entry": {
215
+ return this.#lineWrapSeparator(options) + this.#argIndentation(options);
216
+ }
217
+ case "by-argument": {
218
+ return this.#lineWrapSeparator(options);
219
+ }
220
+ case "inline": {
221
+ return INLINE_SEPARATOR;
222
+ }
223
+ default:
224
+ throw new Error("Invalid argument line wrapping argument.");
225
+ }
226
+ }
227
+
228
+ #entrySeparator(options: PrintOptions): string {
229
+ switch (options?.argumentLineWrapping ?? DEFAULT_ARGUMENT_LINE_WRAPPING) {
230
+ case "by-entry": {
231
+ return LINE_WRAP_LINE_END + this.#argIndentation(options);
232
+ }
233
+ case "nested-by-entry": {
234
+ return LINE_WRAP_LINE_END + this.#argIndentation(options);
235
+ }
236
+ case "by-argument": {
237
+ return LINE_WRAP_LINE_END + this.#argIndentation(options);
238
+ }
239
+ case "inline": {
240
+ return INLINE_SEPARATOR;
241
+ }
242
+ default:
243
+ throw new Error("Invalid argument line wrapping argument.");
244
+ }
178
245
  }
179
246
 
180
247
  public getPrintableCommand(options?: PrintOptions): string {
181
- const lines: string[] = [];
248
+ // TODO: Why in the world does TypeScript not give the `options` arg the type of `PrintOptions | undefined`???
249
+ // biome-ignore lint/style/noParameterAssign: We want a default assignment without affecting the signature.
250
+ options ??= {};
251
+ const serializedEntries: string[] = [];
182
252
 
183
- lines.push(
184
- this.#smallIndent(
253
+ serializedEntries.push(
254
+ this.#mainIndentation(options) +
185
255
  this.#escapeArg(this.commandName, true, options),
186
- options,
187
- ),
188
256
  );
189
257
 
190
- // let pendingNewlineAfterPart = options?.separateLines === "dash-heuristic";
191
258
  for (let i = 0; i < this.args.length; i++) {
192
259
  const argsEntry = this.args[i];
193
260
 
194
261
  if (isString(argsEntry)) {
195
- lines.push(
196
- this.#bigIndent(this.#escapeArg(argsEntry, false, options), options),
197
- );
262
+ serializedEntries.push(this.#escapeArg(argsEntry, false, options));
198
263
  } else {
199
264
  const [part1, part2] = argsEntry;
200
-
201
- lines.push(
202
- this.#bigIndent(
203
- this.#escapeArg(part1, false, options) +
204
- INLINE_SEPARATOR +
205
- this.#escapeArg(part2, false, options),
206
- options,
207
- ),
265
+ serializedEntries.push(
266
+ this.#escapeArg(part1, false, options) +
267
+ this.#argPairSeparator(options) +
268
+ this.#escapeArg(part2, false, options),
208
269
  );
209
270
  }
210
271
  }
211
- return lines.join(LINE_WRAP_SEPARATOR);
272
+
273
+ return serializedEntries.join(this.#entrySeparator(options));
212
274
  }
213
275
 
214
276
  public print(options?: PrintOptions): void {
215
277
  console.log(this.getPrintableCommand(options));
216
278
  }
279
+
280
+ public spawnNode<
281
+ Stdin extends NodeStdioNull | NodeStdioPipe,
282
+ Stdout extends NodeStdioNull | NodeStdioPipe,
283
+ Stderr extends NodeStdioNull | NodeStdioPipe,
284
+ >(
285
+ options: NodeSpawnOptions
286
+ | NodeSpawnOptionsWithoutStdio | NodeSpawnOptionsWithStdioTuple<Stdin, Stdout, Stderr>,
287
+ ): // TODO: figure out how to return `ChildProcessByStdio<…>` without duplicating fragile boilerplate.
288
+ NodeChildProcess {
289
+ const { spawn } = process.getBuiltinModule("node:child_process");
290
+ return spawn(...this.forNode(), options);
291
+ }
292
+
293
+ // The returned subprocess includes a `.success` `Promise` field, per https://github.com/oven-sh/bun/issues/8313
294
+ public spawnBun<
295
+ const In extends BunSpawnOptions.Writable = "ignore",
296
+ const Out extends BunSpawnOptions.Readable = "pipe",
297
+ const Err extends BunSpawnOptions.Readable = "inherit",
298
+ >(
299
+ options?: Omit<BunSpawnOptions.OptionsObject<In, Out, Err>, "cmd">,
300
+ ): BunSubprocess<In, Out, Err> & { success: Promise<void> } {
301
+ const { spawn } = process.getBuiltinModule("bun") as typeof import("bun");
302
+ const subprocess = spawn({
303
+ ...options,
304
+ cmd: this.forBun(),
305
+ }) as BunSubprocess<In, Out, Err> & { success: Promise<void> };
306
+ Object.defineProperty(subprocess, "success", {
307
+ get() {
308
+ return new Promise<void>((resolve, reject) =>
309
+ this.exited
310
+ .then((exitCode: number) => {
311
+ if (exitCode === 0) {
312
+ resolve();
313
+ } else {
314
+ reject("Command failed.");
315
+ }
316
+ })
317
+ .catch(reject),
318
+ );
319
+ },
320
+ enumerable: false,
321
+ });
322
+ return subprocess;
323
+ }
217
324
  }
@@ -0,0 +1,152 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PrintableShellCommand } from "../src";
3
+
4
+ const rsyncCommand = new PrintableShellCommand("rsync", [
5
+ "-avz",
6
+ ["--exclude", ".DS_Store"],
7
+ ["--exclude", ".git"],
8
+ "./dist/web/experiments.cubing.net/test/deploy/",
9
+ "experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
10
+ ]);
11
+
12
+ test("args for commands", () => {
13
+ expect(rsyncCommand.toCommandWithFlatArgs()).toEqual([
14
+ "rsync",
15
+ [
16
+ "-avz",
17
+ "--exclude",
18
+ ".DS_Store",
19
+ "--exclude",
20
+ ".git",
21
+ "./dist/web/experiments.cubing.net/test/deploy/",
22
+ "experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
23
+ ],
24
+ ]);
25
+ expect(rsyncCommand.toFlatCommand()).toEqual([
26
+ "rsync",
27
+ "-avz",
28
+ "--exclude",
29
+ ".DS_Store",
30
+ "--exclude",
31
+ ".git",
32
+ "./dist/web/experiments.cubing.net/test/deploy/",
33
+ "experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
34
+ ]);
35
+ expect(rsyncCommand.toCommandWithFlatArgs()).toEqual(rsyncCommand.forNode());
36
+ expect(rsyncCommand.toFlatCommand()).toEqual(rsyncCommand.forBun());
37
+ });
38
+
39
+ test("default formatting", () => {
40
+ expect(rsyncCommand.getPrintableCommand()).toEqual(
41
+ `rsync \\
42
+ -avz \\
43
+ --exclude .DS_Store \\
44
+ --exclude .git \\
45
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
46
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
47
+ );
48
+ expect(
49
+ rsyncCommand.getPrintableCommand({
50
+ quoting: "auto",
51
+ argumentLineWrapping: "by-entry",
52
+ }),
53
+ ).toEqual(rsyncCommand.getPrintableCommand());
54
+ });
55
+
56
+ test("extra-safe quoting", () => {
57
+ expect(rsyncCommand.getPrintableCommand({ quoting: "extra-safe" })).toEqual(
58
+ `'rsync' \\
59
+ '-avz' \\
60
+ '--exclude' '.DS_Store' \\
61
+ '--exclude' '.git' \\
62
+ './dist/web/experiments.cubing.net/test/deploy/' \\
63
+ 'experiments.cubing.net:~/experiments.cubing.net/test/deploy/'`,
64
+ );
65
+ });
66
+
67
+ test("indentation", () => {
68
+ expect(
69
+ rsyncCommand.getPrintableCommand({ argIndentation: "\t \t" }),
70
+ ).toEqual(
71
+ `rsync \\
72
+ -avz \\
73
+ --exclude .DS_Store \\
74
+ --exclude .git \\
75
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
76
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
77
+ );
78
+ expect(rsyncCommand.getPrintableCommand({ argIndentation: "↪ " })).toEqual(
79
+ `rsync \\
80
+ ↪ -avz \\
81
+ ↪ --exclude .DS_Store \\
82
+ ↪ --exclude .git \\
83
+ ↪ ./dist/web/experiments.cubing.net/test/deploy/ \\
84
+ ↪ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
85
+ );
86
+ expect(rsyncCommand.getPrintableCommand({ mainIndentation: " " })).toEqual(
87
+ ` rsync \\
88
+ -avz \\
89
+ --exclude .DS_Store \\
90
+ --exclude .git \\
91
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
92
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
93
+ );
94
+ expect(
95
+ rsyncCommand.getPrintableCommand({
96
+ mainIndentation: "🙈",
97
+ argIndentation: "🙉",
98
+ }),
99
+ ).toEqual(
100
+ `🙈rsync \\
101
+ 🙈🙉-avz \\
102
+ 🙈🙉--exclude .DS_Store \\
103
+ 🙈🙉--exclude .git \\
104
+ 🙈🙉./dist/web/experiments.cubing.net/test/deploy/ \\
105
+ 🙈🙉experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
106
+ );
107
+ });
108
+
109
+ test("line wrapping", () => {
110
+ expect(
111
+ rsyncCommand.getPrintableCommand({ argumentLineWrapping: "by-entry" }),
112
+ ).toEqual(rsyncCommand.getPrintableCommand());
113
+ expect(
114
+ rsyncCommand.getPrintableCommand({
115
+ argumentLineWrapping: "nested-by-entry",
116
+ }),
117
+ ).toEqual(`rsync \\
118
+ -avz \\
119
+ --exclude \\
120
+ .DS_Store \\
121
+ --exclude \\
122
+ .git \\
123
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
124
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`);
125
+ expect(
126
+ rsyncCommand.getPrintableCommand({ argumentLineWrapping: "by-argument" }),
127
+ ).toEqual(`rsync \\
128
+ -avz \\
129
+ --exclude \\
130
+ .DS_Store \\
131
+ --exclude \\
132
+ .git \\
133
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
134
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`);
135
+ expect(
136
+ rsyncCommand.getPrintableCommand({
137
+ argumentLineWrapping: "inline",
138
+ }),
139
+ ).toEqual(
140
+ "rsync -avz --exclude .DS_Store --exclude .git ./dist/web/experiments.cubing.net/test/deploy/ experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
141
+ );
142
+ });
143
+
144
+ test("command with space is escaped by default", () => {
145
+ const command = new PrintableShellCommand(
146
+ "/Applications/My App.app/Contents/Resources/my-app",
147
+ );
148
+
149
+ expect(command.getPrintableCommand()).toEqual(
150
+ `'/Applications/My App.app/Contents/Resources/my-app'`,
151
+ );
152
+ });