printable-shell-command 4.0.4 → 5.0.1

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,508 @@
1
+ import { expect, spyOn, test } from "bun:test";
2
+ import assert from "node:assert";
3
+ import { createWriteStream } from "node:fs";
4
+ import { stderr, stdout } from "node:process";
5
+ import { Path } from "path-class";
6
+ import { PrintableShellCommand } from ".";
7
+
8
+ globalThis.process.stdout.isTTY = false;
9
+
10
+ const rsyncCommand = new PrintableShellCommand("rsync", [
11
+ "-avz",
12
+ ["--exclude", ".DS_Store"],
13
+ ["--exclude", ".git"],
14
+ "./dist/web/experiments.cubing.net/test/deploy/",
15
+ "experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
16
+ ]);
17
+
18
+ test("args for commands", () => {
19
+ expect(rsyncCommand.toCommandWithFlatArgs()).toEqual([
20
+ "rsync",
21
+ [
22
+ "-avz",
23
+ "--exclude",
24
+ ".DS_Store",
25
+ "--exclude",
26
+ ".git",
27
+ "./dist/web/experiments.cubing.net/test/deploy/",
28
+ "experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
29
+ ],
30
+ ]);
31
+ });
32
+
33
+ test("default formatting", () => {
34
+ expect(rsyncCommand.getPrintableCommand()).toEqual(
35
+ `rsync \\
36
+ -avz \\
37
+ --exclude .DS_Store \\
38
+ --exclude .git \\
39
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
40
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
41
+ );
42
+ expect(
43
+ rsyncCommand.getPrintableCommand({
44
+ quoting: "auto",
45
+ argumentLineWrapping: "by-entry",
46
+ }),
47
+ ).toEqual(rsyncCommand.getPrintableCommand());
48
+ });
49
+
50
+ test("extra-safe quoting", () => {
51
+ expect(rsyncCommand.getPrintableCommand({ quoting: "extra-safe" })).toEqual(
52
+ `'rsync' \\
53
+ '-avz' \\
54
+ '--exclude' '.DS_Store' \\
55
+ '--exclude' '.git' \\
56
+ './dist/web/experiments.cubing.net/test/deploy/' \\
57
+ 'experiments.cubing.net:~/experiments.cubing.net/test/deploy/'`,
58
+ );
59
+ });
60
+
61
+ test("indentation", () => {
62
+ expect(
63
+ rsyncCommand.getPrintableCommand({ argIndentation: "\t \t" }),
64
+ ).toEqual(
65
+ `rsync \\
66
+ -avz \\
67
+ --exclude .DS_Store \\
68
+ --exclude .git \\
69
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
70
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
71
+ );
72
+ expect(rsyncCommand.getPrintableCommand({ argIndentation: "↪ " })).toEqual(
73
+ `rsync \\
74
+ ↪ -avz \\
75
+ ↪ --exclude .DS_Store \\
76
+ ↪ --exclude .git \\
77
+ ↪ ./dist/web/experiments.cubing.net/test/deploy/ \\
78
+ ↪ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
79
+ );
80
+ expect(rsyncCommand.getPrintableCommand({ mainIndentation: " " })).toEqual(
81
+ ` rsync \\
82
+ -avz \\
83
+ --exclude .DS_Store \\
84
+ --exclude .git \\
85
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
86
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
87
+ );
88
+ expect(
89
+ rsyncCommand.getPrintableCommand({
90
+ mainIndentation: "🙈",
91
+ argIndentation: "🙉",
92
+ }),
93
+ ).toEqual(
94
+ `🙈rsync \\
95
+ 🙈🙉-avz \\
96
+ 🙈🙉--exclude .DS_Store \\
97
+ 🙈🙉--exclude .git \\
98
+ 🙈🙉./dist/web/experiments.cubing.net/test/deploy/ \\
99
+ 🙈🙉experiments.cubing.net:~/experiments.cubing.net/test/deploy/`,
100
+ );
101
+ });
102
+
103
+ test("line wrapping", () => {
104
+ expect(
105
+ rsyncCommand.getPrintableCommand({ argumentLineWrapping: "by-entry" }),
106
+ ).toEqual(rsyncCommand.getPrintableCommand());
107
+ expect(
108
+ rsyncCommand.getPrintableCommand({
109
+ argumentLineWrapping: "nested-by-entry",
110
+ }),
111
+ ).toEqual(`rsync \\
112
+ -avz \\
113
+ --exclude \\
114
+ .DS_Store \\
115
+ --exclude \\
116
+ .git \\
117
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
118
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`);
119
+ expect(
120
+ rsyncCommand.getPrintableCommand({ argumentLineWrapping: "by-argument" }),
121
+ ).toEqual(`rsync \\
122
+ -avz \\
123
+ --exclude \\
124
+ .DS_Store \\
125
+ --exclude \\
126
+ .git \\
127
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
128
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/`);
129
+ expect(
130
+ rsyncCommand.getPrintableCommand({
131
+ argumentLineWrapping: "inline",
132
+ }),
133
+ ).toEqual(
134
+ "rsync -avz --exclude .DS_Store --exclude .git ./dist/web/experiments.cubing.net/test/deploy/ experiments.cubing.net:~/experiments.cubing.net/test/deploy/",
135
+ );
136
+ });
137
+
138
+ test("command with space is escaped by default", () => {
139
+ const command = new PrintableShellCommand(
140
+ "/Applications/My App.app/Contents/Resources/my-app",
141
+ );
142
+
143
+ expect(command.getPrintableCommand()).toEqual(
144
+ `'/Applications/My App.app/Contents/Resources/my-app'`,
145
+ );
146
+ });
147
+
148
+ test("command with equal sign is escaped by default", () => {
149
+ const command = new PrintableShellCommand("THIS_LOOKS_LIKE_AN=env-var");
150
+
151
+ expect(command.getPrintableCommand()).toEqual(`'THIS_LOOKS_LIKE_AN=env-var'`);
152
+ });
153
+
154
+ test("stylin'", () => {
155
+ expect(rsyncCommand.getPrintableCommand({ style: ["gray", "bold"] })).toEqual(
156
+ `\u001B[90m\u001B[1mrsync \\
157
+ -avz \\
158
+ --exclude .DS_Store \\
159
+ --exclude .git \\
160
+ ./dist/web/experiments.cubing.net/test/deploy/ \\
161
+ experiments.cubing.net:~/experiments.cubing.net/test/deploy/\u001B[22m\u001B[39m`,
162
+ );
163
+ });
164
+
165
+ test("more than 2 args in a group", () => {
166
+ expect(
167
+ new PrintableShellCommand("echo", [
168
+ ["the", "rain", "in", "spain"],
169
+ "stays",
170
+ ["mainly", "in", "the", "plain"],
171
+ ]).getPrintableCommand(),
172
+ ).toEqual(
173
+ `echo \\
174
+ the rain in spain \\
175
+ stays \\
176
+ mainly in the plain`,
177
+ );
178
+ });
179
+
180
+ test("don't line wrap after command", () => {
181
+ expect(
182
+ new PrintableShellCommand("echo", [
183
+ ["the", "rain", "in", "spain"],
184
+ "stays",
185
+ ["mainly", "in", "the", "plain"],
186
+ ]).getPrintableCommand({ skipLineWrapBeforeFirstArg: true }),
187
+ ).toEqual(
188
+ `echo the rain in spain \\
189
+ stays \\
190
+ mainly in the plain`,
191
+ );
192
+ });
193
+
194
+ test("don't line wrap after command (when there are no args)", () => {
195
+ expect(
196
+ new PrintableShellCommand("echo", []).getPrintableCommand({
197
+ skipLineWrapBeforeFirstArg: true,
198
+ }),
199
+ ).toEqual(`echo`);
200
+ });
201
+
202
+ test("Throws spawning error if the command can't be executed.", async () => {
203
+ const binPath = (await Path.makeTempDir()).join("nonexistent.bin");
204
+ expect(() => new PrintableShellCommand(binPath, []).text()).toThrow(
205
+ /ENOENT|Premature close/,
206
+ );
207
+ await binPath.write(`#!/usr/bin/env -S bun run --
208
+
209
+ console.log("hi");`);
210
+ expect(() => new PrintableShellCommand(binPath, []).text()).toThrow(
211
+ /EACCES|Premature close/,
212
+ );
213
+ await binPath.chmod(0o755);
214
+ expect(await new PrintableShellCommand(binPath, []).text()).toEqual("hi\n");
215
+ });
216
+
217
+ test("spawnDetached", async () => {
218
+ const tempPath = (await Path.makeTempDir()).join("file.txt");
219
+
220
+ expect(await tempPath.exists()).toBe(false);
221
+ new PrintableShellCommand("touch", [tempPath]).spawnDetached();
222
+
223
+ // Wait a short while for the command to finish.
224
+ await new Promise((resolve) => setTimeout(resolve, 100));
225
+ expect(await tempPath.existsAsFile()).toBe(true);
226
+
227
+ expect(() =>
228
+ new PrintableShellCommand("touch", [tempPath]).spawnDetached({
229
+ stdio: "pipe",
230
+ // biome-ignore lint/suspicious/noExplicitAny: We're purposely passing an invalid value.
231
+ } as any),
232
+ ).toThrow("Unexpected `stdio` field.");
233
+ expect(() =>
234
+ new PrintableShellCommand("touch", [tempPath]).spawnDetached({
235
+ detached: false,
236
+ // biome-ignore lint/suspicious/noExplicitAny: We're purposely passing an invalid value.
237
+ } as any),
238
+ ).toThrow("Unexpected `detached` field.");
239
+ });
240
+
241
+ test(".stdin(…) (text)", async () => {
242
+ const text = await new PrintableShellCommand("sed", [""])
243
+ .stdin({ text: "hello world" })
244
+ .text();
245
+ expect(text).toEqual("hello world");
246
+ });
247
+
248
+ test(".stdin(…) (JSON)", async () => {
249
+ const text = await new PrintableShellCommand("sed", [""])
250
+ .stdin({ json: [6, 7] })
251
+ .text();
252
+ expect(text).toEqual("[6,7]");
253
+
254
+ const json = await new PrintableShellCommand("sed", [""])
255
+ .stdin({ json: [6, 7] })
256
+ .json();
257
+ expect(json).toEqual([6, 7]);
258
+ });
259
+
260
+ test(".stdin(…) (Path)", async () => {
261
+ const path = (await Path.makeTempDir()).join("meme.json");
262
+ await path.writeJSON([6, 7]);
263
+
264
+ const json = await new PrintableShellCommand("sed", [""])
265
+ .stdin({ path })
266
+ .json();
267
+ expect(json).toEqual([6, 7]);
268
+ });
269
+
270
+ test(".stdin(…) (web stream)", async () => {
271
+ const tempDir = await Path.makeTempDir();
272
+ await tempDir.join("a.txt").write("");
273
+ await tempDir.join("b.txt").write("");
274
+
275
+ const paths = await Array.fromAsync(
276
+ new PrintableShellCommand("find", [
277
+ tempDir,
278
+ ["-type", "f"],
279
+ "-print0",
280
+ ]).text0(),
281
+ );
282
+ expect(paths.map((path) => new Path(path).basename.path).sort()).toEqual([
283
+ "a.txt",
284
+ "b.txt",
285
+ ]);
286
+ });
287
+
288
+ test("`Path` commandName", async () => {
289
+ const echoPath = new Path(
290
+ // Note that we need to use `which` instead of `command` here, because the latter binary does not have the same functionality as `command --search` in the shell.
291
+ (await new PrintableShellCommand("which", ["echo"]).stdout().text()).trim(),
292
+ );
293
+ await new PrintableShellCommand(echoPath, [
294
+ "from a `Path` commandName!",
295
+ ]).shellOut();
296
+ });
297
+
298
+ test("`Path` arg (unnested)", async () => {
299
+ const tempDir = await Path.makeTempDir();
300
+
301
+ await new PrintableShellCommand("ls", [tempDir]).shellOut();
302
+ });
303
+
304
+ test("`Path` arg (nested)", async () => {
305
+ const tempDir = await Path.makeTempDir();
306
+
307
+ await new PrintableShellCommand("ls", [[tempDir]]).shellOut();
308
+ });
309
+
310
+ test("`Path` cwd", async () => {
311
+ const tempDir = await Path.makeTempDir();
312
+ await tempDir.join("foo.txt").write("foo");
313
+ await tempDir.join("bar.txt").write("bar");
314
+
315
+ expect(
316
+ await new PrintableShellCommand("ls", [tempDir]).stdout().text(),
317
+ ).toEqual(`bar.txt
318
+ foo.txt
319
+ `);
320
+ });
321
+
322
+ test(".text()", async () => {
323
+ expect(await new PrintableShellCommand("echo", ["-n", "hi"]).text()).toEqual(
324
+ "hi",
325
+ );
326
+ });
327
+
328
+ test(".text()", async () => {
329
+ const bogusBinaryPath = (await Path.makeTempDir()).join("bogus-bin");
330
+ expect(() => new PrintableShellCommand(bogusBinaryPath).text()).toThrow(
331
+ "Premature close",
332
+ );
333
+ });
334
+
335
+ test(".json()", async () => {
336
+ expect(
337
+ await new PrintableShellCommand("echo", ["-n", '{ "foo": 4 }']).json<{
338
+ foo: number;
339
+ }>(),
340
+ ).toEqual({ foo: 4 });
341
+ });
342
+
343
+ test(".text0(…)", async () => {
344
+ const tempDir = await Path.makeTempDir();
345
+ await tempDir.join("a.txt").write("");
346
+ await tempDir.join("b.txt").write("");
347
+
348
+ const paths = await Array.fromAsync(
349
+ new PrintableShellCommand("find", [
350
+ tempDir,
351
+ ["-type", "f"],
352
+ "-print0",
353
+ ]).text0(),
354
+ );
355
+ expect(paths.map((path) => new Path(path).basename.path).sort()).toEqual([
356
+ "a.txt",
357
+ "b.txt",
358
+ ]);
359
+ });
360
+
361
+ // TODO: `bun` non-deterministically hangs on this.
362
+ test.skip(".text0(…) missing trailing NUL", async () => {
363
+ expect(() =>
364
+ Array.fromAsync(new PrintableShellCommand("printf", ["a\\0b"]).text0()),
365
+ ).toThrow(
366
+ "Missing a trailing NUL character at the end of a NUL-delimited stream.",
367
+ );
368
+ });
369
+
370
+ test(".text0(…) missing trailing NUL (workaround version)", async () => {
371
+ let caught: Error | undefined;
372
+ try {
373
+ await Array.fromAsync(
374
+ new PrintableShellCommand("printf", ["a\\0b"]).text0(),
375
+ );
376
+ } catch (e) {
377
+ caught = e as Error;
378
+ }
379
+ assert(caught);
380
+ expect(caught).toBeInstanceOf(Error);
381
+ expect(caught.toString()).toEqual(
382
+ "Error: Missing a trailing NUL character at the end of a NUL-delimited stream.",
383
+ );
384
+ });
385
+
386
+ test(".json0(…)", async () => {
387
+ const output = await Array.fromAsync(
388
+ new PrintableShellCommand("printf", ["%s\\0%s\\0", "[]", '[""]']).json0(),
389
+ );
390
+ expect(output).toEqual([[], [""]]);
391
+ });
392
+
393
+ // TODO: `bun` non-deterministically hangs on this.
394
+ test.skip(".json0(…) missing trailing NUL", async () => {
395
+ expect(() =>
396
+ Array.fromAsync(
397
+ new PrintableShellCommand("printf", ["%s\\0%s", "[]", '[""]']).json0(),
398
+ ),
399
+ ).toThrow(
400
+ "Missing a trailing NUL character at the end of a NUL-delimited stream.",
401
+ );
402
+ });
403
+
404
+ test(".json0(…) missing trailing NUL (workaround version)", async () => {
405
+ let caught: Error | undefined;
406
+ try {
407
+ await Array.fromAsync(
408
+ new PrintableShellCommand("printf", ["%s\\0%s", "[]", '[""]']).json0(),
409
+ );
410
+ } catch (e) {
411
+ caught = e as Error;
412
+ }
413
+ expect(caught).toBeInstanceOf(Error);
414
+ // Types are not powerful enough to infer this from last line.
415
+ assert(caught);
416
+ expect(caught.toString()).toEqual(
417
+ "Error: Missing a trailing NUL character at the end of a NUL-delimited stream.",
418
+ );
419
+ });
420
+
421
+ test(".shellOut()", async () => {
422
+ await new PrintableShellCommand("echo", ["hi"]).shellOut();
423
+ // await new PrintableShellCommand(("echo"), ["hi"]).shellOut({print: {styleTextFormat: "bgYellow"}});
424
+ });
425
+
426
+ const spyStderr = spyOn(stderr, "write");
427
+ const spyStdout = spyOn(stdout, "write");
428
+
429
+ const PLAIN_ECHO: [string][] = [["echo \\\n hi"], ["\n"]];
430
+ const BOLD_GRAY_ECHO: [string][] = [
431
+ ["\u001b[90m\u001b[1mecho \\\n hi\u001b[22m\u001b[39m"],
432
+ ["\n"],
433
+ ];
434
+
435
+ test("tty (stderr)", async () => {
436
+ globalThis.process.stderr.isTTY = false;
437
+ new PrintableShellCommand("echo", ["hi"]).print();
438
+ expect(spyStderr.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
439
+ expect(spyStdout.mock.lastCall).toEqual(undefined);
440
+
441
+ globalThis.process.stderr.isTTY = false;
442
+ new PrintableShellCommand("echo", ["hi"]).print({
443
+ autoStyle: "tty",
444
+ });
445
+ expect(spyStderr.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
446
+ new PrintableShellCommand("echo", ["hi"]).print({
447
+ autoStyle: "never",
448
+ });
449
+ expect(spyStderr.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
450
+
451
+ globalThis.process.stderr.isTTY = true;
452
+ new PrintableShellCommand("echo", ["hi"]).print();
453
+ expect(spyStderr.mock.calls.slice(-2)).toEqual(BOLD_GRAY_ECHO);
454
+ new PrintableShellCommand("echo", ["hi"]).print({
455
+ autoStyle: "tty",
456
+ });
457
+ expect(spyStderr.mock.calls.slice(-2)).toEqual(BOLD_GRAY_ECHO);
458
+ new PrintableShellCommand("echo", ["hi"]).print({
459
+ autoStyle: "never",
460
+ });
461
+ expect(spyStderr.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
462
+ });
463
+
464
+ test("tty (stdout)", async () => {
465
+ spyStderr.mockReset();
466
+ globalThis.process.stdout.isTTY = false;
467
+ new PrintableShellCommand("echo", ["hi"]).print({ stream: stdout });
468
+ expect(spyStderr.mock.lastCall).toEqual(undefined);
469
+ expect(spyStdout.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
470
+
471
+ globalThis.process.stdout.isTTY = false;
472
+ new PrintableShellCommand("echo", ["hi"]).print({
473
+ stream: stdout,
474
+ autoStyle: "tty",
475
+ });
476
+ expect(spyStdout.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
477
+ new PrintableShellCommand("echo", ["hi"]).print({
478
+ stream: stdout,
479
+ autoStyle: "never",
480
+ });
481
+ expect(spyStdout.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
482
+
483
+ globalThis.process.stdout.isTTY = true;
484
+ new PrintableShellCommand("echo", ["hi"]).print({ stream: stdout });
485
+ expect(spyStdout.mock.calls.slice(-2)).toEqual(BOLD_GRAY_ECHO);
486
+ new PrintableShellCommand("echo", ["hi"]).print({
487
+ stream: stdout,
488
+ autoStyle: "tty",
489
+ });
490
+ expect(spyStdout.mock.calls.slice(-2)).toEqual(BOLD_GRAY_ECHO);
491
+ new PrintableShellCommand("echo", ["hi"]).print({
492
+ stream: stdout,
493
+ autoStyle: "never",
494
+ });
495
+ expect(spyStdout.mock.calls.slice(-2)).toEqual(PLAIN_ECHO);
496
+ });
497
+
498
+ test("tty (fd 3)", async () => {
499
+ spyStdout.mockReset();
500
+ spyStderr.mockReset();
501
+ globalThis.process.stdout.isTTY = false;
502
+
503
+ const stream = createWriteStream("", { fd: 3 });
504
+
505
+ new PrintableShellCommand("echo", ["hi"]).print({ stream });
506
+ expect(spyStdout.mock.lastCall).toEqual(undefined);
507
+ expect(spyStderr.mock.lastCall).toEqual(undefined);
508
+ });