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.
package/src/index.ts CHANGED
@@ -1,624 +1,7 @@
1
- import assert from "node:assert";
2
- import type {
3
- ChildProcessByStdio,
4
- ChildProcess as NodeChildProcess,
5
- SpawnOptions as NodeSpawnOptions,
6
- } from "node:child_process";
7
- import { createReadStream } from "node:fs";
8
- import { stderr } from "node:process";
9
- import { Readable, Writable } from "node:stream";
10
- import type { WriteStream } from "node:tty";
11
- import { styleText } from "node:util";
12
- import { Path, stringifyIfPath } from "path-class";
13
- import type {
14
- NodeWithCwd,
15
- spawnType,
16
- WithStderrResponse,
17
- WithStdoutResponse,
18
- WithSuccess,
19
- } from "./spawn";
20
-
21
- // TODO: does this import work?
22
- /**
23
- * @import { stdout } from "node:process"
24
- */
25
-
26
- const DEFAULT_MAIN_INDENTATION = "";
27
- const DEFAULT_ARG_INDENTATION = " ";
28
- const DEFAULT_ARGUMENT_LINE_WRAPPING = "by-entry";
29
-
30
- const INLINE_SEPARATOR = " ";
31
- const LINE_WRAP_LINE_END = " \\\n";
32
-
33
- type StyleTextFormat = Parameters<typeof styleText>[0];
34
-
35
- const TTY_AUTO_STYLE: StyleTextFormat = ["gray", "bold"];
36
-
37
- // biome-ignore lint/suspicious/noExplicitAny: This is the correct type nere.
38
- function isString(s: any): s is string {
39
- return typeof s === "string";
40
- }
41
-
42
- // biome-ignore lint/suspicious/noExplicitAny: This is the correct type here.
43
- function isValidArgsEntryArray(entries: any[]): entries is SingleArgument[] {
44
- for (const entry of entries) {
45
- if (isString(entry)) {
46
- continue;
47
- }
48
- if (entry instanceof Path) {
49
- continue;
50
- }
51
- return false;
52
- }
53
- return true;
54
- }
55
-
56
- // TODO: allow `.toString()`ables?
57
- type SingleArgument = string | Path;
58
- type ArgsEntry = SingleArgument | SingleArgument[];
59
- type Args = ArgsEntry[];
60
-
61
- export interface PrintOptions {
62
- /** Defaults to "" */
63
- mainIndentation?: string;
64
- /** Defaults to " " */
65
- argIndentation?: string;
66
- /**
67
- * - `"auto"`: Quote only arguments that need it for safety. This tries to be
68
- * portable and safe across shells, but true safety and portability is hard
69
- * to guarantee.
70
- * - `"extra-safe"`: Quote all arguments, even ones that don't need it. This is
71
- * more likely to be safe under all circumstances.
72
- */
73
- quoting?: "auto" | "extra-safe";
74
- /** Line wrapping to use between arguments. Defaults to `"by-entry"`. */
75
- argumentLineWrapping?:
76
- | "by-entry"
77
- | "nested-by-entry"
78
- | "by-argument"
79
- | "inline";
80
- /** Include the first arg (or first arg group) on the same line as the command, regardless of the `argumentLineWrapping` setting. */
81
- skipLineWrapBeforeFirstArg?: true | false;
82
- /**
83
- * Style text using `node`'s {@link styleText | `styleText(…)`}.
84
- *
85
- * Example usage:
86
- *
87
- * ```
88
- * new PrintableShellCommand("echo", ["hi"]).print({
89
- * styleTextFormat: ["green", "underline"],
90
- * });
91
- * */
92
- styleTextFormat?: StyleTextFormat;
93
- }
94
-
95
- export interface StreamPrintOptions extends PrintOptions {
96
- /**
97
- * Auto-style the text when:
98
- *
99
- * - the output stream is detected to be a TTY
100
- * - `styleTextFormat` is not specified.
101
- *
102
- * The current auto style is: `["gray", "bold"]`
103
- */
104
- autoStyle?: "tty" | "never";
105
- // This would be a `WritableStream` (open web standard), but `WriteStream` allows us to query `.isTTY`.
106
- stream?: WriteStream | Writable;
107
- }
108
-
109
- // https://mywiki.wooledge.org/BashGuide/SpecialCharacters
110
- const SPECIAL_SHELL_CHARACTERS = new Set([
111
- " ",
112
- '"',
113
- "'",
114
- "`",
115
- "|",
116
- "$",
117
- "*",
118
- "?",
119
- ">",
120
- "<",
121
- "(",
122
- ")",
123
- "[",
124
- "]",
125
- "{",
126
- "}",
127
- "&",
128
- "\\",
129
- ";",
130
- "#",
131
- ]);
132
-
133
- // https://mywiki.wooledge.org/BashGuide/SpecialCharacters
134
- const SPECIAL_SHELL_CHARACTERS_FOR_MAIN_COMMAND =
135
- // biome-ignore lint/suspicious/noExplicitAny: Workaround to make this package easier to use in a project that otherwise only uses ES2022.)
136
- (SPECIAL_SHELL_CHARACTERS as unknown as any).union(new Set(["="]));
137
-
138
- // TODO: Is there an idiomatic ways to check that all potential fields of
139
- // `StdinSource` satisfy `(typeof STDIN_SOURCE_KEYS)[number]`, without adding
140
- // extra indirection for type wrangling?
141
- const STDIN_SOURCE_KEYS = ["text", "json", "path", "stream"] as const;
142
- export type StdinSource =
143
- | { text: string }
144
- // biome-ignore lint/suspicious/noExplicitAny: `any` is the correct type for JSON data.
145
- | { json: any }
146
- | { path: string | Path }
147
- | { stream: Readable | ReadableStream };
148
-
149
- export class PrintableShellCommand {
150
- #commandName: string | Path;
151
- constructor(
152
- commandName: string | Path,
153
- private args: Args = [],
154
- ) {
155
- if (!isString(commandName) && !(commandName instanceof Path)) {
156
- // biome-ignore lint/suspicious/noExplicitAny: We want to print this, no matter what it is.
157
- throw new Error("Command name is not a string:", commandName as any);
158
- }
159
- this.#commandName = commandName;
160
- if (typeof args === "undefined") {
161
- return;
162
- }
163
- if (!Array.isArray(args)) {
164
- throw new Error("Command arguments are not an array");
165
- }
166
- for (let i = 0; i < args.length; i++) {
167
- const argEntry = args[i];
168
- if (typeof argEntry === "string") {
169
- continue;
170
- }
171
- if (argEntry instanceof Path) {
172
- continue;
173
- }
174
- if (Array.isArray(argEntry) && isValidArgsEntryArray(argEntry)) {
175
- continue;
176
- }
177
- throw new Error(`Invalid arg entry at index: ${i}`);
178
- }
179
- }
180
-
181
- get commandName(): string {
182
- return stringifyIfPath(this.#commandName);
183
- }
184
-
185
- /**
186
- * For use with `node:child_process`
187
- *
188
- * Usage example:
189
- *
190
- * ```
191
- * import { PrintableShellCommand } from "printable-shell-command";
192
- * import { spawn } from "node:child_process";
193
- *
194
- * const command = new PrintableShellCommand( … );
195
- * const child_process = spawn(...command.toCommandWithFlatArgs()); // Note the `...`
196
- * ```
197
- *
198
- */
199
- public toCommandWithFlatArgs(): [string, string[]] {
200
- return [this.commandName, this.args.flat().map(stringifyIfPath)];
201
- }
202
-
203
- #mainIndentation(options: PrintOptions): string {
204
- return options?.mainIndentation ?? DEFAULT_MAIN_INDENTATION;
205
- }
206
-
207
- #argIndentation(options: PrintOptions): string {
208
- return (
209
- this.#mainIndentation(options) +
210
- (options?.argIndentation ?? DEFAULT_ARG_INDENTATION)
211
- );
212
- }
213
-
214
- #lineWrapSeparator(options: PrintOptions): string {
215
- return LINE_WRAP_LINE_END + this.#argIndentation(options);
216
- }
217
-
218
- #argPairSeparator(options: PrintOptions): string {
219
- switch (options?.argumentLineWrapping ?? DEFAULT_ARGUMENT_LINE_WRAPPING) {
220
- case "by-entry": {
221
- return INLINE_SEPARATOR;
222
- }
223
- case "nested-by-entry": {
224
- return this.#lineWrapSeparator(options) + this.#argIndentation(options);
225
- }
226
- case "by-argument": {
227
- return this.#lineWrapSeparator(options);
228
- }
229
- case "inline": {
230
- return INLINE_SEPARATOR;
231
- }
232
- default:
233
- throw new Error("Invalid argument line wrapping argument.");
234
- }
235
- }
236
-
237
- #intraEntrySeparator(options: PrintOptions): string {
238
- switch (options?.argumentLineWrapping ?? DEFAULT_ARGUMENT_LINE_WRAPPING) {
239
- case "by-entry":
240
- case "nested-by-entry":
241
- case "by-argument": {
242
- return LINE_WRAP_LINE_END + this.#argIndentation(options);
243
- }
244
- case "inline": {
245
- return INLINE_SEPARATOR;
246
- }
247
- default:
248
- throw new Error("Invalid argument line wrapping argument.");
249
- }
250
- }
251
-
252
- #separatorAfterCommand(
253
- options: PrintOptions,
254
- numFollowingEntries: number,
255
- ): string {
256
- if (numFollowingEntries === 0) {
257
- return "";
258
- }
259
- if (options.skipLineWrapBeforeFirstArg ?? false) {
260
- return INLINE_SEPARATOR;
261
- }
262
- return this.#intraEntrySeparator(options);
263
- }
264
-
265
- public getPrintableCommand(options?: PrintOptions): string {
266
- // TODO: Why in the world does TypeScript not give the `options` arg the type of `PrintOptions | undefined`???
267
- options ??= {};
268
- const serializedEntries: string[] = [];
269
-
270
- for (let i = 0; i < this.args.length; i++) {
271
- const argsEntry = stringifyIfPath(this.args[i]);
272
-
273
- if (isString(argsEntry)) {
274
- serializedEntries.push(escapeArg(argsEntry, false, options));
275
- } else {
276
- serializedEntries.push(
277
- argsEntry
278
- .map((part) => escapeArg(stringifyIfPath(part), false, options))
279
- .join(this.#argPairSeparator(options)),
280
- );
281
- }
282
- }
283
-
284
- let text =
285
- this.#mainIndentation(options) +
286
- escapeArg(this.commandName, true, options) +
287
- this.#separatorAfterCommand(options, serializedEntries.length) +
288
- serializedEntries.join(this.#intraEntrySeparator(options));
289
- if (options?.styleTextFormat) {
290
- text = styleText(options.styleTextFormat, text);
291
- }
292
- return text;
293
- }
294
-
295
- /**
296
- * Print the shell command to {@link stderr} (default) or a specified stream.
297
- *
298
- * By default, this will be auto-styled (as bold gray) when `.isTTY` is true
299
- * for the stream. `.isTTY` is populated for the {@link stderr} and
300
- * {@link stdout} objects. Pass `"autoStyle": "never"` or an explicit
301
- * `styleTextFormat` to disable this.
302
- *
303
- */
304
- public print(options?: StreamPrintOptions): PrintableShellCommand {
305
- const stream = options?.stream ?? stderr;
306
- // Note: we only need to modify top-level fields, so `structuredClone(…)`
307
- // would be overkill and can only cause performance issues.
308
- const optionsCopy = { ...options };
309
- optionsCopy.styleTextFormat ??=
310
- options?.autoStyle !== "never" &&
311
- (stream as { isTTY?: boolean }).isTTY === true
312
- ? TTY_AUTO_STYLE
313
- : undefined;
314
- const writable =
315
- stream instanceof Writable ? stream : Writable.fromWeb(stream);
316
- writable.write(this.getPrintableCommand(optionsCopy));
317
- writable.write("\n");
318
- return this;
319
- }
320
-
321
- #stdinSource: StdinSource | undefined;
322
- /**
323
- * Send data to `stdin` of the subprocess.
324
- *
325
- * Note that this will overwrite:
326
- *
327
- * - Any previous value set using {@link PrintableShellCommand.stdin | `.stdin(…)`}.
328
- * - Any value set for `stdin` using the `"stdio"` field of {@link PrintableShellCommand.spawn | `.spawn(…)`}.
329
- */
330
- stdin(source: StdinSource): PrintableShellCommand {
331
- const [key, ...moreKeys] = Object.keys(source);
332
- assert.equal(moreKeys.length, 0);
333
- // TODO: validate values?
334
- assert((STDIN_SOURCE_KEYS as unknown as string[]).includes(key));
335
-
336
- this.#stdinSource = source;
337
- return this;
338
- }
339
-
340
- /**
341
- * The returned child process includes a `.success` `Promise` field, per https://github.com/oven-sh/bun/issues/8313
342
- */
343
- public spawn: typeof spawnType = ((
344
- options?: Parameters<typeof spawnType>[0],
345
- ) => {
346
- const { spawn } = process.getBuiltinModule("node:child_process");
347
- const cwd = stringifyIfPath(options?.cwd);
348
- if (this.#stdinSource) {
349
- options ??= {};
350
- if (typeof options.stdio === "undefined") {
351
- options.stdio = "pipe";
352
- }
353
- if (typeof options.stdio === "string") {
354
- options.stdio = new Array(3).fill(options.stdio);
355
- }
356
- options.stdio[0] = "pipe";
357
- }
358
- // biome-ignore lint/suspicious/noTsIgnore: We don't want linting to depend on *broken* type checking.
359
- // @ts-ignore: The TypeScript checker has trouble reconciling the optional (i.e. potentially `undefined`) `options` with the third argument.
360
- const subprocess = spawn(...this.toCommandWithFlatArgs(), {
361
- ...(options as object),
362
- cwd,
363
- }) as NodeChildProcess & {
364
- success: Promise<void>;
365
- };
366
- // TODO: define properties on prototypes instead.
367
- Object.defineProperty(subprocess, "success", {
368
- get() {
369
- return new Promise<void>((resolve, reject) => {
370
- this.addListener(
371
- "exit",
372
- (exitCode: number /* we only use the first arg */) => {
373
- if (exitCode === 0) {
374
- resolve();
375
- } else {
376
- reject(`Command failed with non-zero exit code: ${exitCode}`);
377
- }
378
- },
379
- );
380
- // biome-ignore lint/suspicious/noExplicitAny: We don't have the type available.
381
- this.addListener("error", (err: any) => {
382
- reject(err);
383
- });
384
- });
385
- },
386
- enumerable: false,
387
- });
388
- if (subprocess.stdout) {
389
- // TODO: dedupe
390
- const s = subprocess as unknown as Readable &
391
- WithStdoutResponse &
392
- WithSuccess;
393
- s.stdout.response = () =>
394
- new Response(Readable.from(this.#generator(s.stdout, s.success)));
395
- s.stdout.text = () => s.stdout.response().text();
396
- const thisCached = this; // TODO: make this type-check using `.bind(…)`
397
- s.stdout.text0 = async function* () {
398
- yield* thisCached.#split0(thisCached.#generator(s.stdout, s.success));
399
- };
400
- s.stdout.json = <T>() => s.stdout.response().json() as Promise<T>;
401
- }
402
- if (subprocess.stderr) {
403
- // TODO: dedupe
404
- const s = subprocess as unknown as Readable &
405
- WithStderrResponse &
406
- WithSuccess;
407
- s.stderr.response = () =>
408
- new Response(Readable.from(this.#generator(s.stderr, s.success)));
409
- s.stderr.text = () => s.stderr.response().text();
410
- const thisCached = this; // TODO: make this type-check using `.bind(…)`
411
- s.stderr.text0 = async function* () {
412
- yield* thisCached.#split0(thisCached.#generator(s.stderr, s.success));
413
- };
414
- s.stderr.json = <T>() => s.stderr.response().json() as Promise<T>;
415
- }
416
- if (this.#stdinSource) {
417
- const { stdin } = subprocess;
418
- assert(stdin);
419
- if ("text" in this.#stdinSource) {
420
- stdin.write(this.#stdinSource.text);
421
- stdin.end();
422
- } else if ("json" in this.#stdinSource) {
423
- stdin.write(JSON.stringify(this.#stdinSource.json));
424
- stdin.end();
425
- } else if ("path" in this.#stdinSource) {
426
- createReadStream(stringifyIfPath(this.#stdinSource.path)).pipe(stdin);
427
- } else if ("stream" in this.#stdinSource) {
428
- const stream = (() => {
429
- const { stream } = this.#stdinSource;
430
- return stream instanceof Readable ? stream : Readable.fromWeb(stream);
431
- })();
432
- stream.pipe(stdin);
433
- } else {
434
- throw new Error("Invalid `.stdin(…)` source?");
435
- }
436
- }
437
- return subprocess;
438
- // biome-ignore lint/suspicious/noExplicitAny: Type wrangling
439
- }) as any;
440
-
441
- /** A wrapper for `.spawn(…)` that sets stdio to `"inherit"` (common for
442
- * invoking commands from scripts whose output and interaction should be
443
- * surfaced to the user).
444
- *
445
- * If there is no other interaction with the shell from the calling process,
446
- * then it acts "transparent" and allows user to interact with the subprocess
447
- * in its stead.
448
- */
449
- public spawnTransparently(
450
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
451
- ): ChildProcessByStdio<null, null, null> & WithSuccess {
452
- if (options && "stdio" in options) {
453
- throw new Error("Unexpected `stdio` field.");
454
- }
455
-
456
- // biome-ignore lint/suspicious/noExplicitAny: Type wrangling.
457
- return this.spawn({ ...options, stdio: "inherit" }) as any;
458
- }
459
-
460
- /**
461
- * A wrapper for {@link PrintableShellCommand.spawn | `.spawn(…)`} that:
462
- *
463
- * - sets `detached` to `true`,
464
- * - sets stdio to `"inherit"`,
465
- * - calls `.unref()`, and
466
- * - does not wait for the process to exit.
467
- *
468
- * This is similar to starting a command in the background and disowning it (in a shell).
469
- *
470
- */
471
- public spawnDetached(
472
- options?: NodeWithCwd<Omit<Omit<NodeSpawnOptions, "stdio">, "detached">>,
473
- ): void {
474
- if (options) {
475
- for (const field of ["stdio", "detached"]) {
476
- if (field in options) {
477
- throw new Error(`Unexpected \`${field}\` field.`);
478
- }
479
- }
480
- }
481
- const childProcess = this.spawn({
482
- stdio: "ignore",
483
- ...options,
484
- detached: true,
485
- });
486
- childProcess.unref();
487
- }
488
-
489
- #generator(
490
- readable: Readable,
491
- successPromise: Promise<void>,
492
- ): AsyncGenerator<string> {
493
- // TODO: we'd make this a `ReadableStream`, but `ReadableStream.from(…)` is
494
- // not implemented in `bun`: https://github.com/oven-sh/bun/issues/3700
495
- return (async function* () {
496
- for await (const chunk of readable) {
497
- yield chunk;
498
- }
499
- await successPromise;
500
- })();
501
- }
502
-
503
- #stdoutSpawnGenerator(
504
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
505
- ): AsyncGenerator<string> {
506
- if (options && "stdio" in options) {
507
- throw new Error("Unexpected `stdio` field.");
508
- }
509
- const subprocess = this.spawn({
510
- ...options,
511
- stdio: ["ignore", "pipe", "inherit"],
512
- });
513
- return this.#generator(subprocess.stdout, subprocess.success);
514
- }
515
-
516
- public stdout(
517
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
518
- ): Response {
519
- // TODO: Use `ReadableStream.from(…)` once `bun` implements it: https://github.com/oven-sh/bun/pull/21269
520
- return new Response(Readable.from(this.#stdoutSpawnGenerator(options)));
521
- }
522
-
523
- async *#split0(generator: AsyncGenerator<string>): AsyncGenerator<string> {
524
- let pending = "";
525
- for await (const chunk of generator) {
526
- pending += chunk;
527
- const newChunks = pending.split("\x00");
528
- pending = newChunks.splice(-1)[0];
529
- yield* newChunks;
530
- }
531
- if (pending !== "") {
532
- throw new Error(
533
- "Missing a trailing NUL character at the end of a NUL-delimited stream.",
534
- );
535
- }
536
- }
537
-
538
- /**
539
- * Convenience function for:
540
- *
541
- * .stdout(options).text()
542
- *
543
- * This can make some simple invocations easier to read and/or fit on a single line.
544
- */
545
- public text(
546
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
547
- ): Promise<string> {
548
- return this.stdout(options).text();
549
- }
550
-
551
- /**
552
- * Convenience function for:
553
- *
554
- * .stdout(options).json()
555
- *
556
- * This can make some simple invocations easier to read and/or fit on a single line.
557
- */
558
- public json<T>(
559
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
560
- ): Promise<T> {
561
- return this.stdout(options).json() as Promise<T>;
562
- }
563
-
564
- /**
565
- * Parse `stdout` into a generator of string values using a NULL delimiter.
566
- *
567
- * A trailing NULL delimiter from `stdout` is required and removed.
568
- */
569
- public async *text0(
570
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
571
- ): AsyncGenerator<string> {
572
- yield* this.#split0(this.#stdoutSpawnGenerator(options));
573
- }
574
-
575
- /**
576
- * Parse `stdout` into a generator of JSON values using a NULL delimiter.
577
- *
578
- * A trailing NULL delimiter from `stdout` is required and removed.
579
- */
580
- public async *json0(
581
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
582
- ): // biome-ignore lint/suspicious/noExplicitAny: `any` is the correct type for JSON
583
- AsyncGenerator<any> {
584
- for await (const part of this.#split0(
585
- this.#stdoutSpawnGenerator(options),
586
- )) {
587
- yield JSON.parse(part);
588
- }
589
- }
590
-
591
- /** Equivalent to:
592
- *
593
- * ```
594
- * await this.print().spawnTransparently(…).success;
595
- * ```
596
- */
597
- public async shellOut(
598
- options?: NodeWithCwd<Omit<NodeSpawnOptions, "stdio">>,
599
- ): Promise<void> {
600
- await this.print().spawnTransparently(options).success;
601
- }
602
- }
603
-
604
- export function escapeArg(
605
- arg: string,
606
- isMainCommand: boolean,
607
- options: PrintOptions,
608
- ): string {
609
- const argCharacters = new Set(arg);
610
- const specialShellCharacters = isMainCommand
611
- ? SPECIAL_SHELL_CHARACTERS_FOR_MAIN_COMMAND
612
- : SPECIAL_SHELL_CHARACTERS;
613
- if (
614
- options?.quoting === "extra-safe" ||
615
- // biome-ignore lint/suspicious/noExplicitAny: Workaround to make this package easier to use in a project that otherwise only uses ES2022.)
616
- (argCharacters as unknown as any).intersection(specialShellCharacters)
617
- .size > 0
618
- ) {
619
- // Use single quote to reduce the need to escape (and therefore reduce the chance for bugs/security issues).
620
- const escaped = arg.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
621
- return `'${escaped}'`;
622
- }
623
- return arg;
624
- }
1
+ export {
2
+ escapeArg,
3
+ PrintableShellCommand,
4
+ type PrintOptions,
5
+ type StdinSource,
6
+ type StreamPrintOptions,
7
+ } from "./PrintableShellCommand";