utilful 3.4.0 → 3.5.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/README.md CHANGED
@@ -39,7 +39,7 @@ import { defu } from 'utilful' // Everything
39
39
  import { joinURL } from 'utilful/path' // Just the path helpers
40
40
  ```
41
41
 
42
- Two modules are the exception and live on their subpaths alone. `cli` is Node-only (22.13 or later) and ships as `utilful/cli` and `utilful/cli/testing`. `image` is browser-only and ships as `utilful/image`.
42
+ Two modules are the exception and live on their subpaths alone. `cli` is Node-only (24.16 or later) and ships as `utilful/cli` and `utilful/cli/testing`. `image` is browser-only and ships as `utilful/image`.
43
43
 
44
44
  ## API
45
45
 
@@ -57,23 +57,26 @@ declare function toArray<T>(array?: MaybeArray<T> | null | undefined): T[]
57
57
 
58
58
  ### CLI
59
59
 
60
- A command runner on top of Node's `util.parseArgs`: strict option parsing, one level of sub-commands, `--help` and `--version`, and an error boundary that prints a recognized error as a message and anything else with its stack. `-h` and `-v` belong to the runner, so no option may take either letter as its alias.
60
+ A command runner on top of Node's `util.parseArgs`: strict option parsing, sub-commands at any depth, `--help`, `--version` and `--verbose` on every command, and an error boundary that prints a recognized error as its message and causes, and anything else with its stack as well. `--verbose` adds the stack to every failure but a wrong argument. `-h` and `-v` belong to the runner, so no option may take either letter as its alias.
61
61
 
62
62
  #### `defineCommand`
63
63
 
64
- Types a command definition, so `run` receives its `args` typed by their definitions.
64
+ Types a command definition, so `run` receives its `args` typed by their definitions. Declared `as const`, the arguments also type an exported command under `isolatedDeclarations`.
65
65
 
66
66
  ```ts
67
- import { CliError, commonArgs, defineCommand } from 'utilful/cli'
67
+ import type { CommandDef } from 'utilful/cli'
68
+ import { CliError, defineCommand } from 'utilful/cli'
68
69
 
69
- const build = defineCommand({
70
+ const buildArgs = {
71
+ 'file': { type: 'positional', description: 'Entry file', required: true }, // string
72
+ 'out-dir': { type: 'string', alias: 'd', description: 'Output directory', valueHint: 'path' }, // string | undefined
73
+ 'format': { type: 'enum', options: ['esm', 'iife'], default: 'esm' }, // 'esm' | 'iife'
74
+ 'watch': { type: 'boolean', alias: 'w', description: 'Rebuild on change' }, // boolean, --no-watch turns it off
75
+ } as const
76
+
77
+ export const build: CommandDef<typeof buildArgs> = defineCommand({
70
78
  meta: { name: 'build', description: 'Compile the entry file' },
71
- args: {
72
- ...commonArgs, // Adds --verbose
73
- 'file': { type: 'positional', description: 'Entry file', required: true }, // string
74
- 'out-dir': { type: 'string', alias: 'd', description: 'Output directory', valueHint: 'path' }, // string | undefined
75
- 'watch': { type: 'boolean', alias: 'w', description: 'Rebuild on change' }, // boolean, --no-watch turns it off
76
- },
79
+ args: buildArgs,
77
80
  run({ args }) {
78
81
  if (args.watch && args['out-dir'] === undefined)
79
82
  throw new CliError('--watch needs an --out-dir') // Printed as a message, no stack
@@ -96,11 +99,11 @@ const main = defineCommand({
96
99
  void runMain(main, { expectedErrors: [MyLibraryError] }) // Reported like a `CliError`
97
100
  ```
98
101
 
99
- Also exported: `runCommand` (runs the tree and throws instead of reporting), `parseArgs`, `reportFailure` (for a failure that outlives `run`, such as a watch rebuild), and `log` with `error`, `warn`, `info`, `success` and `blankLine`, all writing to stderr.
102
+ Also exported: `runCommand` (runs the tree and throws instead of reporting), `parseArgs`, `reportFailure` (for a failure that outlives `run`, such as a watch rebuild), `readStdin` (everything piped in, as text), `paint` (`styleText` for a given stream, hex colors included), and `log` with `error`, `warn`, `info`, `success` and `blankLine`, all writing to stderr.
100
103
 
101
104
  #### `createCliHarness`
102
105
 
103
- From `utilful/cli/testing`, which needs `vitest`. `runCli` runs the tree in-process and captures both streams and the exit code, `runCliProcess` runs the entry file as a child process. `useTemporaryDirectories` and `mockStdin` ship alongside.
106
+ From `utilful/cli/testing`, which needs `vitest`. `runCli` runs the tree in-process and captures both streams and the exit code, `runCliProcess` runs the entry file as a child process. Both take a `cwd` and an `env`, where `undefined` removes a variable. `useTemporaryDirectories` and `mockStdin` ship alongside and clean up after the test.
104
107
 
105
108
  ```ts
106
109
  import { createCliHarness, useTemporaryDirectories } from 'utilful/cli/testing'
@@ -15,30 +15,40 @@ interface BooleanArgDef {
15
15
  description?: string;
16
16
  default?: boolean;
17
17
  }
18
+ interface EnumArgDef extends Omit<StringArgDef, "type"> {
19
+ type: "enum";
20
+ options: readonly string[];
21
+ }
18
22
  interface PositionalArgDef {
19
23
  type: "positional";
20
24
  description?: string;
21
25
  required?: boolean;
22
26
  }
23
- type ArgDef = StringArgDef | BooleanArgDef | PositionalArgDef;
27
+ type ArgDef = StringArgDef | BooleanArgDef | EnumArgDef | PositionalArgDef;
24
28
  type ArgsDef = Record<string, ArgDef>;
25
29
  type ParsedArgs<T extends ArgsDef = ArgsDef> = {
26
30
  /** Every positional, bound by a definition or not. */
27
31
  _: string[];
28
32
  } & { [K in keyof T]: ArgDef extends T[K] ? string | boolean | undefined : T[K] extends {
29
33
  type: "boolean";
30
- } ? boolean : T[K] extends {
34
+ } ? boolean : OptionalUnless<T[K], T[K] extends {
35
+ type: "enum";
36
+ options: readonly (infer V)[];
37
+ } ? V : string>; };
38
+ type OptionalUnless<D, V> = D extends {
31
39
  default: string;
32
40
  } | {
33
41
  required: true;
34
- } ? string : string | undefined; };
42
+ } ? V : V | undefined;
43
+ /** @deprecated See `commonArgs`. */
35
44
  interface CommonArgs extends ArgsDef {
36
45
  verbose: BooleanArgDef;
37
46
  }
47
+ /** @deprecated The runner accepts `--verbose` on every command, so spreading this adds nothing. */
38
48
  declare const commonArgs: CommonArgs;
39
49
  /** Parses `argv` against a definition. An absent boolean reads as `false`, and `--no-<name>` turns one off. */
40
- declare function parseArgs$1<T extends ArgsDef>(argv: readonly string[], argsDef: T, { allowExtraPositionals }?: {
50
+ declare function parseArgs$1<const T extends ArgsDef>(argv: readonly string[], argsDef: T, { allowExtraPositionals }?: {
41
51
  allowExtraPositionals?: boolean;
42
52
  }): ParsedArgs<T>;
43
53
  //#endregion
44
- export { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs$1 as parseArgs };
54
+ export { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, EnumArgDef, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs$1 as parseArgs };
package/dist/cli/args.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { ArgumentError } from "./errors.mjs";
2
2
  import { parseArgs } from "node:util";
3
3
  //#region src/cli/args.ts
4
- const commonArgs = { verbose: {
4
+ const verboseArg = {
5
5
  type: "boolean",
6
- description: "Print the cause chain and stack trace on failure"
7
- } };
6
+ description: "Print the stack trace on failure"
7
+ };
8
+ /** @deprecated The runner accepts `--verbose` on every command, so spreading this adds nothing. */
9
+ const commonArgs = { verbose: verboseArg };
8
10
  /** Parses `argv` against a definition. An absent boolean reads as `false`, and `--no-<name>` turns one off. */
9
11
  function parseArgs$1(argv, argsDef, { allowExtraPositionals = false } = {}) {
10
12
  let parseResult;
@@ -28,6 +30,7 @@ function parseArgs$1(argv, argsDef, { allowExtraPositionals = false } = {}) {
28
30
  continue;
29
31
  }
30
32
  const value = parseResult.values[name];
33
+ if (definition.type === "enum") assertEnumValue(name, definition, value);
31
34
  if (definition.type === "boolean") args[name] = value ?? false;
32
35
  else if (value === void 0 && definition.required === true) throw new ArgumentError(`Missing required argument: --${name}`);
33
36
  else args[name] = value;
@@ -45,7 +48,7 @@ function toNodeOptions(argsDef) {
45
48
  const options = {};
46
49
  for (const [name, definition] of Object.entries(argsDef)) {
47
50
  if (definition.type === "positional") continue;
48
- const option = { type: definition.type };
51
+ const option = { type: definition.type === "boolean" ? "boolean" : "string" };
49
52
  if (definition.alias !== void 0) option.short = definition.alias;
50
53
  if (definition.default !== void 0) option.default = definition.default;
51
54
  options[name] = option;
@@ -80,7 +83,7 @@ function splitShortOptionValues(argv) {
80
83
  function joinNegativeValues(argv, argsDef) {
81
84
  const optionNamesBySpelling = /* @__PURE__ */ new Map();
82
85
  for (const [name, definition] of Object.entries(argsDef)) {
83
- if (definition.type !== "string") continue;
86
+ if (definition.type !== "string" && definition.type !== "enum") continue;
84
87
  optionNamesBySpelling.set(`--${name}`, name);
85
88
  if (definition.alias !== void 0) optionNamesBySpelling.set(`-${definition.alias}`, name);
86
89
  }
@@ -102,8 +105,13 @@ function joinNegativeValues(argv, argsDef) {
102
105
  }
103
106
  return joinedArguments;
104
107
  }
108
+ function assertEnumValue(name, definition, value) {
109
+ const options = definition.options.join(", ");
110
+ if (definition.default !== void 0 && !definition.options.includes(definition.default)) throw new Error(`The default of --${name}, ${JSON.stringify(definition.default)}, is not one of: ${options}`);
111
+ if (value !== void 0 && !definition.options.includes(value)) throw new ArgumentError(`Invalid value for --${name}: ${JSON.stringify(value)}. Expected one of: ${options}`);
112
+ }
105
113
  function isNodeArgumentError(error) {
106
114
  return error instanceof Error && String(error.code).startsWith("ERR_PARSE_ARGS");
107
115
  }
108
116
  //#endregion
109
- export { commonArgs, parseArgs$1 as parseArgs, toNodeOptions };
117
+ export { commonArgs, parseArgs$1 as parseArgs, toNodeOptions, verboseArg };
@@ -1,4 +1,4 @@
1
- import { ArgsDef, ParsedArgs } from "./args.mjs";
1
+ import { ArgDef, ArgsDef, ParsedArgs } from "./args.mjs";
2
2
  import { ReportOptions } from "./errors.mjs";
3
3
  //#region src/cli/command.d.ts
4
4
  interface CommandMeta {
@@ -20,7 +20,12 @@ interface RunMainOptions extends Omit<ReportOptions, "verbose"> {
20
20
  /** @default process.argv.slice(2) */
21
21
  argv?: readonly string[];
22
22
  }
23
- declare function defineCommand<T extends ArgsDef>(command: CommandDef<T>): CommandDef<T>;
23
+ type ArgDefKey = ArgDef extends (infer D) ? D extends unknown ? keyof D : never : never;
24
+ /** Maps every key no argument definition knows to `never`, so a misspelled one fails where the arguments are passed. */
25
+ type KnownKeysOnly<T> = { [K in keyof T]: { [P in keyof T[K]]: P extends ArgDefKey ? T[K][P] : never; }; };
26
+ declare function defineCommand<const T extends ArgsDef>(command: CommandDef<T> & {
27
+ args?: KnownKeysOnly<T>;
28
+ }): CommandDef<T>;
24
29
  /**
25
30
  * Runs the command tree and reports whatever it throws. Help someone asked for
26
31
  * is the result of the run and goes to stdout; usage that follows a wrong
@@ -1,5 +1,5 @@
1
1
  import { ArgumentError, reportFailure } from "./errors.mjs";
2
- import { parseArgs as parseArgs$1, toNodeOptions } from "./args.mjs";
2
+ import { parseArgs as parseArgs$1, toNodeOptions, verboseArg } from "./args.mjs";
3
3
  import { renderUsage } from "./usage.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
  import process from "node:process";
@@ -33,19 +33,51 @@ async function runMain(command, options = {}) {
33
33
  }
34
34
  /** Runs like `runMain`, but without the error boundary. */
35
35
  async function runCommand(command, argv) {
36
- const { name, firstOperand, rest } = findSubCommand(command, argv);
37
- if (name !== void 0) return runCommand(command.subCommands[name], rest);
38
- if (command.subCommands !== void 0 && command.run === void 0) throw new ArgumentError(firstOperand === void 0 ? "Missing command" : `Unknown command: ${firstOperand}`);
39
- const args = parseArgs$1(rest, command.args ?? {}, { allowExtraPositionals: command.allowExtraPositionals });
40
- await command.run?.({ args });
36
+ const { commands, firstOperand, rest } = resolveCommand(command, argv);
37
+ const leaf = withRunnerArgs(commands.at(-1));
38
+ if (isDispatchOnly(leaf)) throw new ArgumentError(firstOperand === void 0 ? "Missing command" : `Unknown command: ${firstOperand}`);
39
+ const args = parseArgs$1(rest, leaf.args ?? {}, { allowExtraPositionals: leaf.allowExtraPositionals });
40
+ await leaf.run?.({ args });
41
41
  }
42
42
  function usageFor(command, argv, stream) {
43
- const { name } = findSubCommand(command, argv);
44
- return name === void 0 ? renderUsage(command, { stream }) : renderUsage(command.subCommands[name], {
45
- parent: command,
43
+ const { commands, keys } = resolveCommand(command, argv);
44
+ return renderUsage(withRunnerArgs(commands.at(-1)), {
45
+ commandPath: [command.meta?.name, ...keys].filter((name) => name !== void 0).join(" "),
46
+ version: commands.findLast((walked) => walked.meta?.version !== void 0)?.meta?.version,
46
47
  stream
47
48
  });
48
49
  }
50
+ function resolveCommand(command, argv) {
51
+ const commands = [command];
52
+ const keys = [];
53
+ let rest = [...argv];
54
+ while (true) {
55
+ const found = findSubCommand(commands.at(-1), rest);
56
+ if (found.name === void 0) return {
57
+ commands,
58
+ keys,
59
+ firstOperand: found.firstOperand,
60
+ rest
61
+ };
62
+ commands.push(commands.at(-1).subCommands[found.name]);
63
+ keys.push(found.name);
64
+ rest = found.rest;
65
+ }
66
+ }
67
+ function isDispatchOnly(command) {
68
+ return command.subCommands !== void 0 && command.run === void 0;
69
+ }
70
+ /** `runMain` reads `--verbose` from argv itself, so strict parsing has to accept it on every command that parses arguments. */
71
+ function withRunnerArgs(command) {
72
+ if (isDispatchOnly(command)) return command;
73
+ return {
74
+ ...command,
75
+ args: {
76
+ ...command.args,
77
+ verbose: command.args?.verbose ?? verboseArg
78
+ }
79
+ };
80
+ }
49
81
  /**
50
82
  * Finds the sub-command the first operand names, wherever it stands among the
51
83
  * options. Telling an operand from the value of an option needs every
@@ -2,7 +2,7 @@
2
2
  type ErrorClass = abstract new (...args: never[]) => Error;
3
3
  interface ReportOptions {
4
4
  verbose?: boolean;
5
- /** Treated like `CliError`: message only, no stack. */
5
+ /** Treated like `CliError`: no stack unless `verbose`. */
6
6
  expectedErrors?: readonly ErrorClass[];
7
7
  /** Renders an error where its message alone will not do; `undefined` falls back to the message. */
8
8
  describe?: (error: Error) => string | undefined;
@@ -10,12 +10,11 @@ var CliError = class extends Error {};
10
10
  var ArgumentError = class extends CliError {};
11
11
  /** Reports a failure the way the boundary does, for one that outlives `run`, such as a watch rebuild. */
12
12
  function reportFailure(error$1, { verbose = false, expectedErrors = [], describe } = {}) {
13
- const sections = [error$1 instanceof Error ? describe?.(error$1) ?? error$1.message : String(error$1)];
14
- if (verbose || !isExpected(error$1, expectedErrors)) {
15
- const causeChain = formatCauseChain(error$1);
16
- if (causeChain) sections.push(causeChain);
17
- if (error$1 instanceof Error && error$1.stack) sections.push(error$1.stack);
18
- }
13
+ const message = error$1 instanceof Error ? describe?.(error$1) ?? error$1.message : String(error$1);
14
+ const sections = [message];
15
+ const causeChain = formatCauseChain(error$1, message);
16
+ if (causeChain) sections.push(causeChain);
17
+ if ((verbose || !isExpected(error$1, expectedErrors)) && error$1 instanceof Error && error$1.stack) sections.push(error$1.stack);
19
18
  error(sections.join("\n\n"));
20
19
  process.exitCode = 1;
21
20
  }
@@ -30,11 +29,13 @@ function isExpected(error, expectedErrors) {
30
29
  if (expectedErrors.some((expectedError) => error instanceof expectedError)) return true;
31
30
  return error instanceof Error && /^E[A-Z0-9]+$/.test(String(error.code));
32
31
  }
33
- function formatCauseChain(error) {
32
+ function formatCauseChain(error, message) {
34
33
  const causeLines = [];
34
+ const seen = /* @__PURE__ */ new Set([error]);
35
35
  let current = error instanceof Error ? error.cause : void 0;
36
- while (current instanceof Error) {
37
- causeLines.push(`Caused by: ${current.name || "Error"}: ${current.message}`);
36
+ while (current instanceof Error && !seen.has(current)) {
37
+ seen.add(current);
38
+ if (current.message !== message) causeLines.push(`Caused by: ${current.name || "Error"}: ${current.message}`);
38
39
  current = current.cause;
39
40
  }
40
41
  return causeLines.join("\n");
@@ -1,5 +1,7 @@
1
- import { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs } from "./args.mjs";
1
+ import { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, EnumArgDef, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs } from "./args.mjs";
2
2
  import { ArgumentError, CliError, ErrorClass, ReportOptions, reportFailure } from "./errors.mjs";
3
3
  import { CommandContext, CommandDef, CommandMeta, RunMainOptions, defineCommand, runCommand, runMain } from "./command.mjs";
4
4
  import { log_d_exports } from "./log.mjs";
5
- export { type ArgDef, type ArgsDef, ArgumentError, type BooleanArgDef, CliError, type CommandContext, type CommandDef, type CommandMeta, type CommonArgs, type ErrorClass, type ParsedArgs, type PositionalArgDef, type ReportOptions, type RunMainOptions, type StringArgDef, commonArgs, defineCommand, log_d_exports as log, parseArgs, reportFailure, runCommand, runMain };
5
+ import { readStdin } from "./stdin.mjs";
6
+ import { Style, paint } from "./style.mjs";
7
+ export { type ArgDef, type ArgsDef, ArgumentError, type BooleanArgDef, CliError, type CommandContext, type CommandDef, type CommandMeta, type CommonArgs, type EnumArgDef, type ErrorClass, type ParsedArgs, type PositionalArgDef, type ReportOptions, type RunMainOptions, type StringArgDef, type Style, commonArgs, defineCommand, log_d_exports as log, paint, parseArgs, readStdin, reportFailure, runCommand, runMain };
@@ -1,5 +1,7 @@
1
+ import { paint } from "./style.mjs";
1
2
  import { log_exports } from "./log.mjs";
2
3
  import { ArgumentError, CliError, reportFailure } from "./errors.mjs";
3
4
  import { commonArgs, parseArgs } from "./args.mjs";
4
5
  import { defineCommand, runCommand, runMain } from "./command.mjs";
5
- export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, parseArgs, reportFailure, runCommand, runMain };
6
+ import { readStdin } from "./stdin.mjs";
7
+ export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, paint, parseArgs, readStdin, reportFailure, runCommand, runMain };
@@ -0,0 +1,4 @@
1
+ //#region src/cli/stdin.d.ts
2
+ declare function readStdin(): Promise<string>;
3
+ //#endregion
4
+ export { readStdin };
@@ -0,0 +1,8 @@
1
+ import process from "node:process";
2
+ import { text } from "node:stream/consumers";
3
+ //#region src/cli/stdin.ts
4
+ function readStdin() {
5
+ return text(process.stdin);
6
+ }
7
+ //#endregion
8
+ export { readStdin };
@@ -0,0 +1,6 @@
1
+ import { styleText } from "node:util";
2
+ //#region src/cli/style.d.ts
3
+ type Style = Parameters<typeof styleText>[0];
4
+ declare function paint(style: Style, text: string, stream: NodeJS.WriteStream): string;
5
+ //#endregion
6
+ export { Style, paint };
@@ -11,6 +11,7 @@ interface CliResult {
11
11
  }
12
12
  interface RunOptions {
13
13
  cwd?: string;
14
+ env?: Record<string, string | undefined>;
14
15
  }
15
16
  interface CliHarnessOptions extends Omit<RunMainOptions, "argv"> {
16
17
  /** Only `runCliProcess` needs it. */
@@ -24,6 +25,7 @@ interface CliHarness {
24
25
  declare function createCliHarness<T extends ArgsDef>(command: CommandDef<T>, { entry, ...runOptions }?: CliHarnessOptions): CliHarness;
25
26
  /** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
26
27
  declare function useTemporaryDirectories(prefix?: string): (files?: FileMap) => string;
28
+ /** Pipes `input` into stdin until the test finishes; the function it returns restores stdin earlier. */
27
29
  declare function mockStdin(input: string): () => void;
28
30
  //#endregion
29
31
  export { CliHarness, CliHarnessOptions, CliResult, FileMap, RunOptions, createCliHarness, mockStdin, useTemporaryDirectories };
@@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { Readable } from "node:stream";
8
- import { afterEach, vi } from "vitest";
8
+ import { afterEach, onTestFinished, vi } from "vitest";
9
9
  //#region src/cli/testing.ts
10
10
  var ProcessExitError = class extends Error {
11
11
  constructor(exitCode) {
@@ -20,6 +20,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
20
20
  const stderr = [];
21
21
  const previousExitCode = process.exitCode;
22
22
  const previousCwd = process.cwd();
23
+ const previousEnv = Object.fromEntries(Object.keys(options.env ?? {}).map((name) => [name, process.env[name]]));
23
24
  process.exitCode = void 0;
24
25
  const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
25
26
  stdout.push(String(chunk));
@@ -41,6 +42,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
41
42
  let exitCode;
42
43
  try {
43
44
  if (options.cwd !== void 0) process.chdir(options.cwd);
45
+ setEnv(options.env ?? {});
44
46
  await runMain(command, {
45
47
  ...runOptions,
46
48
  argv
@@ -51,6 +53,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
51
53
  exitCode = error.exitCode;
52
54
  } finally {
53
55
  process.chdir(previousCwd);
56
+ setEnv(previousEnv);
54
57
  process.exitCode = previousExitCode;
55
58
  exitSpy.mockRestore();
56
59
  consoleErrorSpy.mockRestore();
@@ -69,6 +72,10 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
69
72
  return new Promise((resolve, reject) => {
70
73
  execFile(process.execPath, [entry, ...argv], {
71
74
  cwd: options.cwd,
75
+ env: {
76
+ ...process.env,
77
+ ...options.env
78
+ },
72
79
  maxBuffer: 67108864
73
80
  }, (spawnError, stdout, stderr) => {
74
81
  if (spawnError && typeof spawnError.code !== "number") reject(spawnError);
@@ -82,6 +89,10 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
82
89
  }
83
90
  };
84
91
  }
92
+ function setEnv(env) {
93
+ for (const [name, value] of Object.entries(env)) if (value === void 0) delete process.env[name];
94
+ else process.env[name] = value;
95
+ }
85
96
  /** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
86
97
  function useTemporaryDirectories(prefix = "cli-test-") {
87
98
  const directories = [];
@@ -102,19 +113,22 @@ function useTemporaryDirectories(prefix = "cli-test-") {
102
113
  return directory;
103
114
  };
104
115
  }
116
+ /** Pipes `input` into stdin until the test finishes; the function it returns restores stdin earlier. */
105
117
  function mockStdin(input) {
106
118
  const stream = Readable.from([new TextEncoder().encode(input)]);
107
119
  const originalStdin = process.stdin;
108
- Object.defineProperty(process, "stdin", {
109
- value: stream,
110
- writable: true
111
- });
112
- return () => {
120
+ const restoreStdin = () => {
113
121
  Object.defineProperty(process, "stdin", {
114
122
  value: originalStdin,
115
123
  writable: true
116
124
  });
117
125
  };
126
+ onTestFinished(restoreStdin);
127
+ Object.defineProperty(process, "stdin", {
128
+ value: stream,
129
+ writable: true
130
+ });
131
+ return restoreStdin;
118
132
  }
119
133
  //#endregion
120
134
  export { createCliHarness, mockStdin, useTemporaryDirectories };
@@ -2,13 +2,10 @@ import { paint } from "./style.mjs";
2
2
  import { stripVTControlCharacters } from "node:util";
3
3
  import process from "node:process";
4
4
  //#region src/cli/usage.ts
5
- function renderUsage(command, { parent, stream = process.stdout } = {}) {
5
+ function renderUsage(command, { commandPath: commandName = command.meta?.name ?? "", version = command.meta?.version, stream = process.stdout } = {}) {
6
6
  const color = (style, text) => paint(style, text, stream);
7
7
  const heading = (title) => color("bold", title);
8
8
  const meta = command.meta ?? {};
9
- const parentMeta = parent?.meta ?? {};
10
- const commandName = [parentMeta.name, meta.name].filter((name) => name !== void 0).join(" ");
11
- const version = meta.version ?? parentMeta.version;
12
9
  const positionalLines = [];
13
10
  const optionLines = [];
14
11
  const commandLines = [];
@@ -28,7 +25,7 @@ function renderUsage(command, { parent, stream = process.stdout } = {}) {
28
25
  continue;
29
26
  }
30
27
  const spellings = [definition.alias === void 0 ? void 0 : `-${definition.alias}`, `--${name}`].filter((spelling) => spelling !== void 0).join(", ");
31
- const value = definition.type === "string" ? `<${definition.valueHint ?? name}>` : void 0;
28
+ const value = definition.type === "boolean" ? void 0 : `<${definition.valueHint ?? (definition.type === "enum" ? definition.options.join("|") : name)}>`;
32
29
  optionLines.push([color("cyan", spellings) + (value === void 0 ? "" : color("dim", `=${value}`)), hints]);
33
30
  if (definition.type === "boolean" && definition.default === true) optionLines.push([color("cyan", `--no-${name}`), ""]);
34
31
  if (isRequired) usageLine.push(`--${name}=${value}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "utilful",
3
3
  "type": "module",
4
- "version": "3.4.0",
4
+ "version": "3.5.0",
5
5
  "packageManager": "pnpm@11.25.0",
6
6
  "description": "A collection of TypeScript utilities",
7
7
  "author": "Johann Schopplich <hello@johannschopplich.com>",