@effected/cli-kit 0.0.0-stage → 0.26.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/Cancelled.js +43 -0
- package/CliAudience.js +177 -0
- package/CliColor.js +59 -0
- package/CliEnv.js +101 -0
- package/CliExit.js +64 -0
- package/CliFailure.js +287 -0
- package/CliFormat.js +124 -0
- package/CliInteractive.js +94 -0
- package/CliLinks.js +153 -0
- package/CliLog.js +367 -0
- package/CliLogger.js +91 -0
- package/CliMessage.js +79 -0
- package/CliPrompt.js +110 -0
- package/CliRuntime.js +254 -0
- package/CliTest.js +206 -0
- package/CliTheme.js +142 -0
- package/ConfigIssueRenderer.js +63 -0
- package/Doc.js +548 -0
- package/Fmt.js +335 -0
- package/GithubAnnotation.js +39 -0
- package/Glyphs.js +82 -0
- package/LICENSE +21 -0
- package/NotInteractive.js +41 -0
- package/README.md +215 -2
- package/Render.js +280 -0
- package/SchemaIssueRenderer.js +47 -0
- package/Status.js +165 -0
- package/TestTerminal.js +101 -0
- package/Token.js +68 -0
- package/index.d.ts +3951 -0
- package/index.js +27 -0
- package/internal/ExitRequested.js +21 -0
- package/internal/HelpRouting.js +35 -0
- package/internal/ansi.js +229 -0
- package/internal/autoFormat.js +33 -0
- package/internal/canPrompt.js +14 -0
- package/internal/counts.js +83 -0
- package/internal/diagnostics.js +31 -0
- package/internal/displayPath.js +46 -0
- package/internal/displayWidth.js +42 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +17 -0
- package/internal/fileSink.js +61 -0
- package/internal/format.js +175 -0
- package/internal/inspect.js +40 -0
- package/internal/isExitCode.js +11 -0
- package/internal/layout.js +249 -0
- package/internal/linkScheme.js +29 -0
- package/internal/linkTarget.js +49 -0
- package/internal/logSafety.js +45 -0
- package/internal/renderAnsi.js +51 -0
- package/internal/renderDoc.js +322 -0
- package/internal/renderGithubLog.js +59 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +49 -0
- package/internal/scanAudience.js +105 -0
- package/internal/splitFrame.js +55 -0
- package/internal/terminalCapabilities.js +23 -0
- package/internal/wizardGate.js +17 -0
- package/package.json +90 -5
- package/testing.d.ts +246 -0
- package/testing.js +4 -0
- package/tsdoc-metadata.json +11 -0
- package/ui/CliUi.js +507 -0
- package/ui/CliUiDetached.js +36 -0
- package/ui/CliUiLive.js +436 -0
- package/ui/CliUiRouteStream.js +72 -0
- package/ui/Confirm.js +244 -0
- package/ui/DocView.js +76 -0
- package/ui/KeyHelp.js +61 -0
- package/ui/KeyTable.js +198 -0
- package/ui/MultiSelect.js +259 -0
- package/ui/Select.js +229 -0
- package/ui/Tabs.js +201 -0
- package/ui/TextInput.js +289 -0
- package/ui/Toggle.js +31 -0
- package/ui/UiKey.js +48 -0
- package/ui/UiProvider.js +62 -0
- package/ui/UiStreams.js +17 -0
- package/ui/UiTheme.js +147 -0
- package/ui/Viewport.js +203 -0
- package/ui/internal/ErrorBoundary.js +29 -0
- package/ui/internal/Holder.js +73 -0
- package/ui/internal/ScreenContext.js +51 -0
- package/ui/internal/UiProviders.js +38 -0
- package/ui/internal/ink.js +121 -0
- package/ui/internal/inkChalk.js +47 -0
- package/ui/internal/inkConsole.js +150 -0
- package/ui/internal/lazyView.js +73 -0
- package/ui/internal/lineRouter.js +70 -0
- package/ui/internal/lineText.js +18 -0
- package/ui/internal/mountPermit.js +15 -0
- package/ui/internal/processStreams.js +18 -0
- package/ui/internal/renderOptions.js +12 -0
- package/ui/testing/CliUiTest.js +792 -0
- package/ui/testing/fakeStreams.js +104 -0
- package/ui/testing/terminalModel.js +67 -0
- package/ui-testing-serializer.d.ts +12 -0
- package/ui-testing-serializer.js +32 -0
- package/ui-testing.d.ts +558 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1990 -0
- package/ui.js +17 -0
package/Fmt.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { displayWidth, graphemes, isEscapeOnly, stripAnsi } from "./internal/displayWidth.js";
|
|
2
|
+
import { Duration, Option } from "effect";
|
|
3
|
+
import { ErrorText } from "@effected/kit";
|
|
4
|
+
|
|
5
|
+
// src/Fmt.ts
|
|
6
|
+
/**
|
|
7
|
+
* The Levenshtein distance between `a` and `b` when it is at most `limit`, and `limit + 1` otherwise.
|
|
8
|
+
*
|
|
9
|
+
* Only the diagonal band of the matrix within `limit` of it is computed (a cell outside it is past `limit` already),
|
|
10
|
+
* over two rows reused in turn, and a row whose every cell is past `limit` ends the walk, since no later row can come
|
|
11
|
+
* back under it: the cost is the length of `a` times `2 * limit + 1`.
|
|
12
|
+
*/
|
|
13
|
+
const boundedDistance = (a, b, limit) => {
|
|
14
|
+
const over = limit + 1;
|
|
15
|
+
if (Math.abs(a.length - b.length) > limit) return over;
|
|
16
|
+
const n = b.length;
|
|
17
|
+
const at = (row, j) => row[j] ?? over;
|
|
18
|
+
let previous = Array.from({ length: n + 1 }, (_, j) => j <= limit ? j : over);
|
|
19
|
+
let current = new Array(n + 1).fill(over);
|
|
20
|
+
for (let i = 1; i <= a.length; i++) {
|
|
21
|
+
const lo = Math.max(1, i - limit);
|
|
22
|
+
const hi = Math.min(n, i + limit);
|
|
23
|
+
const edge = lo === 1 && i <= limit ? i : over;
|
|
24
|
+
current[lo - 1] = edge;
|
|
25
|
+
let rowMin = edge;
|
|
26
|
+
for (let j = lo; j <= hi; j++) {
|
|
27
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
28
|
+
const cell = Math.min(at(previous, j) + 1, at(current, j - 1) + 1, at(previous, j - 1) + cost, over);
|
|
29
|
+
current[j] = cell;
|
|
30
|
+
if (cell < rowMin) rowMin = cell;
|
|
31
|
+
}
|
|
32
|
+
if (hi < n) current[hi + 1] = over;
|
|
33
|
+
if (rowMin > limit) return over;
|
|
34
|
+
[previous, current] = [current, previous];
|
|
35
|
+
}
|
|
36
|
+
return Math.min(at(previous, n), over);
|
|
37
|
+
};
|
|
38
|
+
const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
39
|
+
/**
|
|
40
|
+
* Text made safe to lay out and print: complete ANSI and OSC sequences removed, every remaining control character
|
|
41
|
+
* removed (a stray ESC, BEL, BS, DEL, the C1 range) except line feed and carriage return, and a tab turned into a
|
|
42
|
+
* space.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Stripping complete sequences is not enough on its own: a lone ESC survives it, and so does whatever would
|
|
46
|
+
* complete a sequence once two adjacent pieces of text are joined (`ESC` in one node, `[31m` in the next). With
|
|
47
|
+
* every ESC and C0 or C1 control gone after the strip, nothing can reassemble, so the width of the text equals what
|
|
48
|
+
* a terminal shows. A tab counts as no columns but draws up to eight, hence the space.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
const sanitize = (input) => stripAnsi(input).replace(/\t/g, " ").replace(CONTROL, "");
|
|
53
|
+
/**
|
|
54
|
+
* Small, pure formatting primitives for terminal output.
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
var Fmt = class {
|
|
59
|
+
constructor() {}
|
|
60
|
+
/**
|
|
61
|
+
* Text made safe to lay out and print, exactly as the renderers make it: complete ANSI and OSC sequences removed,
|
|
62
|
+
* every other control character removed (a stray ESC, BEL, BS, DEL, the C1 range) except line feed and carriage
|
|
63
|
+
* return, and a tab turned into a space.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* For a `render` or any other text a program builds by hand from data it does not control: an error message, a
|
|
67
|
+
* file name, a value from the environment. Line breaks are kept, so split on them if the text must be one line.
|
|
68
|
+
*
|
|
69
|
+
* @param text - the text
|
|
70
|
+
*/
|
|
71
|
+
static sanitize = sanitize;
|
|
72
|
+
/**
|
|
73
|
+
* The display width of `text` in terminal columns: wide East Asian characters and emoji count two,
|
|
74
|
+
* combining marks and ANSI escapes count none.
|
|
75
|
+
*
|
|
76
|
+
* @param text - the text to measure
|
|
77
|
+
*/
|
|
78
|
+
static width = (text) => displayWidth(text);
|
|
79
|
+
/**
|
|
80
|
+
* Cut `text` to at most `width` columns, ending in `ellipsis` when it was cut.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* Grapheme-safe: a grapheme, such as a wide character or an emoji ZWJ family, is kept whole or dropped
|
|
84
|
+
* whole, so the result is never wider than `width`. Text that already fits is returned unchanged, colour
|
|
85
|
+
* escapes included. Text that must be cut is cut as plain text: its ANSI escapes are dropped rather than cut
|
|
86
|
+
* in half. When the ellipsis cannot fit, it is omitted and the text is cut to `width`; a `width` of 0 or
|
|
87
|
+
* less gives `""`.
|
|
88
|
+
*
|
|
89
|
+
* @param text - the text to truncate
|
|
90
|
+
* @param width - the most columns the result may take
|
|
91
|
+
* @param options - the ellipsis
|
|
92
|
+
*/
|
|
93
|
+
static truncate = (text, width, options) => {
|
|
94
|
+
const limit = Number.isFinite(width) ? Math.floor(width) : width === Number.POSITIVE_INFINITY ? Infinity : 0;
|
|
95
|
+
if (limit <= 0) return "";
|
|
96
|
+
if (displayWidth(text) <= limit) return text;
|
|
97
|
+
const plain = stripAnsi(text);
|
|
98
|
+
const ellipsis = options?.ellipsis ?? "…";
|
|
99
|
+
const ellipsisWidth = displayWidth(ellipsis);
|
|
100
|
+
const useEllipsis = ellipsisWidth <= limit && ellipsis !== "";
|
|
101
|
+
const budget = useEllipsis ? limit - ellipsisWidth : limit;
|
|
102
|
+
let out = "";
|
|
103
|
+
let used = 0;
|
|
104
|
+
for (const grapheme of graphemes(plain)) {
|
|
105
|
+
const w = displayWidth(grapheme);
|
|
106
|
+
if (used + w > budget) break;
|
|
107
|
+
out += grapheme;
|
|
108
|
+
used += w;
|
|
109
|
+
}
|
|
110
|
+
return useEllipsis ? `${out}${ellipsis}` : out;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* A duration as short human text: `250ms`, `1.2s`, `2s`, `1m 3s`, `2m`, `1h 2m`, `2h`.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* Under a second it is whole milliseconds; under a minute, seconds to one decimal with a trailing `.0`
|
|
117
|
+
* dropped; under an hour, minutes and whole seconds, with the seconds dropped when zero; from an hour, hours and
|
|
118
|
+
* whole minutes, the seconds dropped. A value that rounds up to the next unit (`999.6` to a second, `59999` to a
|
|
119
|
+
* minute, `3599999` to an hour) is written in that unit, so `1000ms`, `60s` and `60m` never appear. There is no
|
|
120
|
+
* days unit. The format is bespoke: core's `Duration.format` renders differently.
|
|
121
|
+
*
|
|
122
|
+
* The input is anything `Duration.Input` admits. A plain number is milliseconds and is used as given, never
|
|
123
|
+
* converted through a `Duration`, which rounds a fractional millisecond to whole nanoseconds and would move a value
|
|
124
|
+
* such as `999.4999999` across the rounding boundary. A `bigint` is nanoseconds, as in core. A negative, infinite
|
|
125
|
+
* or invalid input (a string `Duration.Input` cannot parse) is `0ms`: a formatter in a render path never throws.
|
|
126
|
+
*
|
|
127
|
+
* @param input - the duration; a number is milliseconds
|
|
128
|
+
*/
|
|
129
|
+
static duration = (input) => {
|
|
130
|
+
const ms = typeof input === "number" ? input : Option.match(Duration.fromInput(input), {
|
|
131
|
+
onNone: () => 0,
|
|
132
|
+
onSome: Duration.toMillis
|
|
133
|
+
});
|
|
134
|
+
const value = Number.isFinite(ms) ? Math.max(0, ms) : 0;
|
|
135
|
+
const whole = Math.round(value);
|
|
136
|
+
if (whole < 1e3) return `${whole}ms`;
|
|
137
|
+
const tenths = Math.round(value / 100);
|
|
138
|
+
if (tenths < 600) return `${Number((tenths / 10).toFixed(1))}s`;
|
|
139
|
+
const seconds = Math.round(value / 1e3);
|
|
140
|
+
if (seconds >= 3600) {
|
|
141
|
+
const hours = Math.floor(seconds / 3600);
|
|
142
|
+
const remaining = Math.floor(seconds % 3600 / 60);
|
|
143
|
+
return remaining === 0 ? `${hours}h` : `${hours}h ${remaining}m`;
|
|
144
|
+
}
|
|
145
|
+
const minutes = Math.floor(seconds / 60);
|
|
146
|
+
const rest = seconds % 60;
|
|
147
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* A ratio from 0 to 1 as a percentage, or a number already from 0 to 100 with `scale: 100`: `83.3%`, `50%`,
|
|
151
|
+
* `100%`.
|
|
152
|
+
*
|
|
153
|
+
* @remarks
|
|
154
|
+
* Rounded to `digits` decimal places (one by default) with trailing zeros dropped, so whole values print
|
|
155
|
+
* without a decimal. A value that rounds to zero prints `0%`, never `-0%`. The input is not validated.
|
|
156
|
+
*
|
|
157
|
+
* @param n - the ratio, 0 to 1 (0 to 100 with `scale: 100`)
|
|
158
|
+
* @param options - the decimal places and the scale
|
|
159
|
+
*/
|
|
160
|
+
static percent = (n, options) => {
|
|
161
|
+
const digits = Math.min(10, Math.max(0, Math.floor(options?.digits ?? 1)));
|
|
162
|
+
const percent = options?.scale === 100 ? n : n * 100;
|
|
163
|
+
return `${Number(percent.toFixed(digits))}%`;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* A count with its noun: `1 test`, `2 tests`, `0 tests`.
|
|
167
|
+
*
|
|
168
|
+
* @param n - the count
|
|
169
|
+
* @param singular - the noun for exactly one
|
|
170
|
+
* @param plural - the noun otherwise; `singular` with an `s` appended by default
|
|
171
|
+
*/
|
|
172
|
+
static plural = (n, singular, plural) => `${n} ${n === 1 ? singular : plural ?? `${singular}s`}`;
|
|
173
|
+
/**
|
|
174
|
+
* Pad `text` to `width` terminal columns, measured as {@link Fmt.width} measures: a wide character counts two and
|
|
175
|
+
* an escape sequence none, so coloured text pads to the same column as plain text.
|
|
176
|
+
*
|
|
177
|
+
* @remarks
|
|
178
|
+
* Text already `width` columns or wider is returned unchanged: compose with {@link Fmt.truncate} to cut it. A
|
|
179
|
+
* `width` that is not a finite number pads nothing. Never throws.
|
|
180
|
+
*
|
|
181
|
+
* @param text - the text to pad
|
|
182
|
+
* @param width - the columns the result should take
|
|
183
|
+
* @param options - the alignment and the fill
|
|
184
|
+
*/
|
|
185
|
+
static pad = (text, width, options) => {
|
|
186
|
+
const missing = Number.isFinite(width) ? Math.floor(width) - displayWidth(text) : 0;
|
|
187
|
+
if (missing <= 0) return text;
|
|
188
|
+
const fill = options?.fill !== void 0 && displayWidth(options.fill) === 1 && stripAnsi(options.fill) === options.fill ? options.fill : " ";
|
|
189
|
+
switch (options?.align) {
|
|
190
|
+
case "right": return `${fill.repeat(missing)}${text}`;
|
|
191
|
+
case "center": {
|
|
192
|
+
const before = Math.floor(missing / 2);
|
|
193
|
+
return `${fill.repeat(before)}${text}${fill.repeat(missing - before)}`;
|
|
194
|
+
}
|
|
195
|
+
default: return `${text}${fill.repeat(missing)}`;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* `text` without its escape sequences: CSI (colour, cursor moves, erases), OSC (titles, OSC 8 hyperlinks) ending in
|
|
200
|
+
* BEL or ST, the DCS, SOS, PM and APC strings, and every other escape, the nF sequences (`ESC ( B`) and the
|
|
201
|
+
* two-byte ones (`ESC 7`, `ESC 8`, `ESC =`, `ESC c`).
|
|
202
|
+
*
|
|
203
|
+
* @remarks
|
|
204
|
+
* The same strip {@link Fmt.width} and {@link Fmt.sanitize} start from. Only complete sequences go: a lone ESC, or
|
|
205
|
+
* any other control character, is kept, and so is an ESC in front of a lowercase letter other than `c`, which a
|
|
206
|
+
* stray ESC before a word would otherwise cost the word its first letter. An unterminated CSI or OSC keeps its `[`
|
|
207
|
+
* or `]`. Use {@link Fmt.sanitize} for text that will be laid out and printed.
|
|
208
|
+
*
|
|
209
|
+
* @param text - the text
|
|
210
|
+
*/
|
|
211
|
+
static stripAnsi = (text) => stripAnsi(text);
|
|
212
|
+
/**
|
|
213
|
+
* Whether `text` is nothing but escape sequences, as {@link Fmt.stripAnsi} reads them: a cursor hide or show, a
|
|
214
|
+
* clear-screen, an erase-scrollback. Empty text is not escape-only, and neither is a bare control character
|
|
215
|
+
* (`\r`, BEL), which is not an escape sequence.
|
|
216
|
+
*
|
|
217
|
+
* @param text - the text
|
|
218
|
+
*/
|
|
219
|
+
static isEscapeOnly = (text) => isEscapeOnly(text);
|
|
220
|
+
/**
|
|
221
|
+
* Integers as compact ranges: `[1, 2, 3, 5, 10, 11]` is `"1-3,5,10-11"`, a run of consecutive numbers written
|
|
222
|
+
* `start-end` and the rest one by one, joined with commas.
|
|
223
|
+
*
|
|
224
|
+
* @remarks
|
|
225
|
+
* The input is sorted and its duplicates dropped first, so order does not matter. Anything that is not a finite
|
|
226
|
+
* integer is left out, and no input is `""`. Never throws.
|
|
227
|
+
*
|
|
228
|
+
* @param numbers - the integers, such as line numbers
|
|
229
|
+
*/
|
|
230
|
+
static ranges = (numbers) => {
|
|
231
|
+
const sorted = [...new Set(Array.from(numbers).filter((n) => Number.isInteger(n)))].sort((a, b) => a - b);
|
|
232
|
+
const parts = [];
|
|
233
|
+
let index = 0;
|
|
234
|
+
while (index < sorted.length) {
|
|
235
|
+
const start = sorted[index];
|
|
236
|
+
let end = start;
|
|
237
|
+
while (index + 1 < sorted.length && sorted[index + 1] === end + 1) end = sorted[++index];
|
|
238
|
+
parts.push(start === end ? `${start}` : `${start}-${end}`);
|
|
239
|
+
index++;
|
|
240
|
+
}
|
|
241
|
+
return parts.join(",");
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Any value as text, without ever throwing.
|
|
245
|
+
*
|
|
246
|
+
* @remarks
|
|
247
|
+
* A string is itself; a number, boolean, bigint or symbol is `String(value)`; `undefined` and `null` are
|
|
248
|
+
* `"undefined"` and `"null"`. A value with a string `message` (an `Error`, one from another realm, a plain
|
|
249
|
+
* `{ message }`) is that message. Anything else is its JSON, or `String(value)` when it has none (a cycle, a
|
|
250
|
+
* function), or `"[unprintable value]"` when that throws too. A getter that throws is a value with no message.
|
|
251
|
+
*
|
|
252
|
+
* The text is not sanitised: pass it through {@link Fmt.sanitize} before printing data you do not control. For
|
|
253
|
+
* the messages along an error's `cause` chain, use {@link Fmt.causeMessage}; for the two combined, use
|
|
254
|
+
* {@link Fmt.errorText}.
|
|
255
|
+
*
|
|
256
|
+
* It is `@effected/kit`'s `ErrorText.describe`, the same function, so a front end that cannot depend on this
|
|
257
|
+
* package (an MCP server) describes a value with the same words.
|
|
258
|
+
*
|
|
259
|
+
* @param value - the value
|
|
260
|
+
*/
|
|
261
|
+
static describe = ErrorText.describe;
|
|
262
|
+
/**
|
|
263
|
+
* Every distinct non-empty message along a value's `cause` chain, nearest first and joined with `: `, or
|
|
264
|
+
* `undefined` when nothing along it has one.
|
|
265
|
+
*
|
|
266
|
+
* @remarks
|
|
267
|
+
* The kit's one guarded cause-message read, the one its own errors render a preserved cause with. A node's message
|
|
268
|
+
* is a thrown string or a non-empty string `message` property, read without `instanceof Error`, so an error from
|
|
269
|
+
* another realm or a plain `{ message }` object counts. A deeper message the joined text already contains is not
|
|
270
|
+
* repeated, and one that contains the joined text replaces it, so a `SqlError` wrapping SQLite's own text reads
|
|
271
|
+
* `Failed to execute statement: SQLITE_BUSY: database is locked`. It is the whole chain, not the innermost
|
|
272
|
+
* message alone. The walk stops after eight nodes, so a cyclic chain terminates.
|
|
273
|
+
*
|
|
274
|
+
* Never throws: a `message` getter that throws leaves that node without text, and a `cause` getter that throws ends
|
|
275
|
+
* the walk with the text found so far, `undefined` when there is none, so a caller's own fallback
|
|
276
|
+
* (`?? Fmt.describe(value)`, which {@link Fmt.errorText} spells once) applies.
|
|
277
|
+
*
|
|
278
|
+
* It is `@effected/kit`'s `ErrorText.causeMessage`, the same function.
|
|
279
|
+
*
|
|
280
|
+
* @param value - the error, or whatever was thrown
|
|
281
|
+
*/
|
|
282
|
+
static causeMessage = ErrorText.causeMessage;
|
|
283
|
+
/**
|
|
284
|
+
* The text of an unknown failure: {@link Fmt.causeMessage} when the chain carries a message, otherwise
|
|
285
|
+
* {@link Fmt.describe}.
|
|
286
|
+
*
|
|
287
|
+
* @remarks
|
|
288
|
+
* Exactly `Fmt.causeMessage(value) ?? Fmt.describe(value)`, the fallback an I/O site turning a caught value into
|
|
289
|
+
* text would otherwise spell out by hand at every call. An error whose message is empty and whose chain carries none
|
|
290
|
+
* is `""`, as `describe` reads it. Never throws, and not sanitised: pass it through {@link Fmt.sanitize} before
|
|
291
|
+
* printing data you do not control. It is `@effected/kit`'s `ErrorText.of`, the same function.
|
|
292
|
+
*
|
|
293
|
+
* @param value - the error, or whatever was thrown
|
|
294
|
+
*/
|
|
295
|
+
static errorText = ErrorText.of;
|
|
296
|
+
/**
|
|
297
|
+
* The candidates nearest to `input`, for a "did you mean" hint: those at the smallest edit distance found, up to
|
|
298
|
+
* `maxDistance` edits (two by default), in the order given, each once. No candidate that close is `[]`.
|
|
299
|
+
*
|
|
300
|
+
* @remarks
|
|
301
|
+
* The distance is Levenshtein's (one insertion, deletion or substitution per edit), over UTF-16 code units and
|
|
302
|
+
* case-sensitive, with the same two-edit threshold and the same ties-all-kept rule `effect/cli` uses for an unknown
|
|
303
|
+
* subcommand or flag, so a program's own suggestions agree with the parser's. A candidate equal to `input` is at
|
|
304
|
+
* distance zero and is the only one returned.
|
|
305
|
+
*
|
|
306
|
+
* The cost is linear in the candidates' total length: a candidate whose length differs from `input`'s by more than
|
|
307
|
+
* `maxDistance` is skipped unmeasured, and only the band of the edit matrix within `maxDistance` of its diagonal is
|
|
308
|
+
* measured, so a long typed value cannot make it quadratic. A `maxDistance` that is not a non-negative integer is a programming mistake and
|
|
309
|
+
* throws a `RangeError`.
|
|
310
|
+
*
|
|
311
|
+
* ```ts
|
|
312
|
+
* Fmt.suggest("stauts", ["status", "stack", "build"]) // => ["status"]
|
|
313
|
+
* ```
|
|
314
|
+
*
|
|
315
|
+
* @param input - what was typed: an unknown key, a profile name, a phase name
|
|
316
|
+
* @param candidates - the names it could have meant
|
|
317
|
+
* @param options - the most edits a suggestion may be away
|
|
318
|
+
*/
|
|
319
|
+
static suggest = (input, candidates, options) => {
|
|
320
|
+
const max = options?.maxDistance ?? 2;
|
|
321
|
+
if (!Number.isInteger(max) || max < 0) throw new RangeError(`Fmt.suggest: maxDistance must be a non-negative integer, received ${max}`);
|
|
322
|
+
let best = max + 1;
|
|
323
|
+
let found = [];
|
|
324
|
+
for (const candidate of new Set(candidates)) {
|
|
325
|
+
const distance = boundedDistance(input, candidate, Math.min(max, best));
|
|
326
|
+
if (distance < best) {
|
|
327
|
+
best = distance;
|
|
328
|
+
found = [candidate];
|
|
329
|
+
} else if (distance === best) found.push(candidate);
|
|
330
|
+
}
|
|
331
|
+
return best <= max ? found : [];
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export { Fmt, sanitize };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { WorkflowCommand } from "@effected/github-commands";
|
|
2
|
+
|
|
3
|
+
// src/GithubAnnotation.ts
|
|
4
|
+
/**
|
|
5
|
+
* GitHub Actions annotations, as workflow commands.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
var GithubAnnotation = class {
|
|
10
|
+
constructor() {}
|
|
11
|
+
/**
|
|
12
|
+
* Format an annotation as a workflow command: `::error title=T,file=F,line=1,endLine=2,col=3,endColumn=4::message`.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* The message escapes `%`, CR and LF; a property value escapes those and `:` and `,`, per GitHub's
|
|
16
|
+
* [workflow-command documentation](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions).
|
|
17
|
+
* The percent sign is escaped first, so an escape that was just written is never escaped again. An unescaped line
|
|
18
|
+
* break in a message would let the text after it be read as a new command, which is why the escaping is not optional.
|
|
19
|
+
*
|
|
20
|
+
* A property that is not given is left out, and the properties are written in the order `title`, `file`, `line`,
|
|
21
|
+
* `endLine`, `col`, `endColumn`, the same as `@effected/github-commands`' `WorkflowCommand`, which this renders
|
|
22
|
+
* through.
|
|
23
|
+
*
|
|
24
|
+
* @param annotation - the level and the optional file, position and title
|
|
25
|
+
* @param message - the annotation's text
|
|
26
|
+
*/
|
|
27
|
+
static format = (annotation, message) => {
|
|
28
|
+
return WorkflowCommand.render(annotation.level, {
|
|
29
|
+
title: annotation.title,
|
|
30
|
+
file: annotation.file,
|
|
31
|
+
line: annotation.line,
|
|
32
|
+
endLine: annotation.endLine,
|
|
33
|
+
col: annotation.col,
|
|
34
|
+
endColumn: annotation.endColumn
|
|
35
|
+
}, message);
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { GithubAnnotation };
|
package/Glyphs.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/Glyphs.ts
|
|
2
|
+
/**
|
|
3
|
+
* The two glyph sets: Unicode, and a plain-ASCII fallback for terminals that cannot draw it.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* The sets are shared, so they and their nested values are frozen.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
var Glyphs = class Glyphs {
|
|
11
|
+
constructor() {}
|
|
12
|
+
/** Unicode symbols. */
|
|
13
|
+
static unicode = Object.freeze({
|
|
14
|
+
kind: "unicode",
|
|
15
|
+
ellipsis: "…",
|
|
16
|
+
spinner: Object.freeze([
|
|
17
|
+
"⠋",
|
|
18
|
+
"⠙",
|
|
19
|
+
"⠹",
|
|
20
|
+
"⠸",
|
|
21
|
+
"⠼",
|
|
22
|
+
"⠴",
|
|
23
|
+
"⠦",
|
|
24
|
+
"⠧",
|
|
25
|
+
"⠇",
|
|
26
|
+
"⠏"
|
|
27
|
+
]),
|
|
28
|
+
bullet: "•",
|
|
29
|
+
arrow: "→",
|
|
30
|
+
pathSeparator: Object.freeze({
|
|
31
|
+
human: "›",
|
|
32
|
+
agent: " > "
|
|
33
|
+
}),
|
|
34
|
+
spinnerIntervalMs: 80,
|
|
35
|
+
tree: Object.freeze({
|
|
36
|
+
branch: "├─ ",
|
|
37
|
+
last: "└─ ",
|
|
38
|
+
pipe: "│ ",
|
|
39
|
+
blank: " "
|
|
40
|
+
})
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Pick a glyph set without a service: the one `CliTheme` uses, as a pure function.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* `CliTheme.layer` reads `TERM` through `Config` and calls this, so the two agree. `StreamEnv` carries no
|
|
47
|
+
* `TERM`, and nothing else in it decides ASCII, so the caller passes `term` when it wants `auto` to mean
|
|
48
|
+
* something.
|
|
49
|
+
*
|
|
50
|
+
* @param options - whether to force ASCII or Unicode, and the `TERM` value `auto` reads
|
|
51
|
+
*/
|
|
52
|
+
static select = (options) => {
|
|
53
|
+
const ascii = options?.ascii ?? "auto";
|
|
54
|
+
return ascii === true || ascii === "auto" && options?.term === "dumb" ? Glyphs.ascii : Glyphs.unicode;
|
|
55
|
+
};
|
|
56
|
+
/** ASCII-only symbols. */
|
|
57
|
+
static ascii = Object.freeze({
|
|
58
|
+
kind: "ascii",
|
|
59
|
+
ellipsis: "...",
|
|
60
|
+
spinner: Object.freeze([
|
|
61
|
+
"-",
|
|
62
|
+
"\\",
|
|
63
|
+
"|",
|
|
64
|
+
"/"
|
|
65
|
+
]),
|
|
66
|
+
bullet: "*",
|
|
67
|
+
arrow: "->",
|
|
68
|
+
pathSeparator: Object.freeze({
|
|
69
|
+
human: ">",
|
|
70
|
+
agent: " > "
|
|
71
|
+
}),
|
|
72
|
+
spinnerIntervalMs: 80,
|
|
73
|
+
tree: Object.freeze({
|
|
74
|
+
branch: "|-- ",
|
|
75
|
+
last: "\\-- ",
|
|
76
|
+
pipe: "| ",
|
|
77
|
+
blank: " "
|
|
78
|
+
})
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export { Glyphs };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Runtime, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/NotInteractive.ts
|
|
4
|
+
/**
|
|
5
|
+
* A command needed to prompt, but there is no terminal to prompt on.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Exits `64` (BSD `EX_USAGE`) through core's `Runtime.errorExitCode` marker:
|
|
9
|
+
* the caller invoked the command the wrong way, so the fix is to run it in a
|
|
10
|
+
* terminal or pass the flag that supplies the answer. Its default rendering is
|
|
11
|
+
* one line, `not interactive: run in a terminal or pass the flag`, which is the error's `message`, so a consumer
|
|
12
|
+
* `render` can print `error.message` and keep it.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
var NotInteractive = class extends Schema.TaggedError()("NotInteractive", {}) {
|
|
17
|
+
/**
|
|
18
|
+
* The one line, `not interactive: run in a terminal or pass the flag`.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* A prototype getter, not a field, so it is not part of the encoded form, equality or a JSON dump. Assigning to
|
|
22
|
+
* it is ignored: a library that rewrites `error.message` must not make this error throw, which a getter-only
|
|
23
|
+
* property does in strict mode. The line is fixed.
|
|
24
|
+
*/
|
|
25
|
+
get message() {
|
|
26
|
+
return "not interactive: run in a terminal or pass the flag";
|
|
27
|
+
}
|
|
28
|
+
set message(_value) {}
|
|
29
|
+
/**
|
|
30
|
+
* The process exit code: `64`.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* A prototype getter rather than an own field, so a JSON or logger dump of the error does not carry the
|
|
34
|
+
* runtime marker. It is the error's own code, so `CliRuntime`'s `usageExitCode` option does not change it.
|
|
35
|
+
*/
|
|
36
|
+
get [Runtime.errorExitCode]() {
|
|
37
|
+
return 64;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export { NotInteractive };
|