cli-kiss 0.2.4 → 0.2.6

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,34 +38,32 @@ 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
- };
66
- const booleanValuesTrue = new Set(["true", "yes", "on", "1", "y", "t"]);
67
- const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
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 (typeBooleanValuesTrue.has(lower)) {
55
+ return true;
56
+ }
57
+ if (typeBooleanValuesFalse.has(lower)) {
58
+ return false;
59
+ }
60
+ throwInvalidValue("a boolean", input);
61
+ },
62
+ };
63
+ }
64
+
65
+ export const typeBooleanValuesTrue = new Set(["true", "yes", "on", "y"]);
66
+ export const typeBooleanValuesFalse = new Set(["false", "no", "off", "n"]);
68
67
 
69
68
  /**
70
69
  * Parses a date/time string via `Date.parse`.
@@ -72,60 +71,54 @@ const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
72
71
  *
73
72
  * @example
74
73
  * ```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
74
+ * typeDatetime("my-datetime").decoder("2024-01-15") // → Date object for 2024-01-15
75
+ * typeDatetime("my-datetime").decoder("2024-01-15T13:45:30Z") // → Date object for 2024-01-15 13:45:30 UTC
76
+ * typeDatetime("my-datetime").decoder("not a date") // throws TypoError
78
77
  * ```
79
78
  */
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();
79
+ export function typeDatetime(name?: string): Type<Date> {
80
+ return {
81
+ content: name ?? "datetime",
82
+ decoder(input: string) {
83
+ try {
84
+ const timestampMs = Date.parse(input);
85
+ if (isNaN(timestampMs)) {
86
+ throw new Error();
87
+ }
88
+ return new Date(timestampMs);
89
+ } catch {
90
+ throwInvalidValue("a valid ISO_8601 datetime", input);
87
91
  }
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
- };
92
+ },
93
+ };
94
+ }
99
95
 
100
96
  /**
101
97
  * Parses a string to `number` via `Number()`; `NaN` throws {@link TypoError}.
102
98
  *
103
99
  * @example
104
100
  * ```ts
105
- * typeNumber.decoder("3.14") // → 3.14
106
- * typeNumber.decoder("-1") // → -1
107
- * typeNumber.decoder("hello") // throws TypoError
101
+ * typeNumber("my-number").decoder("3.14") // → 3.14
102
+ * typeNumber("my-number").decoder("-1") // → -1
103
+ * typeNumber("my-number").decoder("hello") // throws
108
104
  * ```
109
105
  */
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();
106
+ export function typeNumber(name?: string): Type<number> {
107
+ return {
108
+ content: name ?? "number",
109
+ decoder(input: string) {
110
+ try {
111
+ const parsed = Number(input);
112
+ if (isNaN(parsed)) {
113
+ throw new Error();
114
+ }
115
+ return parsed;
116
+ } catch {
117
+ throwInvalidValue("a number", input);
117
118
  }
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
- };
119
+ },
120
+ };
121
+ }
129
122
 
130
123
  /**
131
124
  * Parses an integer string to `bigint` via `BigInt()`.
@@ -133,26 +126,23 @@ export const typeNumber: Type<number> = {
133
126
  *
134
127
  * @example
135
128
  * ```ts
136
- * typeInteger.decoder("42") // → 42n
137
- * typeInteger.decoder("3.14") // throws TypoError
138
- * typeInteger.decoder("abc") // throws TypoError
129
+ * typeInteger("my-integer").decoder("42") // → 42n
130
+ * typeInteger("my-integer").decoder("3.14") // throws
131
+ * typeInteger("my-integer").decoder("abc") // throws
139
132
  * ```
140
133
  */
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
- };
134
+ export function typeInteger(name?: string): Type<bigint> {
135
+ return {
136
+ content: name ?? "integer",
137
+ decoder(input: string) {
138
+ try {
139
+ return BigInt(input);
140
+ } catch {
141
+ throwInvalidValue("an integer", input);
142
+ }
143
+ },
144
+ };
145
+ }
156
146
 
157
147
  /**
158
148
  * Parses an absolute URL string to a `URL` object.
@@ -160,41 +150,38 @@ export const typeInteger: Type<bigint> = {
160
150
  *
161
151
  * @example
162
152
  * ```ts
163
- * typeUrl.decoder("https://example.com") // → URL { href: "https://example.com/", ... }
164
- * typeUrl.decoder("not-a-url") // throws TypoError
153
+ * typeUrl("my-url").decoder("https://example.com") // → URL { href: "https://example.com/", ... }
154
+ * typeUrl("my-url").decoder("not-a-url") // throws
165
155
  * ```
166
156
  */
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
- };
157
+ export function typeUrl(name?: string): Type<URL> {
158
+ return {
159
+ content: name ?? "url",
160
+ decoder(input: string) {
161
+ try {
162
+ return new URL(input);
163
+ } catch {
164
+ throwInvalidValue("an URL", input);
165
+ }
166
+ },
167
+ };
168
+ }
182
169
 
183
170
  /**
184
- * Identity decoder passes the raw string through unchanged.
185
- *
171
+ * A named type that accepts any string as input.
172
+ * @param name - Name shown in help and errors (e.g. `"my-value"`).
186
173
  * @example
187
174
  * ```ts
188
- * typeString.decoder("hello") // → "hello"
189
- * typeString.decoder("") // → ""
175
+ * type("greeting").decoder("hello") // → "hello"
176
+ * type("greeting").decoder("") // → ""
190
177
  * ```
191
178
  */
192
- export const typeString: Type<string> = {
193
- content: "String",
194
- decoder(input: string) {
195
- return input;
196
- },
197
- };
179
+ export function type(name?: string): Type<string> {
180
+ return {
181
+ content: name ?? "string",
182
+ decoder: (input: string) => input,
183
+ };
184
+ }
198
185
 
199
186
  /**
200
187
  * Chains `before`'s decoder with an `after` transformation.
@@ -203,33 +190,30 @@ export const typeString: Type<string> = {
203
190
  * @typeParam Before - Intermediate type from `before.decoder`.
204
191
  * @typeParam After - Final type from `after.decoder`.
205
192
  *
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`.
193
+ * @param name - Name shown in help and errors (e.g. `"my-value"`).
194
+ * @param before - Base type to decode the raw string.
195
+ * @param mapper - Transforms `before`'s output to the final value; errors are wrapped with context.
210
196
  * @returns A {@link Type}`<After>`.
211
197
  *
212
198
  * @example
213
199
  * ```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
- * },
200
+ * const typePort = typeConverted("port", typeNumber(), (n) => {
201
+ * if (n < 1 || n > 65535) throw new Error("Out of range");
202
+ * return n;
220
203
  * });
221
204
  * // "--port 8080" → 8080
222
205
  * // "--port 99999" → TypoError: --port: <PORT>: Port: Out of range
223
206
  * ```
224
207
  */
225
- export function typeMapped<Before, After>(
208
+ export function typeConverted<Before, After>(
209
+ name: string,
226
210
  before: Type<Before>,
227
- after: { content: string; decoder: (value: Before) => After },
211
+ mapper: (value: Before) => After,
228
212
  ): Type<After> {
229
213
  return {
230
- content: after.content,
214
+ content: name,
231
215
  decoder: (input: string) => {
232
- return after.decoder(
216
+ return mapper(
233
217
  TypoError.tryWithContext(
234
218
  () => before.decoder(input),
235
219
  () =>
@@ -243,32 +227,123 @@ export function typeMapped<Before, After>(
243
227
  };
244
228
  }
245
229
 
230
+ /**
231
+ * Adds a name to a {@link Type} for clearer error messages and help text.
232
+ *
233
+ * @param name - Name to use for the type.
234
+ * @param type - Base type to name.
235
+ * @returns A {@link Type} with the given name.
236
+ */
237
+ export function typeRenamed<Value>(
238
+ type: Type<Value>,
239
+ name: string,
240
+ ): Type<Value> {
241
+ return {
242
+ content: name,
243
+ decoder: (input: string) => {
244
+ return TypoError.tryWithContext(
245
+ () => type.decoder(input),
246
+ () =>
247
+ new TypoText(
248
+ new TypoString("from: "),
249
+ new TypoString(type.content, typoStyleLogic),
250
+ ),
251
+ );
252
+ },
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Creates a {@link Type} for filesystem paths with optional existence checks.
258
+ * @param checks - Optional checks for path existence and type (file/directory).
259
+ * @returns A {@link Type}`<string>` representing the path.
260
+ */
261
+ export function typePath(
262
+ name?: string,
263
+ checks?: { checkSyncExistAs?: "file" | "directory" | "anything" },
264
+ ): Type<string> {
265
+ return {
266
+ content: name ?? "path",
267
+ decoder(input: string) {
268
+ if (input.length === 0) {
269
+ throw new Error(`Path cannot be empty`);
270
+ }
271
+ if (input.includes("\0")) {
272
+ throw new Error(`Path cannot contain null characters`);
273
+ }
274
+ if (checks?.checkSyncExistAs !== undefined) {
275
+ function safeStatSync(path: string) {
276
+ try {
277
+ return statSync(path);
278
+ } catch (error) {
279
+ throw new TypoError(
280
+ new TypoText(
281
+ new TypoString(`Path does not exist: `),
282
+ new TypoString(`"${path}"`, typoStyleQuote),
283
+ ),
284
+ error,
285
+ );
286
+ }
287
+ }
288
+ const stats = safeStatSync(input);
289
+ const preview = stats.isDirectory()
290
+ ? "directory"
291
+ : stats.isFile()
292
+ ? "file"
293
+ : "unknown";
294
+ if (checks.checkSyncExistAs === "file" && !stats.isFile()) {
295
+ throw new TypoError(
296
+ new TypoText(
297
+ new TypoString(`Expected a file but found: ${preview}: `),
298
+ new TypoString(`"${input}"`, typoStyleQuote),
299
+ ),
300
+ );
301
+ }
302
+ if (checks.checkSyncExistAs === "directory" && !stats.isDirectory()) {
303
+ throw new TypoError(
304
+ new TypoText(
305
+ new TypoString(`Expected a directory but found: ${preview}: `),
306
+ new TypoString(`"${input}"`, typoStyleQuote),
307
+ ),
308
+ );
309
+ }
310
+ }
311
+ return input;
312
+ },
313
+ };
314
+ }
315
+
246
316
  /**
247
317
  * Creates a {@link Type}`<string>` that only accepts a fixed set of values.
248
318
  * Out-of-set inputs throw {@link TypoError} listing up to 5 valid options.
249
319
  *
250
- * @param content - Name shown in help and errors (e.g. `"Environment"`).
320
+ * @param name - Name shown in help and errors.
251
321
  * @param values - Ordered list of accepted values.
252
322
  * @returns A {@link Type}`<string>`.
253
323
  *
254
324
  * @example
255
325
  * ```ts
256
- * const typeEnv = typeOneOf("Environment", ["dev", "staging", "prod"]);
326
+ * const typeEnv = typeChoice("environment", ["dev", "staging", "prod"]);
257
327
  * typeEnv.decoder("prod") // → "prod"
258
328
  * typeEnv.decoder("unknown") // throws TypoError: Invalid value: "unknown" (expected one of: "dev" | "staging" | "prod")
259
329
  * ```
260
330
  */
261
- export function typeOneOf<const Value extends string>(
262
- content: string,
331
+ export function typeChoice<const Value extends string>(
332
+ name: string,
263
333
  values: Array<Value>,
334
+ caseSensitive: boolean = false,
264
335
  ): Type<Value> {
336
+ const normalize = caseSensitive
337
+ ? (s: string) => s
338
+ : (s: string) => s.toLowerCase();
339
+ const valueMap = new Map(values.map((value) => [normalize(value), value]));
265
340
  return {
266
- content: content,
341
+ content: name,
267
342
  decoder(input: string) {
268
- for (const value of values) {
269
- if (input === value) {
270
- return value;
271
- }
343
+ const normalized = normalize(input);
344
+ const original = valueMap.get(normalized);
345
+ if (original !== undefined) {
346
+ return original;
272
347
  }
273
348
  const valuesPreview = [];
274
349
  for (const value of values) {
@@ -306,7 +381,7 @@ export function typeOneOf<const Value extends string>(
306
381
  *
307
382
  * @example
308
383
  * ```ts
309
- * const typePoint = typeTuple([typeNumber, typeNumber]);
384
+ * const typePoint = typeTuple([typeNumber("x"), typeNumber("y")]);
310
385
  * typePoint.decoder("3.14,2.71") // → [3.14, 2.71]
311
386
  * typePoint.decoder("1,2,3") // → [1, 2]
312
387
  * typePoint.decoder("x,2") // throws TypoError: at 0: Number: Unable to parse: "x"
@@ -363,7 +438,7 @@ export function typeTuple<const Elements extends Array<any>>(
363
438
  * typeNumbers.decoder("1,2,3") // → [1, 2, 3]
364
439
  * typeNumbers.decoder("1,x,3") // throws TypoError: at 1: Number: Unable to parse: "x"
365
440
  *
366
- * const typePaths = typeList(typeString, ":");
441
+ * const typePaths = typeList(typePath(), ":");
367
442
  * typePaths.decoder("/usr/bin:/usr/local/bin") // → ["/usr/bin", "/usr/local/bin"]
368
443
  * ```
369
444
  */
@@ -388,3 +463,12 @@ export function typeList<Value>(
388
463
  },
389
464
  };
390
465
  }
466
+
467
+ function throwInvalidValue(kind: string, input: string): never {
468
+ throw new TypoError(
469
+ new TypoText(
470
+ new TypoString(`Not ${kind}: `),
471
+ new TypoString(`"${input}"`, typoStyleQuote),
472
+ ),
473
+ );
474
+ }
package/src/lib/Typo.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { typeBooleanValuesFalse } from "./Type";
2
+
1
3
  /**
2
4
  * Color names for terminal styling, used by {@link TypoStyle}.
3
5
  * `dark*` = standard ANSI (30–37); `bright*` = high-intensity (90–97).
@@ -25,6 +27,10 @@ export type TypoColor =
25
27
  * All fields are optional; ignored entirely in `"none"` mode.
26
28
  */
27
29
  export type TypoStyle = {
30
+ /**
31
+ * Letter case.
32
+ */
33
+ case?: "upper" | "lower";
28
34
  /**
29
35
  * Foreground (text) color.
30
36
  */
@@ -228,7 +234,7 @@ export class TypoGrid {
228
234
  * Renders as an array of styled, column-padded strings.
229
235
  *
230
236
  * @param typoSupport - Rendering mode.
231
- * @returns 2-D array of styled strings.
237
+ * @returns Array of styled strings.
232
238
  */
233
239
  computeStyledLines(typoSupport: TypoSupport): Array<string> {
234
240
  const widths = new Array<number>();
@@ -346,36 +352,48 @@ export class TypoSupport {
346
352
  }
347
353
  /**
348
354
  * 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
355
  */
352
356
  static mock(): TypoSupport {
353
357
  return new TypoSupport("mock");
354
358
  }
355
359
  /**
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.
360
+ * Auto-detects styling mode from the process environment on best-effort basis.
359
361
  */
360
- static inferFromProcess(): TypoSupport {
361
- if (!process) {
362
+ static inferFromEnv(): TypoSupport {
363
+ /*
364
+ console.warn({
365
+ no: readEnvVar("NO_COLOR"),
366
+ force: readEnvVar("FORCE_COLOR"),
367
+ mock: readEnvVar("MOCK_COLOR"),
368
+ term: readEnvVar("TERM"),
369
+ tty: process.stdout.isTTY,
370
+ });
371
+ */
372
+ if (!process || !process.env || !process.stdout) {
362
373
  return TypoSupport.none();
363
374
  }
364
- if (process.env) {
365
- if (process.env["FORCE_COLOR"] === "0") {
366
- return TypoSupport.none();
367
- }
368
- if (process.env["FORCE_COLOR"]) {
375
+ if (readEnvVar("NO_COLOR")) {
376
+ return TypoSupport.none();
377
+ }
378
+ const envForceColor = readEnvVar("FORCE_COLOR");
379
+ if (envForceColor === "0") {
380
+ return TypoSupport.none();
381
+ }
382
+ if (envForceColor !== undefined) {
383
+ if (!typeBooleanValuesFalse.has(envForceColor.toLowerCase())) {
369
384
  return TypoSupport.tty();
370
385
  }
371
- if ("NO_COLOR" in process.env) {
372
- return TypoSupport.none();
373
- }
374
386
  }
375
- if (process.stdout && process.stdout.isTTY) {
376
- return TypoSupport.tty();
387
+ if (readEnvVar("MOCK_COLOR")) {
388
+ return TypoSupport.mock();
377
389
  }
378
- return TypoSupport.none();
390
+ if (!process.stdout.isTTY) {
391
+ return TypoSupport.none();
392
+ }
393
+ if (readEnvVar("TERM")?.toLowerCase() === "dumb") {
394
+ return TypoSupport.none();
395
+ }
396
+ return TypoSupport.tty();
379
397
  }
380
398
  /**
381
399
  * Applies `typoStyle` to `value` according to the current mode.
@@ -385,8 +403,15 @@ export class TypoSupport {
385
403
  * @returns Styled string.
386
404
  */
387
405
  computeStyledString(value: string, typoStyle: TypoStyle): string {
406
+ let styledValue = value;
407
+ if (typoStyle.case === "upper") {
408
+ styledValue = styledValue.toUpperCase();
409
+ }
410
+ if (typoStyle.case === "lower") {
411
+ styledValue = styledValue.toLowerCase();
412
+ }
388
413
  if (this.#kind === "none") {
389
- return value;
414
+ return styledValue;
390
415
  }
391
416
  if (this.#kind === "tty") {
392
417
  const fgColorCode = typoStyle.fgColor
@@ -402,12 +427,12 @@ export class TypoSupport {
402
427
  const strikethroughCode = typoStyle.strikethrough
403
428
  ? ttyCodeStrikethrough
404
429
  : "";
405
- return `${fgColorCode}${bgColorCode}${boldCode}${dimCode}${italicCode}${underlineCode}${strikethroughCode}${value}${ttyCodeReset}`;
430
+ return `${fgColorCode}${bgColorCode}${boldCode}${dimCode}${italicCode}${underlineCode}${strikethroughCode}${styledValue}${ttyCodeReset}`;
406
431
  }
407
432
  if (this.#kind === "mock") {
408
433
  const fgColorPart = typoStyle.fgColor
409
- ? `{${value}}@${typoStyle.fgColor}`
410
- : value;
434
+ ? `{${styledValue}}@${typoStyle.fgColor}`
435
+ : styledValue;
411
436
  const bgColorPart = typoStyle.bgColor
412
437
  ? `{${fgColorPart}}#${typoStyle.bgColor}`
413
438
  : fgColorPart;
@@ -484,3 +509,10 @@ const ttyCodeBgColors: Record<TypoColor, string> = {
484
509
  brightCyan: "\x1b[106m",
485
510
  brightWhite: "\x1b[107m",
486
511
  };
512
+
513
+ function readEnvVar(name: string) {
514
+ if (!(name in process.env)) {
515
+ return undefined;
516
+ }
517
+ return process.env[name];
518
+ }