cli-kiss 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/Type.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { statSync } from "fs";
1
2
  import {
2
3
  TypoError,
3
4
  TypoString,
@@ -8,21 +9,21 @@ import {
8
9
 
9
10
  /**
10
11
  * Decodes a raw CLI string into a typed value.
11
- * A pair of a human-readable `content` name (e.g. `"Number"`) and a `decoder` function.
12
+ * A pair of a human-readable `content` name and a `decoder` function.
12
13
  *
13
- * Built-in: {@link typeString}, {@link typeBoolean}, {@link typeNumber},
14
- * {@link typeInteger}, {@link typeDate}, {@link typeUrl}.
15
- * Composite: {@link typeOneOf}, {@link typeMapped}, {@link typeTuple}, {@link typeList}.
14
+ * Built-in: {@link type}, {@link typeBoolean}, {@link typeNumber},
15
+ * {@link typeInteger}, {@link typeDatetime}, {@link typeUrl}.
16
+ * Composite: {@link typeChoice}, {@link typeConverted}, {@link typeTuple}, {@link typeList}.
16
17
  *
17
18
  * @typeParam Value - Type produced by the decoder.
18
19
  */
19
20
  export type Type<Value> = {
20
21
  /**
21
- * Human-readable name shown in help and error messages (e.g. `"String"`, `"Number"`).
22
+ * Human-readable name shown in help and errors (e.g. `"name"`, `"number"`).
22
23
  */
23
24
  content: string;
24
25
  /**
25
- * Converts a raw CLI string into `Value`.
26
+ * Decodes a raw CLI string into `Value`.
26
27
  *
27
28
  * @param input - Raw string from the command line.
28
29
  * @returns The decoded value.
@@ -32,200 +33,211 @@ export type Type<Value> = {
32
33
  };
33
34
 
34
35
  /**
35
- * Decodes `"true"` / `"yes"` `true` and `"false"` / `"no"` → `false` (case-insensitive).
36
- * Used internally by {@link optionFlag} for the `--flag=<value>` syntax.
36
+ * Decodes a string to `boolean` (case-insensitive).
37
+ * Used by {@link optionFlag} for `--flag=<value>`.
37
38
  *
38
39
  * @example
39
40
  * ```ts
40
- * typeBoolean.decoder("yes") // → true
41
- * typeBoolean.decoder("false") // → false
42
- * typeBoolean.decoder("1") // throws TypoError
41
+ * typeBoolean("flag").decoder("true") // → true
42
+ * typeBoolean("flag").decoder("yes") // → true
43
+ * typeBoolean("flag").decoder("y") // true
44
+ * typeBoolean("flag").decoder("false") // → false
45
+ * typeBoolean("flag").decoder("no") // → false
46
+ * typeBoolean("flag").decoder("n") // → false
43
47
  * ```
44
48
  */
45
- export const typeBoolean: Type<boolean> = {
46
- content: "Boolean",
47
- decoder(input: string) {
48
- const lower = input.toLowerCase();
49
- if (lower === "true" || lower === "yes") {
50
- return true;
51
- }
52
- if (lower === "false" || lower === "no") {
53
- return false;
54
- }
55
- throw new TypoError(
56
- new TypoText(
57
- new TypoString(`Invalid value: `),
58
- new TypoString(`"${input}"`, typoStyleQuote),
59
- ),
60
- );
61
- },
62
- };
49
+ export function typeBoolean(name?: string): Type<boolean> {
50
+ return {
51
+ content: name ?? "boolean",
52
+ decoder(input: string) {
53
+ const lower = input.toLowerCase();
54
+ if (booleanValuesTrue.has(lower)) {
55
+ return true;
56
+ }
57
+ if (booleanValuesFalse.has(lower)) {
58
+ return false;
59
+ }
60
+ throw new TypoError(
61
+ new TypoText(
62
+ new TypoString(`Not a boolean: `),
63
+ new TypoString(`"${input}"`, typoStyleQuote),
64
+ ),
65
+ );
66
+ },
67
+ };
68
+ }
69
+ const booleanValuesTrue = new Set(["true", "yes", "on", "1", "y", "t"]);
70
+ const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
63
71
 
64
72
  /**
65
- * Parses a date/time string via `Date.parse` into a `Date` object.
73
+ * Parses a date/time string via `Date.parse`.
66
74
  * Accepts any format supported by `Date.parse`, including ISO 8601.
67
75
  *
68
76
  * @example
69
77
  * ```ts
70
- * typeDate.decoder("2024-01-15") // → Date object for 2024-01-15
71
- * typeDate.decoder("2024-01-15T13:45:30Z") // → Date object for 2024-01-15 13:45:30 UTC
72
- * typeDate.decoder("not a date") // throws TypoError
78
+ * typeDatetime("my-datetime").decoder("2024-01-15") // → Date object for 2024-01-15
79
+ * typeDatetime("my-datetime").decoder("2024-01-15T13:45:30Z") // → Date object for 2024-01-15 13:45:30 UTC
80
+ * typeDatetime("my-datetime").decoder("not a date") // throws TypoError
73
81
  * ```
74
82
  */
75
- export const typeDate: Type<Date> = {
76
- content: "Date",
77
- decoder(input: string) {
78
- try {
79
- const timestampMs = Date.parse(input);
80
- if (isNaN(timestampMs)) {
81
- throw new Error();
83
+ export function typeDatetime(name?: string): Type<Date> {
84
+ return {
85
+ content: name ?? "datetime",
86
+ decoder(input: string) {
87
+ try {
88
+ const timestampMs = Date.parse(input);
89
+ if (isNaN(timestampMs)) {
90
+ throw new Error();
91
+ }
92
+ return new Date(timestampMs);
93
+ } catch {
94
+ throw new TypoError(
95
+ new TypoText(
96
+ new TypoString(`Not a valid ISO_8601 datetime: `),
97
+ new TypoString(`"${input}"`, typoStyleQuote),
98
+ ),
99
+ );
82
100
  }
83
- return new Date(timestampMs);
84
- } catch {
85
- throw new TypoError(
86
- new TypoText(
87
- new TypoString(`Not a valid ISO_8601: `),
88
- new TypoString(`"${input}"`, typoStyleQuote),
89
- ),
90
- );
91
- }
92
- },
93
- };
101
+ },
102
+ };
103
+ }
94
104
 
95
105
  /**
96
- * Parses a string into a `number` via `Number()`.
97
- * Accepts integers, floats, and scientific notation; `NaN` throws a {@link TypoError}.
106
+ * Parses a string to `number` via `Number()`; `NaN` throws {@link TypoError}.
98
107
  *
99
108
  * @example
100
109
  * ```ts
101
- * typeNumber.decoder("3.14") // → 3.14
102
- * typeNumber.decoder("-1") // → -1
103
- * typeNumber.decoder("hello") // throws TypoError
110
+ * typeNumber("my-number").decoder("3.14") // → 3.14
111
+ * typeNumber("my-number").decoder("-1") // → -1
112
+ * typeNumber("my-number").decoder("hello") // throws TypoError
104
113
  * ```
105
114
  */
106
- export const typeNumber: Type<number> = {
107
- content: "Number",
108
- decoder(input: string) {
109
- try {
110
- const parsed = Number(input);
111
- if (isNaN(parsed)) {
112
- throw new Error();
115
+ export function typeNumber(name?: string): Type<number> {
116
+ return {
117
+ content: name ?? "number",
118
+ decoder(input: string) {
119
+ try {
120
+ const parsed = Number(input);
121
+ if (isNaN(parsed)) {
122
+ throw new Error();
123
+ }
124
+ return parsed;
125
+ } catch {
126
+ throw new TypoError(
127
+ new TypoText(
128
+ new TypoString(`Not a number: `),
129
+ new TypoString(`"${input}"`, typoStyleQuote),
130
+ ),
131
+ );
113
132
  }
114
- return parsed;
115
- } catch {
116
- throw new TypoError(
117
- new TypoText(
118
- new TypoString(`Unable to parse: `),
119
- new TypoString(`"${input}"`, typoStyleQuote),
120
- ),
121
- );
122
- }
123
- },
124
- };
133
+ },
134
+ };
135
+ }
125
136
 
126
137
  /**
127
- * Parses an integer string into a `bigint` via `BigInt()`.
128
- * Floats and non-numeric strings throw a {@link TypoError}.
138
+ * Parses an integer string to `bigint` via `BigInt()`.
139
+ * Floats and non-numeric strings throw {@link TypoError}.
129
140
  *
130
141
  * @example
131
142
  * ```ts
132
- * typeInteger.decoder("42") // → 42n
133
- * typeInteger.decoder("3.14") // throws TypoError
134
- * typeInteger.decoder("abc") // throws TypoError
143
+ * typeInteger("my-integer").decoder("42") // → 42n
144
+ * typeInteger("my-integer").decoder("3.14") // throws TypoError
145
+ * typeInteger("my-integer").decoder("abc") // throws TypoError
135
146
  * ```
136
147
  */
137
- export const typeInteger: Type<bigint> = {
138
- content: "Integer",
139
- decoder(input: string) {
140
- try {
141
- return BigInt(input);
142
- } catch {
143
- throw new TypoError(
144
- new TypoText(
145
- new TypoString(`Unable to parse: `),
146
- new TypoString(`"${input}"`, typoStyleQuote),
147
- ),
148
- );
149
- }
150
- },
151
- };
148
+ export function typeInteger(name?: string): Type<bigint> {
149
+ return {
150
+ content: name ?? "integer",
151
+ decoder(input: string) {
152
+ try {
153
+ return BigInt(input);
154
+ } catch {
155
+ throw new TypoError(
156
+ new TypoText(
157
+ new TypoString(`Not an integer: `),
158
+ new TypoString(`"${input}"`, typoStyleQuote),
159
+ ),
160
+ );
161
+ }
162
+ },
163
+ };
164
+ }
152
165
 
153
166
  /**
154
- * Parses an absolute URL string into a `URL` object.
155
- * Relative or malformed URLs throw a {@link TypoError}.
167
+ * Parses an absolute URL string to a `URL` object.
168
+ * Relative or malformed URLs throw {@link TypoError}.
156
169
  *
157
170
  * @example
158
171
  * ```ts
159
- * typeUrl.decoder("https://example.com") // → URL { href: "https://example.com/", ... }
160
- * typeUrl.decoder("not-a-url") // throws TypoError
172
+ * typeUrl("my-url").decoder("https://example.com") // → URL { href: "https://example.com/", ... }
173
+ * typeUrl("my-url").decoder("not-a-url") // throws TypoError
161
174
  * ```
162
175
  */
163
- export const typeUrl: Type<URL> = {
164
- content: "Url",
165
- decoder(input: string) {
166
- try {
167
- return new URL(input);
168
- } catch {
169
- throw new TypoError(
170
- new TypoText(
171
- new TypoString(`Unable to parse: `),
172
- new TypoString(`"${input}"`, typoStyleQuote),
173
- ),
174
- );
175
- }
176
- },
177
- };
176
+ export function typeUrl(name?: string): Type<URL> {
177
+ return {
178
+ content: name ?? "url",
179
+ decoder(input: string) {
180
+ try {
181
+ return new URL(input);
182
+ } catch {
183
+ throw new TypoError(
184
+ new TypoText(
185
+ new TypoString(`Not an URL: `),
186
+ new TypoString(`"${input}"`, typoStyleQuote),
187
+ ),
188
+ );
189
+ }
190
+ },
191
+ };
192
+ }
178
193
 
179
194
  /**
180
- * Identity decoder passes the raw string through unchanged.
181
- *
195
+ * A named type that accepts any string as input.
196
+ * @param name - Name shown in help and errors (e.g. `"my-value"`).
182
197
  * @example
183
198
  * ```ts
184
- * typeString.decoder("hello") // → "hello"
185
- * typeString.decoder("") // → ""
199
+ * type("greeting").decoder("hello") // → "hello"
200
+ * type("greeting").decoder("") // → ""
186
201
  * ```
187
202
  */
188
- export const typeString: Type<string> = {
189
- content: "String",
190
- decoder(input: string) {
191
- return input;
192
- },
193
- };
203
+ export function type(name?: string): Type<string> {
204
+ return {
205
+ content: name ?? "string",
206
+ decoder: (input: string) => input,
207
+ };
208
+ }
194
209
 
195
210
  /**
196
- * Creates a {@link Type} by chaining `before`'s decoder with an `after` transformation.
211
+ * Chains `before`'s decoder with an `after` transformation.
197
212
  * `before` errors are prefixed with `"from: <content>"` for traceability.
198
213
  *
199
214
  * @typeParam Before - Intermediate type from `before.decoder`.
200
215
  * @typeParam After - Final type from `after.decoder`.
201
216
  *
202
- * @param before - Base decoder for the raw string.
203
- * @param after - Transformation applied to the decoded value.
204
- * @param after.content - Name for the resulting type (shown in errors).
205
- * @param after.decoder - Converts a `Before` value to `After`.
217
+ * @param name - Name shown in help and errors (e.g. `"my-value"`).
218
+ * @param before - Base type to decode the raw string.
219
+ * @param mapper - Transforms `before`'s output to the final value; errors are wrapped with context.
206
220
  * @returns A {@link Type}`<After>`.
207
221
  *
208
222
  * @example
209
223
  * ```ts
210
- * const typePort = typeMapped(typeNumber, {
211
- * content: "Port",
212
- * decoder: (n) => {
213
- * if (n < 1 || n > 65535) throw new Error("Out of range");
214
- * return n;
215
- * },
224
+ * const typePort = typeConverted("port", typeNumber(), (n) => {
225
+ * if (n < 1 || n > 65535) throw new Error("Out of range");
226
+ * return n;
216
227
  * });
217
228
  * // "--port 8080" → 8080
218
229
  * // "--port 99999" → TypoError: --port: <PORT>: Port: Out of range
219
230
  * ```
220
231
  */
221
- export function typeMapped<Before, After>(
232
+ export function typeConverted<Before, After>(
233
+ name: string,
222
234
  before: Type<Before>,
223
- after: { content: string; decoder: (value: Before) => After },
235
+ mapper: (value: Before) => After,
224
236
  ): Type<After> {
225
237
  return {
226
- content: after.content,
238
+ content: name,
227
239
  decoder: (input: string) => {
228
- return after.decoder(
240
+ return mapper(
229
241
  TypoError.tryWithContext(
230
242
  () => before.decoder(input),
231
243
  () =>
@@ -240,31 +252,109 @@ export function typeMapped<Before, After>(
240
252
  }
241
253
 
242
254
  /**
243
- * Creates a {@link Type}`<string>` accepting only a fixed set of string values.
244
- * Out-of-set inputs throw a {@link TypoError} listing up to 5 valid options.
255
+ * Adds a name to a {@link Type} for clearer error messages and help text.
245
256
  *
246
- * @param content - Name shown in help and errors (e.g. `"Environment"`).
257
+ * @param name - Name to use for the type.
258
+ * @param type - Base type to name.
259
+ * @returns A {@link Type} with the given name.
260
+ */
261
+ export function typeRenamed<Value>(
262
+ type: Type<Value>,
263
+ name: string,
264
+ ): Type<Value> {
265
+ return {
266
+ content: name,
267
+ decoder: (input: string) => {
268
+ return TypoError.tryWithContext(
269
+ () => type.decoder(input),
270
+ () =>
271
+ new TypoText(
272
+ new TypoString("from: "),
273
+ new TypoString(type.content, typoStyleLogic),
274
+ ),
275
+ );
276
+ },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Creates a {@link Type} for filesystem paths with optional existence checks.
282
+ * @param checks - Optional checks for path existence and type (file/directory).
283
+ * @returns A {@link Type}`<string>` representing the path.
284
+ */
285
+ export function typePath(
286
+ name?: string,
287
+ checks?: { checkSyncExistAs?: "file" | "directory" | "anything" },
288
+ ): Type<string> {
289
+ return {
290
+ content: name ?? "path",
291
+ decoder(input: string) {
292
+ if (input.length === 0) {
293
+ throw new Error(`Path cannot be empty`);
294
+ }
295
+ if (input.includes("\0")) {
296
+ throw new Error(`Path cannot contain null characters`);
297
+ }
298
+ if (checks?.checkSyncExistAs !== undefined) {
299
+ const stats = statSync(input);
300
+ const preview = stats.isDirectory()
301
+ ? "directory"
302
+ : stats.isFile()
303
+ ? "file"
304
+ : "unknown";
305
+ if (checks.checkSyncExistAs === "file" && !stats.isFile()) {
306
+ throw new TypoError(
307
+ new TypoText(
308
+ new TypoString(`Expected a 'file' but found '${preview}': `),
309
+ new TypoString(`"${input}"`, typoStyleQuote),
310
+ ),
311
+ );
312
+ }
313
+ if (checks.checkSyncExistAs === "directory" && !stats.isDirectory()) {
314
+ throw new TypoError(
315
+ new TypoText(
316
+ new TypoString(`Expected a 'directory' but found '${preview}': `),
317
+ new TypoString(`"${input}"`, typoStyleQuote),
318
+ ),
319
+ );
320
+ }
321
+ }
322
+ return input;
323
+ },
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Creates a {@link Type}`<string>` that only accepts a fixed set of values.
329
+ * Out-of-set inputs throw {@link TypoError} listing up to 5 valid options.
330
+ *
331
+ * @param name - Name shown in help and errors.
247
332
  * @param values - Ordered list of accepted values.
248
333
  * @returns A {@link Type}`<string>`.
249
334
  *
250
335
  * @example
251
336
  * ```ts
252
- * const typeEnv = typeOneOf("Environment", ["dev", "staging", "prod"]);
337
+ * const typeEnv = typeChoice("environment", ["dev", "staging", "prod"]);
253
338
  * typeEnv.decoder("prod") // → "prod"
254
339
  * typeEnv.decoder("unknown") // throws TypoError: Invalid value: "unknown" (expected one of: "dev" | "staging" | "prod")
255
340
  * ```
256
341
  */
257
- export function typeOneOf<const Value extends string>(
258
- content: string,
342
+ export function typeChoice<const Value extends string>(
343
+ name: string,
259
344
  values: Array<Value>,
345
+ caseSensitive: boolean = false,
260
346
  ): Type<Value> {
347
+ const normalize = caseSensitive
348
+ ? (s: string) => s
349
+ : (s: string) => s.toLowerCase();
350
+ const valueMap = new Map(values.map((value) => [normalize(value), value]));
261
351
  return {
262
- content: content,
352
+ content: name,
263
353
  decoder(input: string) {
264
- for (const value of values) {
265
- if (input === value) {
266
- return value;
267
- }
354
+ const normalized = normalize(input);
355
+ const original = valueMap.get(normalized);
356
+ if (original !== undefined) {
357
+ return original;
268
358
  }
269
359
  const valuesPreview = [];
270
360
  for (const value of values) {
@@ -291,7 +381,7 @@ export function typeOneOf<const Value extends string>(
291
381
  }
292
382
 
293
383
  /**
294
- * Splits a delimited string into a fixed-length typed tuple.
384
+ * Splits a delimited string into a typed tuple.
295
385
  * Each part is decoded by the corresponding element type; wrong count or decode failure throws {@link TypoError}.
296
386
  *
297
387
  * @typeParam Elements - Tuple of decoded value types (inferred from `elementTypes`).
@@ -302,7 +392,7 @@ export function typeOneOf<const Value extends string>(
302
392
  *
303
393
  * @example
304
394
  * ```ts
305
- * const typePoint = typeTuple([typeNumber, typeNumber]);
395
+ * const typePoint = typeTuple([typeNumber("x"), typeNumber("y")]);
306
396
  * typePoint.decoder("3.14,2.71") // → [3.14, 2.71]
307
397
  * typePoint.decoder("1,2,3") // → [1, 2]
308
398
  * typePoint.decoder("x,2") // throws TypoError: at 0: Number: Unable to parse: "x"
@@ -316,14 +406,14 @@ export function typeTuple<const Elements extends Array<any>>(
316
406
  content: elementTypes
317
407
  .map((elementType) => elementType.content)
318
408
  .join(separator),
319
- decoder(value: string) {
320
- const splits = value.split(separator, elementTypes.length);
409
+ decoder(input: string) {
410
+ const splits = input.split(separator, elementTypes.length);
321
411
  if (splits.length !== elementTypes.length) {
322
412
  throw new TypoError(
323
413
  new TypoText(
324
414
  new TypoString(`Found ${splits.length} splits: `),
325
415
  new TypoString(`Expected ${elementTypes.length} splits from: `),
326
- new TypoString(`"${value}"`, typoStyleQuote),
416
+ new TypoString(`"${input}"`, typoStyleQuote),
327
417
  ),
328
418
  );
329
419
  }
@@ -343,7 +433,7 @@ export function typeTuple<const Elements extends Array<any>>(
343
433
  }
344
434
 
345
435
  /**
346
- * Splits a delimited string into a variable-length typed array.
436
+ * Splits a delimited string into a typed array.
347
437
  * Each part is decoded by `elementType`; failed decodes throw {@link TypoError}.
348
438
  * Note: splitting an empty string yields one empty element — prefer {@link optionRepeatable} for a zero-default.
349
439
  *
@@ -359,7 +449,7 @@ export function typeTuple<const Elements extends Array<any>>(
359
449
  * typeNumbers.decoder("1,2,3") // → [1, 2, 3]
360
450
  * typeNumbers.decoder("1,x,3") // throws TypoError: at 1: Number: Unable to parse: "x"
361
451
  *
362
- * const typePaths = typeList(typeString, ":");
452
+ * const typePaths = typeList(typePath(), ":");
363
453
  * typePaths.decoder("/usr/bin:/usr/local/bin") // → ["/usr/bin", "/usr/local/bin"]
364
454
  * ```
365
455
  */