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 +157 -24
- package/dist/_virtual/_rolldown/runtime.mjs +13 -0
- package/dist/cli/args.d.mts +44 -0
- package/dist/cli/args.mjs +109 -0
- package/dist/cli/command.d.mts +33 -0
- package/dist/cli/command.mjs +76 -0
- package/dist/cli/errors.d.mts +20 -0
- package/dist/cli/errors.mjs +43 -0
- package/dist/cli/index.d.mts +5 -0
- package/dist/cli/index.mjs +5 -0
- package/dist/cli/log.d.mts +10 -0
- package/dist/cli/log.mjs +28 -0
- package/dist/cli/style.mjs +7 -0
- package/dist/cli/testing.d.mts +29 -0
- package/dist/cli/testing.mjs +120 -0
- package/dist/cli/usage.mjs +65 -0
- package/dist/csv.d.mts +2 -2
- package/dist/csv.mjs +3 -3
- package/dist/emitter.d.mts +3 -3
- package/dist/emitter.mjs +0 -6
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/object.d.mts +7 -2
- package/dist/object.mjs +6 -1
- package/dist/path.d.mts +20 -3
- package/dist/path.mjs +73 -13
- package/dist/result.d.mts +2 -4
- package/dist/result.mjs +7 -5
- package/dist/types.d.mts +1 -3
- package/package.json +27 -1
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
|
|
25
|
+
npm install utilful
|
|
25
26
|
|
|
26
27
|
# pnpm
|
|
27
|
-
pnpm add
|
|
28
|
+
pnpm add utilful
|
|
28
29
|
|
|
29
30
|
# yarn
|
|
30
|
-
yarn add
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
469
|
+
Defers a computation until the value is first read, then caches it.
|
|
392
470
|
|
|
393
|
-
|
|
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
|
-
|
|
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>>(
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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')
|
|
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
|
-
|
|
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.
|
|
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 };
|