cli-kiss 0.2.4 → 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,17 +9,17 @@ 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 errors (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
  /**
@@ -37,32 +38,34 @@ export type Type<Value> = {
37
38
  *
38
39
  * @example
39
40
  * ```ts
40
- * typeBoolean.decoder("true") // → true
41
- * typeBoolean.decoder("yes") // → true
42
- * typeBoolean.decoder("y") // → true
43
- * typeBoolean.decoder("false") // → false
44
- * typeBoolean.decoder("no") // → false
45
- * typeBoolean.decoder("n") // → false
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
46
47
  * ```
47
48
  */
48
- export const typeBoolean: Type<boolean> = {
49
- content: "Boolean",
50
- decoder(input: string) {
51
- const lower = input.toLowerCase();
52
- if (booleanValuesTrue.has(lower)) {
53
- return true;
54
- }
55
- if (booleanValuesFalse.has(lower)) {
56
- return false;
57
- }
58
- throw new TypoError(
59
- new TypoText(
60
- new TypoString(`Invalid value: `),
61
- new TypoString(`"${input}"`, typoStyleQuote),
62
- ),
63
- );
64
- },
65
- };
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
+ }
66
69
  const booleanValuesTrue = new Set(["true", "yes", "on", "1", "y", "t"]);
67
70
  const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
68
71
 
@@ -72,60 +75,64 @@ const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
72
75
  *
73
76
  * @example
74
77
  * ```ts
75
- * typeDate.decoder("2024-01-15") // → Date object for 2024-01-15
76
- * typeDate.decoder("2024-01-15T13:45:30Z") // → Date object for 2024-01-15 13:45:30 UTC
77
- * 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
78
81
  * ```
79
82
  */
80
- export const typeDate: Type<Date> = {
81
- content: "Date",
82
- decoder(input: string) {
83
- try {
84
- const timestampMs = Date.parse(input);
85
- if (isNaN(timestampMs)) {
86
- 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
+ );
87
100
  }
88
- return new Date(timestampMs);
89
- } catch {
90
- throw new TypoError(
91
- new TypoText(
92
- new TypoString(`Not a valid ISO_8601: `),
93
- new TypoString(`"${input}"`, typoStyleQuote),
94
- ),
95
- );
96
- }
97
- },
98
- };
101
+ },
102
+ };
103
+ }
99
104
 
100
105
  /**
101
106
  * Parses a string to `number` via `Number()`; `NaN` throws {@link TypoError}.
102
107
  *
103
108
  * @example
104
109
  * ```ts
105
- * typeNumber.decoder("3.14") // → 3.14
106
- * typeNumber.decoder("-1") // → -1
107
- * 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
108
113
  * ```
109
114
  */
110
- export const typeNumber: Type<number> = {
111
- content: "Number",
112
- decoder(input: string) {
113
- try {
114
- const parsed = Number(input);
115
- if (isNaN(parsed)) {
116
- 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
+ );
117
132
  }
118
- return parsed;
119
- } catch {
120
- throw new TypoError(
121
- new TypoText(
122
- new TypoString(`Unable to parse: `),
123
- new TypoString(`"${input}"`, typoStyleQuote),
124
- ),
125
- );
126
- }
127
- },
128
- };
133
+ },
134
+ };
135
+ }
129
136
 
130
137
  /**
131
138
  * Parses an integer string to `bigint` via `BigInt()`.
@@ -133,26 +140,28 @@ export const typeNumber: Type<number> = {
133
140
  *
134
141
  * @example
135
142
  * ```ts
136
- * typeInteger.decoder("42") // → 42n
137
- * typeInteger.decoder("3.14") // throws TypoError
138
- * 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
139
146
  * ```
140
147
  */
141
- export const typeInteger: Type<bigint> = {
142
- content: "Integer",
143
- decoder(input: string) {
144
- try {
145
- return BigInt(input);
146
- } catch {
147
- throw new TypoError(
148
- new TypoText(
149
- new TypoString(`Unable to parse: `),
150
- new TypoString(`"${input}"`, typoStyleQuote),
151
- ),
152
- );
153
- }
154
- },
155
- };
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
+ }
156
165
 
157
166
  /**
158
167
  * Parses an absolute URL string to a `URL` object.
@@ -160,41 +169,43 @@ export const typeInteger: Type<bigint> = {
160
169
  *
161
170
  * @example
162
171
  * ```ts
163
- * typeUrl.decoder("https://example.com") // → URL { href: "https://example.com/", ... }
164
- * 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
165
174
  * ```
166
175
  */
167
- export const typeUrl: Type<URL> = {
168
- content: "Url",
169
- decoder(input: string) {
170
- try {
171
- return new URL(input);
172
- } catch {
173
- throw new TypoError(
174
- new TypoText(
175
- new TypoString(`Unable to parse: `),
176
- new TypoString(`"${input}"`, typoStyleQuote),
177
- ),
178
- );
179
- }
180
- },
181
- };
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
+ }
182
193
 
183
194
  /**
184
- * Identity decoder passes the raw string through unchanged.
185
- *
195
+ * A named type that accepts any string as input.
196
+ * @param name - Name shown in help and errors (e.g. `"my-value"`).
186
197
  * @example
187
198
  * ```ts
188
- * typeString.decoder("hello") // → "hello"
189
- * typeString.decoder("") // → ""
199
+ * type("greeting").decoder("hello") // → "hello"
200
+ * type("greeting").decoder("") // → ""
190
201
  * ```
191
202
  */
192
- export const typeString: Type<string> = {
193
- content: "String",
194
- decoder(input: string) {
195
- return input;
196
- },
197
- };
203
+ export function type(name?: string): Type<string> {
204
+ return {
205
+ content: name ?? "string",
206
+ decoder: (input: string) => input,
207
+ };
208
+ }
198
209
 
199
210
  /**
200
211
  * Chains `before`'s decoder with an `after` transformation.
@@ -203,33 +214,30 @@ export const typeString: Type<string> = {
203
214
  * @typeParam Before - Intermediate type from `before.decoder`.
204
215
  * @typeParam After - Final type from `after.decoder`.
205
216
  *
206
- * @param before - Base decoder for the raw string.
207
- * @param after - Transformation applied to the decoded value.
208
- * @param after.content - Name for the resulting type (shown in errors).
209
- * @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.
210
220
  * @returns A {@link Type}`<After>`.
211
221
  *
212
222
  * @example
213
223
  * ```ts
214
- * const typePort = typeMapped(typeNumber, {
215
- * content: "Port",
216
- * decoder: (n) => {
217
- * if (n < 1 || n > 65535) throw new Error("Out of range");
218
- * return n;
219
- * },
224
+ * const typePort = typeConverted("port", typeNumber(), (n) => {
225
+ * if (n < 1 || n > 65535) throw new Error("Out of range");
226
+ * return n;
220
227
  * });
221
228
  * // "--port 8080" → 8080
222
229
  * // "--port 99999" → TypoError: --port: <PORT>: Port: Out of range
223
230
  * ```
224
231
  */
225
- export function typeMapped<Before, After>(
232
+ export function typeConverted<Before, After>(
233
+ name: string,
226
234
  before: Type<Before>,
227
- after: { content: string; decoder: (value: Before) => After },
235
+ mapper: (value: Before) => After,
228
236
  ): Type<After> {
229
237
  return {
230
- content: after.content,
238
+ content: name,
231
239
  decoder: (input: string) => {
232
- return after.decoder(
240
+ return mapper(
233
241
  TypoError.tryWithContext(
234
242
  () => before.decoder(input),
235
243
  () =>
@@ -243,32 +251,110 @@ export function typeMapped<Before, After>(
243
251
  };
244
252
  }
245
253
 
254
+ /**
255
+ * Adds a name to a {@link Type} for clearer error messages and help text.
256
+ *
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
+
246
327
  /**
247
328
  * Creates a {@link Type}`<string>` that only accepts a fixed set of values.
248
329
  * Out-of-set inputs throw {@link TypoError} listing up to 5 valid options.
249
330
  *
250
- * @param content - Name shown in help and errors (e.g. `"Environment"`).
331
+ * @param name - Name shown in help and errors.
251
332
  * @param values - Ordered list of accepted values.
252
333
  * @returns A {@link Type}`<string>`.
253
334
  *
254
335
  * @example
255
336
  * ```ts
256
- * const typeEnv = typeOneOf("Environment", ["dev", "staging", "prod"]);
337
+ * const typeEnv = typeChoice("environment", ["dev", "staging", "prod"]);
257
338
  * typeEnv.decoder("prod") // → "prod"
258
339
  * typeEnv.decoder("unknown") // throws TypoError: Invalid value: "unknown" (expected one of: "dev" | "staging" | "prod")
259
340
  * ```
260
341
  */
261
- export function typeOneOf<const Value extends string>(
262
- content: string,
342
+ export function typeChoice<const Value extends string>(
343
+ name: string,
263
344
  values: Array<Value>,
345
+ caseSensitive: boolean = false,
264
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]));
265
351
  return {
266
- content: content,
352
+ content: name,
267
353
  decoder(input: string) {
268
- for (const value of values) {
269
- if (input === value) {
270
- return value;
271
- }
354
+ const normalized = normalize(input);
355
+ const original = valueMap.get(normalized);
356
+ if (original !== undefined) {
357
+ return original;
272
358
  }
273
359
  const valuesPreview = [];
274
360
  for (const value of values) {
@@ -306,7 +392,7 @@ export function typeOneOf<const Value extends string>(
306
392
  *
307
393
  * @example
308
394
  * ```ts
309
- * const typePoint = typeTuple([typeNumber, typeNumber]);
395
+ * const typePoint = typeTuple([typeNumber("x"), typeNumber("y")]);
310
396
  * typePoint.decoder("3.14,2.71") // → [3.14, 2.71]
311
397
  * typePoint.decoder("1,2,3") // → [1, 2]
312
398
  * typePoint.decoder("x,2") // throws TypoError: at 0: Number: Unable to parse: "x"
@@ -363,7 +449,7 @@ export function typeTuple<const Elements extends Array<any>>(
363
449
  * typeNumbers.decoder("1,2,3") // → [1, 2, 3]
364
450
  * typeNumbers.decoder("1,x,3") // throws TypoError: at 1: Number: Unable to parse: "x"
365
451
  *
366
- * const typePaths = typeList(typeString, ":");
452
+ * const typePaths = typeList(typePath(), ":");
367
453
  * typePaths.decoder("/usr/bin:/usr/local/bin") // → ["/usr/bin", "/usr/local/bin"]
368
454
  * ```
369
455
  */
package/src/lib/Typo.ts CHANGED
@@ -25,6 +25,10 @@ export type TypoColor =
25
25
  * All fields are optional; ignored entirely in `"none"` mode.
26
26
  */
27
27
  export type TypoStyle = {
28
+ /**
29
+ * Letter case.
30
+ */
31
+ case?: "upper" | "lower";
28
32
  /**
29
33
  * Foreground (text) color.
30
34
  */
@@ -228,7 +232,7 @@ export class TypoGrid {
228
232
  * Renders as an array of styled, column-padded strings.
229
233
  *
230
234
  * @param typoSupport - Rendering mode.
231
- * @returns 2-D array of styled strings.
235
+ * @returns Array of styled strings.
232
236
  */
233
237
  computeStyledLines(typoSupport: TypoSupport): Array<string> {
234
238
  const widths = new Array<number>();
@@ -346,36 +350,37 @@ export class TypoSupport {
346
350
  }
347
351
  /**
348
352
  * Deterministic textual styling for snapshot tests.
349
- * Style flags appear as suffixes: `{text}@color`, `{text}+` (bold), `{text}-` (dim),
350
- * `{text}*` (italic), `{text}_` (underline), `{text}~` (strikethrough).
351
353
  */
352
354
  static mock(): TypoSupport {
353
355
  return new TypoSupport("mock");
354
356
  }
355
357
  /**
356
- * Auto-detects styling mode from the process environment.
357
- * `FORCE_COLOR=0` / `NO_COLOR` → none; `FORCE_COLOR` (truthy) / `isTTY` → tty; else → none.
358
- * Falls back to none if `process` is unavailable.
358
+ * Auto-detects styling mode from the process environment on best-effort basis.
359
359
  */
360
- static inferFromProcess(): TypoSupport {
361
- if (!process) {
360
+ static inferFromEnv(): TypoSupport {
361
+ if (!process || !process.env) {
362
362
  return TypoSupport.none();
363
363
  }
364
- if (process.env) {
365
- if (process.env["FORCE_COLOR"] === "0") {
366
- return TypoSupport.none();
367
- }
368
- if (process.env["FORCE_COLOR"]) {
369
- return TypoSupport.tty();
370
- }
371
- if ("NO_COLOR" in process.env) {
372
- return TypoSupport.none();
364
+ function readEnvVar(name: string) {
365
+ if (!(name in process.env)) {
366
+ return undefined;
373
367
  }
368
+ return process.env[name];
369
+ }
370
+ const envForceColor = readEnvVar("FORCE_COLOR");
371
+ if (envForceColor === "0") {
372
+ return TypoSupport.none();
374
373
  }
375
- if (process.stdout && process.stdout.isTTY) {
376
- return TypoSupport.tty();
374
+ if (envForceColor !== undefined) {
375
+ TypoSupport.tty();
377
376
  }
378
- return TypoSupport.none();
377
+ if (readEnvVar("NO_COLOR") !== undefined) {
378
+ return TypoSupport.none();
379
+ }
380
+ if (readEnvVar("MOCK_COLOR") !== undefined) {
381
+ return TypoSupport.mock();
382
+ }
383
+ return TypoSupport.tty();
379
384
  }
380
385
  /**
381
386
  * Applies `typoStyle` to `value` according to the current mode.
@@ -385,8 +390,15 @@ export class TypoSupport {
385
390
  * @returns Styled string.
386
391
  */
387
392
  computeStyledString(value: string, typoStyle: TypoStyle): string {
393
+ let styledValue = value;
394
+ if (typoStyle.case === "upper") {
395
+ styledValue = styledValue.toUpperCase();
396
+ }
397
+ if (typoStyle.case === "lower") {
398
+ styledValue = styledValue.toLowerCase();
399
+ }
388
400
  if (this.#kind === "none") {
389
- return value;
401
+ return styledValue;
390
402
  }
391
403
  if (this.#kind === "tty") {
392
404
  const fgColorCode = typoStyle.fgColor
@@ -402,12 +414,12 @@ export class TypoSupport {
402
414
  const strikethroughCode = typoStyle.strikethrough
403
415
  ? ttyCodeStrikethrough
404
416
  : "";
405
- return `${fgColorCode}${bgColorCode}${boldCode}${dimCode}${italicCode}${underlineCode}${strikethroughCode}${value}${ttyCodeReset}`;
417
+ return `${fgColorCode}${bgColorCode}${boldCode}${dimCode}${italicCode}${underlineCode}${strikethroughCode}${styledValue}${ttyCodeReset}`;
406
418
  }
407
419
  if (this.#kind === "mock") {
408
420
  const fgColorPart = typoStyle.fgColor
409
- ? `{${value}}@${typoStyle.fgColor}`
410
- : value;
421
+ ? `{${styledValue}}@${typoStyle.fgColor}`
422
+ : styledValue;
411
423
  const bgColorPart = typoStyle.bgColor
412
424
  ? `{${fgColorPart}}#${typoStyle.bgColor}`
413
425
  : fgColorPart;