utilful 3.1.0 → 3.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.
package/README.md CHANGED
@@ -7,6 +7,7 @@ A collection of TypeScript utilities that I use across my projects.
7
7
  - [Installation](#installation)
8
8
  - [API](#api)
9
9
  - [Array](#array)
10
+ - [CLI](#cli)
10
11
  - [CSV](#csv)
11
12
  - [Defu](#defu)
12
13
  - [Emitter](#emitter)
@@ -37,6 +38,8 @@ import { defu } from 'utilful' // Everything
37
38
  import { joinURL } from 'utilful/path' // Just the path helpers
38
39
  ```
39
40
 
41
+ The `cli` module is the exception: it is Node-only (22.13 or later) and lives on its subpaths `utilful/cli` and `utilful/cli/testing` alone.
42
+
40
43
  ## API
41
44
 
42
45
  ### Array
@@ -51,6 +54,67 @@ type MaybeArray<T> = T | T[]
51
54
  declare function toArray<T>(array?: MaybeArray<T> | null | undefined): T[]
52
55
  ```
53
56
 
57
+ ### CLI
58
+
59
+ 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
+
61
+ #### `defineCommand`
62
+
63
+ Types a command definition, so `run` receives its `args` typed by their definitions.
64
+
65
+ ```ts
66
+ import { CliError, commonArgs, defineCommand } from 'utilful/cli'
67
+
68
+ const build = defineCommand({
69
+ meta: { name: 'build', description: 'Compile the entry file' },
70
+ args: {
71
+ ...commonArgs, // Adds --verbose
72
+ 'file': { type: 'positional', description: 'Entry file', required: true }, // string
73
+ 'out-dir': { type: 'string', alias: 'd', description: 'Output directory', valueHint: 'path' }, // string | undefined
74
+ 'watch': { type: 'boolean', alias: 'w', description: 'Rebuild on change' }, // boolean, --no-watch turns it off
75
+ },
76
+ run({ args }) {
77
+ if (args.watch && args['out-dir'] === undefined)
78
+ throw new CliError('--watch needs an --out-dir') // Printed as a message, no stack
79
+ },
80
+ })
81
+ ```
82
+
83
+ #### `runMain`
84
+
85
+ Runs a command tree from `process.argv`, or from `argv` when given, and sets `process.exitCode` instead of exiting. A tree with a `run` of its own runs it when no sub-command is named.
86
+
87
+ ```ts
88
+ import { defineCommand, runMain } from 'utilful/cli'
89
+
90
+ const main = defineCommand({
91
+ meta: { name: 'tool', version: '1.0.0', description: 'Does things' },
92
+ subCommands: { build },
93
+ })
94
+
95
+ void runMain(main, { expectedErrors: [MyLibraryError] }) // Reported like a `CliError`
96
+ ```
97
+
98
+ 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.
99
+
100
+ #### `createCliHarness`
101
+
102
+ 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.
103
+
104
+ ```ts
105
+ import { createCliHarness, useTemporaryDirectories } from 'utilful/cli/testing'
106
+
107
+ const { runCli, runCliProcess } = createCliHarness(main, { entry: 'src/entry.ts' }) // `entry` only matters to `runCliProcess`
108
+ const createDirectory = useTemporaryDirectories()
109
+
110
+ it('rejects an unknown option', async () => {
111
+ const { stderr, exitCode } = await runCli(['build', 'x.js', '--typo'])
112
+
113
+ expect(stderr).toContain('Unknown option \'--typo\'')
114
+ expect(exitCode).toBe(1)
115
+ })
116
+ ```
117
+
54
118
  ### CSV
55
119
 
56
120
  #### `createCSV`
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll };
@@ -0,0 +1,44 @@
1
+ //#region src/cli/args.d.ts
2
+ interface StringArgDef {
3
+ type: "string";
4
+ /** One letter, as in `-d`. */
5
+ alias?: string;
6
+ description?: string;
7
+ default?: string;
8
+ required?: boolean;
9
+ /** Placeholder in the help, as in `--out-dir=<path>`. */
10
+ valueHint?: string;
11
+ }
12
+ interface BooleanArgDef {
13
+ type: "boolean";
14
+ alias?: string;
15
+ description?: string;
16
+ default?: boolean;
17
+ }
18
+ interface PositionalArgDef {
19
+ type: "positional";
20
+ description?: string;
21
+ required?: boolean;
22
+ }
23
+ type ArgDef = StringArgDef | BooleanArgDef | PositionalArgDef;
24
+ type ArgsDef = Record<string, ArgDef>;
25
+ type ParsedArgs<T extends ArgsDef = ArgsDef> = {
26
+ /** Every positional, bound by a definition or not. */
27
+ _: string[];
28
+ } & { [K in keyof T]: ArgDef extends T[K] ? string | boolean | undefined : T[K] extends {
29
+ type: "boolean";
30
+ } ? boolean : T[K] extends {
31
+ default: string;
32
+ } | {
33
+ required: true;
34
+ } ? string : string | undefined; };
35
+ interface CommonArgs extends ArgsDef {
36
+ verbose: BooleanArgDef;
37
+ }
38
+ declare const commonArgs: CommonArgs;
39
+ /** 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 }?: {
41
+ allowExtraPositionals?: boolean;
42
+ }): ParsedArgs<T>;
43
+ //#endregion
44
+ export { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs$1 as parseArgs };
@@ -0,0 +1,109 @@
1
+ import { ArgumentError } from "./errors.mjs";
2
+ import { parseArgs } from "node:util";
3
+ //#region src/cli/args.ts
4
+ const commonArgs = { verbose: {
5
+ type: "boolean",
6
+ description: "Print the cause chain and stack trace on failure"
7
+ } };
8
+ /** Parses `argv` against a definition. An absent boolean reads as `false`, and `--no-<name>` turns one off. */
9
+ function parseArgs$1(argv, argsDef, { allowExtraPositionals = false } = {}) {
10
+ let parseResult;
11
+ try {
12
+ parseResult = parseArgs({
13
+ args: joinNegativeValues(splitShortOptionValues(argv), argsDef),
14
+ options: toNodeOptions(argsDef),
15
+ strict: true,
16
+ allowPositionals: true,
17
+ allowNegative: true
18
+ });
19
+ } catch (error) {
20
+ if (isNodeArgumentError(error)) throw new ArgumentError(error.message.split(". ")[0]);
21
+ throw error;
22
+ }
23
+ const args = { _: parseResult.positionals };
24
+ const positionalNames = [];
25
+ for (const [name, definition] of Object.entries(argsDef)) {
26
+ if (definition.type === "positional") {
27
+ positionalNames.push(name);
28
+ continue;
29
+ }
30
+ const value = parseResult.values[name];
31
+ if (definition.type === "boolean") args[name] = value ?? false;
32
+ else if (value === void 0 && definition.required === true) throw new ArgumentError(`Missing required argument: --${name}`);
33
+ else args[name] = value;
34
+ }
35
+ positionalNames.forEach((name, index) => {
36
+ const definition = argsDef[name];
37
+ const value = parseResult.positionals[index];
38
+ if (value === void 0 && definition.required === true) throw new ArgumentError(`Missing required positional argument: ${name.toUpperCase()}`);
39
+ args[name] = value;
40
+ });
41
+ if (!allowExtraPositionals && parseResult.positionals.length > positionalNames.length) throw new ArgumentError(`Unexpected argument: ${JSON.stringify(parseResult.positionals[positionalNames.length])}`);
42
+ return args;
43
+ }
44
+ function toNodeOptions(argsDef) {
45
+ const options = {};
46
+ for (const [name, definition] of Object.entries(argsDef)) {
47
+ if (definition.type === "positional") continue;
48
+ const option = { type: definition.type };
49
+ if (definition.alias !== void 0) option.short = definition.alias;
50
+ if (definition.default !== void 0) option.default = definition.default;
51
+ options[name] = option;
52
+ }
53
+ return options;
54
+ }
55
+ /**
56
+ * Splits an inline value off a short option. Node's `parseArgs` splits
57
+ * `--name=value` but leaves `-n=value` whole, so `-o=report.json` would write to
58
+ * a file named `=report.json`. Past `--` every token is an operand and stays as
59
+ * it was written.
60
+ */
61
+ function splitShortOptionValues(argv) {
62
+ const splitArguments = [];
63
+ let isTerminated = false;
64
+ for (const argument of argv) {
65
+ const match = /^(-[^-])=(.*)$/.exec(argument);
66
+ if (isTerminated || match === null) {
67
+ splitArguments.push(argument);
68
+ isTerminated ||= argument === "--";
69
+ continue;
70
+ }
71
+ splitArguments.push(match[1], match[2]);
72
+ }
73
+ return splitArguments;
74
+ }
75
+ /**
76
+ * Joins a negative number onto the value-taking option before it, as
77
+ * `--start=-3`. Node refuses `--start -3` as ambiguous, but a token that starts
78
+ * with a digit after its dash is a number wherever a value is due.
79
+ */
80
+ function joinNegativeValues(argv, argsDef) {
81
+ const optionNamesBySpelling = /* @__PURE__ */ new Map();
82
+ for (const [name, definition] of Object.entries(argsDef)) {
83
+ if (definition.type !== "string") continue;
84
+ optionNamesBySpelling.set(`--${name}`, name);
85
+ if (definition.alias !== void 0) optionNamesBySpelling.set(`-${definition.alias}`, name);
86
+ }
87
+ const joinedArguments = [];
88
+ for (let index = 0; index < argv.length; index++) {
89
+ const argument = argv[index];
90
+ const next = argv[index + 1];
91
+ const name = optionNamesBySpelling.get(argument);
92
+ if (argument === "--") {
93
+ joinedArguments.push(...argv.slice(index));
94
+ break;
95
+ }
96
+ if (name !== void 0 && next !== void 0 && /^-\d/.test(next)) {
97
+ joinedArguments.push(`--${name}=${next}`);
98
+ index++;
99
+ continue;
100
+ }
101
+ joinedArguments.push(argument);
102
+ }
103
+ return joinedArguments;
104
+ }
105
+ function isNodeArgumentError(error) {
106
+ return error instanceof Error && String(error.code).startsWith("ERR_PARSE_ARGS");
107
+ }
108
+ //#endregion
109
+ export { commonArgs, parseArgs$1 as parseArgs, toNodeOptions };
@@ -0,0 +1,33 @@
1
+ import { ArgsDef, ParsedArgs } from "./args.mjs";
2
+ import { ReportOptions } from "./errors.mjs";
3
+ //#region src/cli/command.d.ts
4
+ interface CommandMeta {
5
+ name?: string;
6
+ version?: string;
7
+ description?: string;
8
+ }
9
+ interface CommandContext<T extends ArgsDef = ArgsDef> {
10
+ args: ParsedArgs<T>;
11
+ }
12
+ interface CommandDef<T extends ArgsDef = ArgsDef> {
13
+ meta?: CommandMeta;
14
+ args?: T;
15
+ subCommands?: Record<string, CommandDef<any>>;
16
+ allowExtraPositionals?: boolean;
17
+ run?: (context: CommandContext<T>) => unknown;
18
+ }
19
+ interface RunMainOptions extends Omit<ReportOptions, "verbose"> {
20
+ /** @default process.argv.slice(2) */
21
+ argv?: readonly string[];
22
+ }
23
+ declare function defineCommand<T extends ArgsDef>(command: CommandDef<T>): CommandDef<T>;
24
+ /**
25
+ * Runs the command tree and reports whatever it throws. Help someone asked for
26
+ * is the result of the run and goes to stdout; usage that follows a wrong
27
+ * argument is diagnostics and joins its message on stderr.
28
+ */
29
+ declare function runMain<T extends ArgsDef>(command: CommandDef<T>, options?: RunMainOptions): Promise<void>;
30
+ /** Runs like `runMain`, but without the error boundary. */
31
+ declare function runCommand<T extends ArgsDef>(command: CommandDef<T>, argv: readonly string[]): Promise<void>;
32
+ //#endregion
33
+ export { CommandContext, CommandDef, CommandMeta, RunMainOptions, defineCommand, runCommand, runMain };
@@ -0,0 +1,76 @@
1
+ import { ArgumentError, reportFailure } from "./errors.mjs";
2
+ import { parseArgs as parseArgs$1, toNodeOptions } from "./args.mjs";
3
+ import { renderUsage } from "./usage.mjs";
4
+ import { parseArgs } from "node:util";
5
+ import process from "node:process";
6
+ //#region src/cli/command.ts
7
+ const HELP_FLAGS = /* @__PURE__ */ new Set(["--help", "-h"]);
8
+ const VERSION_FLAGS = /* @__PURE__ */ new Set(["--version", "-v"]);
9
+ function defineCommand(command) {
10
+ return command;
11
+ }
12
+ /**
13
+ * Runs the command tree and reports whatever it throws. Help someone asked for
14
+ * is the result of the run and goes to stdout; usage that follows a wrong
15
+ * argument is diagnostics and joins its message on stderr.
16
+ */
17
+ async function runMain(command, options = {}) {
18
+ const argv = options.argv ?? process.argv.slice(2);
19
+ const beforeTerminator = argv.slice(0, argv.includes("--") ? argv.indexOf("--") : void 0);
20
+ const hasFlag = (flags) => beforeTerminator.some((argument) => flags.has(argument));
21
+ try {
22
+ if (hasFlag(HELP_FLAGS)) process.stdout.write(`${usageFor(command, argv, process.stdout)}\n`);
23
+ else if (hasFlag(VERSION_FLAGS)) process.stdout.write(`${command.meta?.version ?? ""}\n`);
24
+ else await runCommand(command, argv);
25
+ } catch (error) {
26
+ if (error instanceof ArgumentError) process.stderr.write(`${usageFor(command, argv, process.stderr)}\n\n`);
27
+ const verbose = !(error instanceof ArgumentError) && beforeTerminator.includes("--verbose");
28
+ reportFailure(error, {
29
+ ...options,
30
+ verbose
31
+ });
32
+ }
33
+ }
34
+ /** Runs like `runMain`, but without the error boundary. */
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 });
41
+ }
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,
46
+ stream
47
+ });
48
+ }
49
+ /**
50
+ * Finds the sub-command the first operand names, wherever it stands among the
51
+ * options. Telling an operand from the value of an option needs every
52
+ * value-taking option of every sub-command, since the command is still unknown.
53
+ */
54
+ function findSubCommand(command, argv) {
55
+ if (command.subCommands === void 0) return { rest: [...argv] };
56
+ const options = Object.assign(toNodeOptions(command.args ?? {}), ...Object.values(command.subCommands).map((subCommand) => toNodeOptions(subCommand.args ?? {})));
57
+ const { tokens } = parseArgs({
58
+ args: [...argv],
59
+ options,
60
+ strict: false,
61
+ allowPositionals: true,
62
+ tokens: true
63
+ });
64
+ const firstOperand = tokens.find((token) => token.kind === "positional" || token.kind === "option-terminator");
65
+ if (firstOperand?.kind !== "positional") return { rest: [...argv] };
66
+ if (Object.hasOwn(command.subCommands, firstOperand.value)) return {
67
+ name: firstOperand.value,
68
+ rest: argv.toSpliced(firstOperand.index, 1)
69
+ };
70
+ return {
71
+ firstOperand: firstOperand.value,
72
+ rest: [...argv]
73
+ };
74
+ }
75
+ //#endregion
76
+ export { defineCommand, runCommand, runMain };
@@ -0,0 +1,20 @@
1
+ //#region src/cli/errors.d.ts
2
+ type ErrorClass = abstract new (...args: never[]) => Error;
3
+ interface ReportOptions {
4
+ verbose?: boolean;
5
+ /** Treated like `CliError`: message only, no stack. */
6
+ expectedErrors?: readonly ErrorClass[];
7
+ /** Renders an error where its message alone will not do; `undefined` falls back to the message. */
8
+ describe?: (error: Error) => string | undefined;
9
+ }
10
+ /**
11
+ * A condition the CLI recognized and phrased for a human. Anything else
12
+ * reaching the boundary is a defect in the tool and prints its stack unasked.
13
+ */
14
+ declare class CliError extends Error {}
15
+ /** Gets usage printed alongside its message. */
16
+ declare class ArgumentError extends CliError {}
17
+ /** Reports a failure the way the boundary does, for one that outlives `run`, such as a watch rebuild. */
18
+ declare function reportFailure(error: unknown, { verbose, expectedErrors, describe }?: ReportOptions): void;
19
+ //#endregion
20
+ export { ArgumentError, CliError, ErrorClass, ReportOptions, reportFailure };
@@ -0,0 +1,43 @@
1
+ import { error } from "./log.mjs";
2
+ import process from "node:process";
3
+ //#region src/cli/errors.ts
4
+ /**
5
+ * A condition the CLI recognized and phrased for a human. Anything else
6
+ * reaching the boundary is a defect in the tool and prints its stack unasked.
7
+ */
8
+ var CliError = class extends Error {};
9
+ /** Gets usage printed alongside its message. */
10
+ var ArgumentError = class extends CliError {};
11
+ /** Reports a failure the way the boundary does, for one that outlives `run`, such as a watch rebuild. */
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
+ }
19
+ error(sections.join("\n\n"));
20
+ process.exitCode = 1;
21
+ }
22
+ /**
23
+ * Reports whether the CLI raised this error deliberately rather than tripping
24
+ * over it. A system error such as `ENOENT` reaches the boundary as the honest
25
+ * answer to what the user asked for, so it reads as deliberate too, while an
26
+ * `ERR_*` error from a Node API is a wrong call and stays a defect.
27
+ */
28
+ function isExpected(error, expectedErrors) {
29
+ if (error instanceof CliError) return true;
30
+ if (expectedErrors.some((expectedError) => error instanceof expectedError)) return true;
31
+ return error instanceof Error && /^E[A-Z0-9]+$/.test(String(error.code));
32
+ }
33
+ function formatCauseChain(error) {
34
+ const causeLines = [];
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}`);
38
+ current = current.cause;
39
+ }
40
+ return causeLines.join("\n");
41
+ }
42
+ //#endregion
43
+ export { ArgumentError, CliError, reportFailure };
@@ -0,0 +1,5 @@
1
+ import { ArgDef, ArgsDef, BooleanArgDef, CommonArgs, ParsedArgs, PositionalArgDef, StringArgDef, commonArgs, parseArgs } from "./args.mjs";
2
+ import { ArgumentError, CliError, ErrorClass, ReportOptions, reportFailure } from "./errors.mjs";
3
+ import { CommandContext, CommandDef, CommandMeta, RunMainOptions, defineCommand, runCommand, runMain } from "./command.mjs";
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 };
@@ -0,0 +1,5 @@
1
+ import { log_exports } from "./log.mjs";
2
+ import { ArgumentError, CliError, reportFailure } from "./errors.mjs";
3
+ import { commonArgs, parseArgs } from "./args.mjs";
4
+ import { defineCommand, runCommand, runMain } from "./command.mjs";
5
+ export { ArgumentError, CliError, commonArgs, defineCommand, log_exports as log, parseArgs, reportFailure, runCommand, runMain };
@@ -0,0 +1,10 @@
1
+ declare namespace log_d_exports {
2
+ export { blankLine, error, info, success, warn };
3
+ }
4
+ declare function error(message: string): void;
5
+ declare function warn(message: string): void;
6
+ declare function info(message: string): void;
7
+ declare function success(message: string): void;
8
+ declare function blankLine(): void;
9
+ //#endregion
10
+ export { blankLine, error, info, log_d_exports, success, warn };
@@ -0,0 +1,28 @@
1
+ import { __exportAll } from "../_virtual/_rolldown/runtime.mjs";
2
+ import { paint } from "./style.mjs";
3
+ import process from "node:process";
4
+ //#region src/cli/log.ts
5
+ var log_exports = /* @__PURE__ */ __exportAll({
6
+ blankLine: () => blankLine,
7
+ error: () => error,
8
+ info: () => info,
9
+ success: () => success,
10
+ warn: () => warn
11
+ });
12
+ function error(message) {
13
+ console.error(`${paint("red", "✖", process.stderr)} ${message}`);
14
+ }
15
+ function warn(message) {
16
+ console.error(`${paint("yellow", "⚠", process.stderr)} ${message}`);
17
+ }
18
+ function info(message) {
19
+ console.error(`${paint("cyan", "●", process.stderr)} ${message}`);
20
+ }
21
+ function success(message) {
22
+ console.error(`${paint("green", "✔", process.stderr)} ${message}`);
23
+ }
24
+ function blankLine() {
25
+ console.error("");
26
+ }
27
+ //#endregion
28
+ export { blankLine, error, info, log_exports, success, warn };
@@ -0,0 +1,7 @@
1
+ import { styleText } from "node:util";
2
+ //#region src/cli/style.ts
3
+ function paint(style, text, stream) {
4
+ return styleText(style, text, { stream });
5
+ }
6
+ //#endregion
7
+ export { paint };
@@ -0,0 +1,29 @@
1
+ import { ArgsDef } from "./args.mjs";
2
+ import { CommandDef, RunMainOptions } from "./command.mjs";
3
+ //#region src/cli/testing.d.ts
4
+ interface FileMap {
5
+ [relativePath: string]: string;
6
+ }
7
+ interface CliResult {
8
+ stdout: string;
9
+ stderr: string;
10
+ exitCode: number;
11
+ }
12
+ interface RunOptions {
13
+ cwd?: string;
14
+ }
15
+ interface CliHarnessOptions extends Omit<RunMainOptions, "argv"> {
16
+ /** Only `runCliProcess` needs it. */
17
+ entry?: string;
18
+ }
19
+ interface CliHarness {
20
+ runCli: (argv: readonly string[], options?: RunOptions) => Promise<CliResult>;
21
+ /** Runs the entry file as a child process, where the exit code is real and stdout can be cut short by exiting. */
22
+ runCliProcess: (argv: readonly string[], options?: RunOptions) => Promise<CliResult>;
23
+ }
24
+ declare function createCliHarness<T extends ArgsDef>(command: CommandDef<T>, { entry, ...runOptions }?: CliHarnessOptions): CliHarness;
25
+ /** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
26
+ declare function useTemporaryDirectories(prefix?: string): (files?: FileMap) => string;
27
+ declare function mockStdin(input: string): () => void;
28
+ //#endregion
29
+ export { CliHarness, CliHarnessOptions, CliResult, FileMap, RunOptions, createCliHarness, mockStdin, useTemporaryDirectories };
@@ -0,0 +1,120 @@
1
+ import { runMain } from "./command.mjs";
2
+ import process from "node:process";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import { afterEach, vi } from "vitest";
9
+ //#region src/cli/testing.ts
10
+ var ProcessExitError = class extends Error {
11
+ constructor(exitCode) {
12
+ super(`process.exit(${exitCode})`);
13
+ this.exitCode = exitCode;
14
+ }
15
+ };
16
+ function createCliHarness(command, { entry, ...runOptions } = {}) {
17
+ return {
18
+ async runCli(argv, options = {}) {
19
+ const stdout = [];
20
+ const stderr = [];
21
+ const previousExitCode = process.exitCode;
22
+ const previousCwd = process.cwd();
23
+ process.exitCode = void 0;
24
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
25
+ stdout.push(String(chunk));
26
+ return true;
27
+ });
28
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
29
+ stderr.push(String(chunk));
30
+ return true;
31
+ });
32
+ const consoleLogSpy = vi.spyOn(console, "log").mockImplementation((...parts) => {
33
+ stdout.push(`${parts.map(String).join(" ")}\n`);
34
+ });
35
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((...parts) => {
36
+ stderr.push(`${parts.map(String).join(" ")}\n`);
37
+ });
38
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
39
+ throw new ProcessExitError(typeof code === "number" ? code : 0);
40
+ });
41
+ let exitCode;
42
+ try {
43
+ if (options.cwd !== void 0) process.chdir(options.cwd);
44
+ await runMain(command, {
45
+ ...runOptions,
46
+ argv
47
+ });
48
+ exitCode = process.exitCode ?? 0;
49
+ } catch (error) {
50
+ if (!(error instanceof ProcessExitError)) throw error;
51
+ exitCode = error.exitCode;
52
+ } finally {
53
+ process.chdir(previousCwd);
54
+ process.exitCode = previousExitCode;
55
+ exitSpy.mockRestore();
56
+ consoleErrorSpy.mockRestore();
57
+ consoleLogSpy.mockRestore();
58
+ stderrSpy.mockRestore();
59
+ stdoutSpy.mockRestore();
60
+ }
61
+ return {
62
+ stdout: stdout.join(""),
63
+ stderr: stderr.join(""),
64
+ exitCode
65
+ };
66
+ },
67
+ runCliProcess(argv, options = {}) {
68
+ if (entry === void 0) return Promise.reject(/* @__PURE__ */ new Error("runCliProcess needs the entry file of the CLI"));
69
+ return new Promise((resolve, reject) => {
70
+ execFile(process.execPath, [entry, ...argv], {
71
+ cwd: options.cwd,
72
+ maxBuffer: 64 * 1024 * 1024
73
+ }, (spawnError, stdout, stderr) => {
74
+ if (spawnError && typeof spawnError.code !== "number") reject(spawnError);
75
+ else resolve({
76
+ stdout,
77
+ stderr,
78
+ exitCode: spawnError ? spawnError.code : 0
79
+ });
80
+ });
81
+ });
82
+ }
83
+ };
84
+ }
85
+ /** Registers an `afterEach` cleanup, so call it at the top level of a test file. */
86
+ function useTemporaryDirectories(prefix = "cli-test-") {
87
+ const directories = [];
88
+ afterEach(() => {
89
+ while (directories.length > 0) rmSync(directories.pop(), {
90
+ recursive: true,
91
+ force: true
92
+ });
93
+ });
94
+ return (files = {}) => {
95
+ const directory = mkdtempSync(path.join(os.tmpdir(), prefix));
96
+ directories.push(directory);
97
+ for (const [relativePath, contents] of Object.entries(files)) {
98
+ const filePath = path.join(directory, relativePath);
99
+ mkdirSync(path.dirname(filePath), { recursive: true });
100
+ writeFileSync(filePath, contents, "utf-8");
101
+ }
102
+ return directory;
103
+ };
104
+ }
105
+ function mockStdin(input) {
106
+ const stream = Readable.from([new TextEncoder().encode(input)]);
107
+ const originalStdin = process.stdin;
108
+ Object.defineProperty(process, "stdin", {
109
+ value: stream,
110
+ writable: true
111
+ });
112
+ return () => {
113
+ Object.defineProperty(process, "stdin", {
114
+ value: originalStdin,
115
+ writable: true
116
+ });
117
+ };
118
+ }
119
+ //#endregion
120
+ export { createCliHarness, mockStdin, useTemporaryDirectories };
@@ -0,0 +1,65 @@
1
+ import { paint } from "./style.mjs";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import process from "node:process";
4
+ //#region src/cli/usage.ts
5
+ function renderUsage(command, { parent, stream = process.stdout } = {}) {
6
+ const color = (style, text) => paint(style, text, stream);
7
+ const heading = (title) => color(["bold", "underline"], title);
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
+ const positionalLines = [];
13
+ const optionLines = [];
14
+ const commandLines = [];
15
+ const usageLine = [];
16
+ for (const [name, definition] of Object.entries(command.args ?? {})) {
17
+ const isRequired = definition.type !== "boolean" && definition.required === true;
18
+ const hasDefault = definition.type !== "positional" && definition.default !== void 0 && definition.default !== false;
19
+ const hints = [
20
+ definition.description,
21
+ isRequired ? color("gray", "(Required)") : void 0,
22
+ hasDefault ? color("gray", `(Default: ${definition.default})`) : void 0
23
+ ].filter((hint) => hint !== void 0).join(" ");
24
+ if (definition.type === "positional") {
25
+ const label = name.toUpperCase();
26
+ positionalLines.push([color("cyan", label), hints]);
27
+ usageLine.push(isRequired ? `<${label}>` : `[${label}]`);
28
+ continue;
29
+ }
30
+ 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;
32
+ optionLines.push([color("cyan", value === void 0 ? spellings : `${spellings}=${value}`), hints]);
33
+ if (definition.type === "boolean" && definition.default === true) optionLines.push([color("cyan", `--no-${name}`), ""]);
34
+ if (isRequired) usageLine.push(`--${name}=${value}`);
35
+ }
36
+ for (const [name, subCommand] of Object.entries(command.subCommands ?? {})) commandLines.push([color("cyan", name), subCommand.meta?.description ?? ""]);
37
+ const hasArguments = positionalLines.length > 0 || optionLines.length > 0;
38
+ if (commandLines.length > 0 && !hasArguments) usageLine.push(Object.keys(command.subCommands).join("|"));
39
+ const lines = [
40
+ color("gray", `${meta.description ?? ""} (${commandName}${version === void 0 ? "" : ` v${version}`})`),
41
+ "",
42
+ `${heading("USAGE")} ${color("cyan", [
43
+ commandName,
44
+ hasArguments ? "[OPTIONS]" : void 0,
45
+ ...usageLine
46
+ ].filter((part) => part !== void 0).join(" "))}`,
47
+ ""
48
+ ];
49
+ for (const [title, rows] of [
50
+ ["ARGUMENTS", positionalLines],
51
+ ["OPTIONS", optionLines],
52
+ ["COMMANDS", commandLines]
53
+ ]) if (rows.length > 0) lines.push(heading(title), "", formatColumns(rows), "");
54
+ if (commandLines.length > 0) lines.push(`Use ${color("cyan", `${commandName} <command> --help`)} for more information about a command.`);
55
+ return lines.join("\n").trimEnd();
56
+ }
57
+ function formatColumns(rows) {
58
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => visibleLength(row[column]))));
59
+ return rows.map((row) => row.map((cell, column) => ` ${cell}${" ".repeat(widths[column] - visibleLength(cell))}`).join("").trimEnd()).join("\n");
60
+ }
61
+ function visibleLength(text) {
62
+ return stripVTControlCharacters(text).length;
63
+ }
64
+ //#endregion
65
+ export { renderUsage };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "utilful",
3
3
  "type": "module",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "packageManager": "pnpm@11.15.1",
6
6
  "description": "A collection of TypeScript utilities",
7
7
  "author": "Johann Schopplich <hello@johannschopplich.com>",
@@ -15,6 +15,7 @@
15
15
  "url": "https://github.com/johannschopplich/utilful/issues"
16
16
  },
17
17
  "keywords": [
18
+ "cli",
18
19
  "csv",
19
20
  "defu",
20
21
  "emitter",
@@ -33,6 +34,14 @@
33
34
  "types": "./dist/array.d.mts",
34
35
  "default": "./dist/array.mjs"
35
36
  },
37
+ "./cli": {
38
+ "types": "./dist/cli/index.d.mts",
39
+ "default": "./dist/cli/index.mjs"
40
+ },
41
+ "./cli/testing": {
42
+ "types": "./dist/cli/testing.d.mts",
43
+ "default": "./dist/cli/testing.mjs"
44
+ },
36
45
  "./csv": {
37
46
  "types": "./dist/csv.d.mts",
38
47
  "default": "./dist/csv.mjs"
@@ -86,6 +95,14 @@
86
95
  "test:types": "tsc --noEmit",
87
96
  "release": "bumpp"
88
97
  },
98
+ "peerDependencies": {
99
+ "vitest": ">=4"
100
+ },
101
+ "peerDependenciesMeta": {
102
+ "vitest": {
103
+ "optional": true
104
+ }
105
+ },
89
106
  "devDependencies": {
90
107
  "@antfu/eslint-config": "^9.1.0",
91
108
  "@types/node": "^26.1.1",