utilful 3.0.2 → 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)
@@ -21,15 +22,24 @@ A collection of TypeScript utilities that I use across my projects.
21
22
 
22
23
  ```bash
23
24
  # npm
24
- npm install -D utilful
25
+ npm install utilful
25
26
 
26
27
  # pnpm
27
- pnpm add -D utilful
28
+ pnpm add utilful
28
29
 
29
30
  # yarn
30
- yarn add -D utilful
31
+ yarn add utilful
31
32
  ```
32
33
 
34
+ Every module is also available on its own subpath, so you can import just the part you need:
35
+
36
+ ```ts
37
+ import { defu } from 'utilful' // Everything
38
+ import { joinURL } from 'utilful/path' // Just the path helpers
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
+
33
43
  ## API
34
44
 
35
45
  ### Array
@@ -44,6 +54,67 @@ type MaybeArray<T> = T | T[]
44
54
  declare function toArray<T>(array?: MaybeArray<T> | null | undefined): T[]
45
55
  ```
46
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
+
47
118
  ### CSV
48
119
 
49
120
  #### `createCSV`
@@ -130,7 +201,12 @@ declare function parseCSV<Header extends string>(
130
201
  ): CSVRow<Header>[]
131
202
  ```
132
203
 
133
- The parser accepts a few lenient deviations from RFC 4180: LF, CR, and CRLF line endings are all recognized, whitespace between a closing quote and the next delimiter or line break is ignored, and quotes inside unquoted fields are kept as literal characters (a field only counts as quoted if it starts with a quote).
204
+ The parser accepts a few lenient deviations from RFC 4180:
205
+
206
+ - LF, CR, and CRLF line endings are all recognized
207
+ - whitespace between a closing quote and the next delimiter or line break is ignored
208
+ - quotes inside unquoted fields are kept as literal characters, since a field only counts as quoted if it starts with a quote
209
+ - text following a closing quote is appended to the field rather than rejected, so `"ab" cd` parses as `abcd`
134
210
 
135
211
  **Example:**
136
212
 
@@ -148,6 +224,9 @@ const data = parseCSV<'name' | 'age'>(csv) // [{ name: 'John', age: '30' }, { na
148
224
 
149
225
  Creates a CSV stream from an iterable or async iterable of objects. Yields complete lines (header and/or data rows) including line endings – useful for large datasets that should not be buffered in memory.
150
226
 
227
+ > [!NOTE]
228
+ > Unlike `createCSV`, `columns` is required here. Inferring them would mean reading every row before writing the first one, which is exactly what streaming avoids.
229
+
151
230
  ```ts
152
231
  declare function createCSVStream<T extends Record<string, unknown>>(
153
232
  data: AsyncIterable<T> | Iterable<T>,
@@ -228,7 +307,7 @@ escapeCSVValue('contains "quotes"') // '"contains ""quotes"""'
228
307
 
229
308
  ### Defu
230
309
 
231
- Recursively assign default properties. Simplified version based on [unjs/defu](https://github.com/unjs/defu).
310
+ Fills in missing properties from a chain of defaults. A trimmed-down take on [unjs/defu](https://github.com/unjs/defu).
232
311
 
233
312
  #### `defu`
234
313
 
@@ -286,7 +365,7 @@ const result = defu(
286
365
 
287
366
  #### `createDefu`
288
367
 
289
- Creates a custom defu function with a custom merger.
368
+ Creates a `defu` variant that hands every property to your own merger first. Return `true` to signal you handled it; return nothing to fall back to the default behavior.
290
369
 
291
370
  ```ts
292
371
  type DefuMerger<T extends PlainObject = PlainObject> = (
@@ -325,8 +404,7 @@ Tiny functional event emitter / pubsub, based on [mitt](https://github.com/devel
325
404
  ```ts
326
405
  import { createEmitter } from 'utilful'
327
406
 
328
- // eslint-disable-next-line ts/consistent-type-definitions
329
- type Events = {
407
+ interface Events {
330
408
  foo: { a: string }
331
409
  }
332
410
 
@@ -388,12 +466,9 @@ async function loadModule() {
388
466
 
389
467
  #### `memoize`
390
468
 
391
- A simple general purpose memoizer utility.
469
+ Defers a computation until the value is first read, then caches it.
392
470
 
393
- - Lazily computes a value when accessed
394
- - Auto-caches the result by overwriting the getter
395
-
396
- Useful for deferring initialization or expensive operations. Unlike a simple getter, there is no runtime overhead after the first invocation, since the getter itself is overwritten with the memoized value.
471
+ Useful for expensive setup you may never need. Unlike a plain getter, there is no runtime cost after the first read, because the getter replaces itself with the computed value.
397
472
 
398
473
  ```ts
399
474
  declare function memoize<T>(getter: () => T): { value: T }
@@ -426,15 +501,20 @@ declare function objectEntries<T extends Record<any, any>>(obj: T): Array<[keyof
426
501
 
427
502
  #### `deepApply`
428
503
 
429
- Deeply applies a callback to every key-value pair in the given object, as well as nested objects and arrays (including arrays nested inside arrays).
504
+ Applies a callback to every key-value pair of the given object, and to every pair inside nested objects and arrays (including arrays nested inside arrays).
505
+
506
+ The callback also fires for nested objects, so `item` is whichever object the pair belongs to rather than the one you passed in.
430
507
 
431
508
  ```ts
432
- declare function deepApply<T extends Record<any, any>>(data: T, callback: (item: T, key: keyof T, value: T[keyof T]) => void): void
509
+ declare function deepApply<T extends Record<any, any>>(
510
+ data: T,
511
+ callback: (item: Record<string, any>, key: string, value: any) => void
512
+ ): void
433
513
  ```
434
514
 
435
515
  #### `isObject`
436
516
 
437
- Checks if a value is an object with the plain `[object Object]` tag. Returns `true` for object literals, class instances, and `null`-prototype objects.
517
+ Checks whether a value is an object. Object literals, class instances, and `null`-prototype objects all count; arrays, `Date`, `RegExp`, and `null` do not.
438
518
 
439
519
  ```ts
440
520
  declare function isObject(value: unknown): value is Record<any, any>
@@ -442,7 +522,7 @@ declare function isObject(value: unknown): value is Record<any, any>
442
522
 
443
523
  ### Path
444
524
 
445
- Utilities to build and normalize URL paths. All of them are also available from the `utilful/path` subpath export.
525
+ Utilities to build and normalize URL paths. They slice strings instead of parsing a full `URL`, which keeps them cheap enough for hot paths. Only `withQuery` and `getQuery` reach for `URLSearchParams`, where correct percent-encoding is worth the allocation.
446
526
 
447
527
  #### `withoutLeadingSlash` / `withLeadingSlash`
448
528
 
@@ -478,7 +558,7 @@ joinURL('/api/', '/users', '42') // '/api/users/42'
478
558
 
479
559
  #### `withBase` / `withoutBase`
480
560
 
481
- Adds or removes a base path – each is a no-op if the base is already present (or absent).
561
+ Adds or removes a base path – each is a no-op if the base is already present (or absent). An absolute URL is returned as it is, since no base path can prefix it.
482
562
 
483
563
  ```ts
484
564
  declare function withBase(input?: string, base?: string): string
@@ -489,20 +569,36 @@ declare function withoutBase(input?: string, base?: string): string
489
569
 
490
570
  ```ts
491
571
  withBase('/users', '/api') // '/api/users'
572
+ withBase('https://example.com/users', '/api') // 'https://example.com/users'
492
573
  withoutBase('/api/users', '/api') // '/users'
493
574
  ```
494
575
 
495
576
  #### `getPathname`
496
577
 
497
- Returns the pathname of the given path – everything before the query string or hash. Absolute URLs (with a scheme, e.g. `https://example.com/foo`) return the URL's pathname; all other inputs are returned unchanged with the query string and hash removed.
578
+ Returns the pathname of the given path – everything before the query string or hash. Absolute URLs return the part after the host, whether they carry a scheme (`https://example.com/foo`) or are protocol-relative (`//example.com/foo`).
579
+
580
+ The pathname is sliced out as written and never normalized, so percent-encoding and `..` segments survive:
498
581
 
499
582
  ```ts
500
583
  declare function getPathname(path?: string): string
501
584
  ```
502
585
 
586
+ ```ts
587
+ getPathname('/foo?bar#baz') // '/foo'
588
+ getPathname('https://example.com/foo') // '/foo'
589
+ getPathname('//example.com/foo') // '/foo'
590
+ getPathname('https://example.com') // '/'
591
+ getPathname('https://example.com/a/../b') // '/a/../b' – use `new URL` if you need this resolved
592
+ ```
593
+
503
594
  #### `withQuery`
504
595
 
505
- Returns the URL with the given query parameters merged in. `undefined` values remove the parameter, array values append one entry per item, and object values are JSON-stringified.
596
+ Returns the URL with the given query parameters merged in. A fragment stays where it belongs, at the very end.
597
+
598
+ - `undefined` removes the parameter
599
+ - `null` keeps the parameter with an empty value
600
+ - arrays append one entry per item, and empty arrays are skipped
601
+ - objects are JSON-stringified
506
602
 
507
603
  ```ts
508
604
  type QueryValue = string | number | boolean | QueryValue[] | Record<string, any> | null | undefined
@@ -515,6 +611,25 @@ declare function withQuery(input: string, query?: QueryObject): string
515
611
 
516
612
  ```ts
517
613
  withQuery('/api/users', { page: 2, tags: ['a', 'b'] }) // '/api/users?page=2&tags=a&tags=b'
614
+ withQuery('/api/users#list', { page: 2 }) // '/api/users?page=2#list'
615
+ withQuery('/api/users?page=2', { page: undefined }) // '/api/users'
616
+ ```
617
+
618
+ #### `getQuery`
619
+
620
+ Reads the query parameters back out of a URL, ignoring the fragment. A parameter that appears more than once becomes an array of its values.
621
+
622
+ ```ts
623
+ type ParsedQuery = Record<string, string | string[]>
624
+
625
+ declare function getQuery(input: string): ParsedQuery
626
+ ```
627
+
628
+ **Example:**
629
+
630
+ ```ts
631
+ getQuery('/api/users?page=2&tags=a&tags=b') // { page: '2', tags: ['a', 'b'] }
632
+ getQuery('/api/users') // {}
518
633
  ```
519
634
 
520
635
  ### Result
@@ -525,7 +640,7 @@ The `Result` type represents either success (`Ok`) or failure (`Err`). It provid
525
640
  type Result<T, E> = Ok<T, E> | Err<T, E>
526
641
  ```
527
642
 
528
- Both `Ok` and `Err` carry phantom types for proper type inference in unions.
643
+ Both variants carry the success *and* the error type, so the two stay in sync as you chain `map` and `mapError` calls.
529
644
 
530
645
  **Basic example:**
531
646
 
@@ -616,7 +731,7 @@ Chains a function that returns a `Result`. Useful for composing fallible operati
616
731
 
617
732
  ```ts
618
733
  ok(2).andThen(x => x > 0 ? ok(x) : err('negative')) // Ok(2)
619
- err('fail').andThen(x => ok(x * 2)) // Err('fail') - short-circuits
734
+ err('fail').andThen(x => ok(x * 2)) // Err('fail') – short-circuits
620
735
  ```
621
736
 
622
737
  #### `Result.unwrap`
@@ -629,6 +744,16 @@ err('fail').unwrap() // throws Error
629
744
  err('fail').unwrap('custom message') // throws Error('custom message')
630
745
  ```
631
746
 
747
+ #### `Result.unwrapErr`
748
+
749
+ Extracts the error, or throws if the result is `Ok`. The mirror image of `unwrap`.
750
+
751
+ ```ts
752
+ err('fail').unwrapErr() // 'fail'
753
+ ok(42).unwrapErr() // throws Error
754
+ ok(42).unwrapErr('custom message') // throws Error('custom message')
755
+ ```
756
+
632
757
  #### `Result.unwrapOr`
633
758
 
634
759
  Extracts the value or returns a fallback.
@@ -690,6 +815,9 @@ declare function tryCatch<T, E = unknown>(fn: () => T): { value: T, error: undef
690
815
  declare function tryCatch<T, E = unknown>(promise: Promise<T>): Promise<{ value: T, error: undefined } | { value: undefined, error: E }>
691
816
  ```
692
817
 
818
+ > [!NOTE]
819
+ > Like `toResult`, the function overload must be synchronous, and passing a function that returns a promise throws a `TypeError` rather than returning it as an error. Pass the promise itself.
820
+
693
821
  **Example:**
694
822
 
695
823
  ```ts
@@ -704,7 +832,9 @@ const { value, error } = await tryCatch(fetch('https://api.example.com').then(r
704
832
 
705
833
  #### `template`
706
834
 
707
- Simple template engine to replace variables in a string.
835
+ Replaces `{name}` placeholders in a string with the matching variable.
836
+
837
+ A placeholder with no matching variable is left as its own key, unless you pass a `fallback` – either a fixed string or a function receiving the key. A variable that is present but `null` or `undefined` is treated the same as a missing one. Only own properties are read, so `{constructor}` cannot reach prototype members.
708
838
 
709
839
  ```ts
710
840
  declare function template(
@@ -727,7 +857,10 @@ console.log(template(str, variables)) // Hello, world!
727
857
 
728
858
  #### `generateRandomId`
729
859
 
730
- Generates a random string. The function is ported from [`nanoid`](https://github.com/ai/nanoid). You can specify the size of the string and the dictionary of characters to use.
860
+ Generates a random string. Ported from [`nanoid`](https://github.com/ai/nanoid). You can specify the length and the dictionary of characters to draw from.
861
+
862
+ > [!WARNING]
863
+ > Backed by `Math.random()` and therefore not cryptographically secure. Use `crypto.randomUUID()` or `crypto.getRandomValues()` for session tokens, password resets, and anything else an attacker would like to guess.
731
864
 
732
865
  ```ts
733
866
  declare function generateRandomId(size?: number, dict?: string): string
@@ -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 };