@effected/cli 0.10.0 → 0.11.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 +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +13 -19
- package/CliEnv.js +89 -0
- package/CliExit.js +1 -1
- package/CliFailure.js +253 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +294 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +83 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +103 -53
- package/CliTest.js +16 -0
- package/CliTheme.js +128 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +512 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +129 -131
- package/Render.js +254 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +163 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +2923 -171
- package/index.js +19 -1
- package/internal/ansi.js +230 -0
- package/internal/autoFormat.js +34 -0
- package/internal/canPrompt.js +15 -0
- package/internal/counts.js +69 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +156 -0
- package/internal/fallbackAnswer.js +18 -0
- package/internal/fileSink.js +62 -0
- package/internal/format.js +62 -7
- package/internal/layout.js +250 -0
- package/internal/linkScheme.js +30 -0
- package/internal/linkTarget.js +50 -0
- package/internal/logSafety.js +46 -0
- package/internal/renderAnsi.js +52 -0
- package/internal/renderDoc.js +319 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +50 -0
- package/internal/scanAudience.js +106 -0
- package/internal/splitFrame.js +56 -0
- package/internal/wizardGate.js +18 -0
- package/package.json +35 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +348 -0
- package/ui/CliUiLive.js +399 -0
- package/ui/Confirm.js +245 -0
- package/ui/DocView.js +74 -0
- package/ui/KeyHelp.js +62 -0
- package/ui/KeyTable.js +199 -0
- package/ui/MultiSelect.js +260 -0
- package/ui/Select.js +226 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +250 -0
- package/ui/Toggle.js +32 -0
- package/ui/UiKey.js +44 -0
- package/ui/UiProvider.js +60 -0
- package/ui/UiStreams.js +18 -0
- package/ui/UiTheme.js +119 -0
- package/ui/Viewport.js +204 -0
- package/ui/internal/ErrorBoundary.js +30 -0
- package/ui/internal/Holder.js +74 -0
- package/ui/internal/ScreenContext.js +52 -0
- package/ui/internal/UiProviders.js +21 -0
- package/ui/internal/ink.js +122 -0
- package/ui/internal/inkChalk.js +58 -0
- package/ui/internal/inkConsole.js +146 -0
- package/ui/internal/lineText.js +19 -0
- package/ui/internal/mountPermit.js +16 -0
- package/ui/internal/perfDrain.js +33 -0
- package/ui/internal/processStreams.js +19 -0
- package/ui/internal/renderOptions.js +13 -0
- package/ui/testing/CliUiTest.js +735 -0
- package/ui/testing/fakeStreams.js +76 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing.d.ts +446 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1648 -0
- package/ui.js +17 -0
package/Render.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { underGithubActions } from "./internal/autoFormat.js";
|
|
2
|
+
import { CliLinks } from "./CliLinks.js";
|
|
3
|
+
import { Glyphs } from "./Glyphs.js";
|
|
4
|
+
import { paintStyle } from "./internal/ansi.js";
|
|
5
|
+
import { Token } from "./Token.js";
|
|
6
|
+
import { CliTheme, themeForAudience } from "./CliTheme.js";
|
|
7
|
+
import { renderAnsi } from "./internal/renderAnsi.js";
|
|
8
|
+
import { renderPlain } from "./internal/renderPlain.js";
|
|
9
|
+
import { renderGithubLog } from "./internal/renderGithubLog.js";
|
|
10
|
+
import { renderMarkdown } from "./internal/renderMarkdown.js";
|
|
11
|
+
import { Effect } from "effect";
|
|
12
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
13
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
14
|
+
|
|
15
|
+
//#region src/Render.ts
|
|
16
|
+
/** A renderer's text, with workflow commands neutralized when the context says the runner is reading it. */
|
|
17
|
+
const guarded = (text, ctx) => ctx.neutralizeWorkflowCommands === true ? CommandNeutralizer.text(text) : text;
|
|
18
|
+
/**
|
|
19
|
+
* Pure renderers of a document: `(doc, context) => string`.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* A renderer has no environment and no effects, so the same document and context always give the same string.
|
|
23
|
+
* {@link Render.plain} is for agents; the rest of the set follows.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { Doc, Render } from "@effected/cli"
|
|
28
|
+
*
|
|
29
|
+
* const doc = [Doc.heading(2, "Results"), Doc.paragraph("3 checks passed")]
|
|
30
|
+
* const ctx = Render.contextOf({ audience: "agent" })
|
|
31
|
+
*
|
|
32
|
+
* Render.plain(doc, ctx)
|
|
33
|
+
* // => "Results\n3 checks passed"
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
var Render = class {
|
|
39
|
+
constructor() {}
|
|
40
|
+
/**
|
|
41
|
+
* The {@link RenderContext} for a stream, from the services a CLI already has.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Everything is read once, here, so the renderers stay pure:
|
|
45
|
+
*
|
|
46
|
+
* - `audience` is the `Audience` in force, so an audience flag is honoured;
|
|
47
|
+
* - `color`, `paint` and `glyphs` are the `CliTheme`'s for THAT stream, so redirecting stdout does not quiet
|
|
48
|
+
* stderr; except that an agent's `color` is `none` and its `paint` the identity, so no renderer, `ansi` included,
|
|
49
|
+
* writes an escape for an agent whatever the terminal could do;
|
|
50
|
+
* - `link` is `CliLinks.linker` over that stream's hyperlink support and the audience, so an agent never
|
|
51
|
+
* gets an escape and a terminal without OSC 8 gets the label;
|
|
52
|
+
* - `neutralizeWorkflowCommands` is set when `CurrentRuntimeEnv` says GitHub Actions (read if present, not
|
|
53
|
+
* required), for every audience, since the runner reads whatever is written there;
|
|
54
|
+
* - `width` is the option, else `TerminalEnv.width()` for a human, and **unbounded** (`Infinity`) for an agent
|
|
55
|
+
* or a CI, so nothing a reader needs is truncated or wrapped for a terminal that is not there.
|
|
56
|
+
*
|
|
57
|
+
* @param stream - the stream the output is for
|
|
58
|
+
* @param options - an explicit width and a path display function
|
|
59
|
+
*/
|
|
60
|
+
static context = (stream, options) => Effect.gen(function* () {
|
|
61
|
+
const terminal = yield* TerminalEnv;
|
|
62
|
+
const { kind } = yield* Audience;
|
|
63
|
+
const theme = (yield* CliTheme).forStream(stream);
|
|
64
|
+
const seen = themeForAudience(theme, kind);
|
|
65
|
+
const links = yield* CliLinks;
|
|
66
|
+
return {
|
|
67
|
+
...(yield* underGithubActions) ? { neutralizeWorkflowCommands: true } : {},
|
|
68
|
+
width: options?.width ?? (kind === "human" ? terminal.width() : Number.POSITIVE_INFINITY),
|
|
69
|
+
audience: kind,
|
|
70
|
+
color: seen.color,
|
|
71
|
+
paint: seen.paint,
|
|
72
|
+
glyphs: theme.glyphs,
|
|
73
|
+
link: CliLinks.linker({
|
|
74
|
+
links,
|
|
75
|
+
hyperlinks: terminal[stream].hyperlinks,
|
|
76
|
+
audience: kind
|
|
77
|
+
}),
|
|
78
|
+
displayPath: options?.displayPath ?? ((absolute) => absolute)
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* A {@link RenderContext} from plain options, for a caller outside Effect, such as a test reporter or an Ink tree.
|
|
83
|
+
*
|
|
84
|
+
* @remarks
|
|
85
|
+
* Pure: nothing is read from the environment. The defaults are colour `none`, the identity paint, no links,
|
|
86
|
+
* Unicode glyphs, unbounded width and the identity `displayPath`, so a context built from an audience alone renders
|
|
87
|
+
* with no escape of any kind. A colour level paints with the default token styles. An `agent` is colourless and
|
|
88
|
+
* unlinked whatever `color` and `links` say, as in {@link Render.context}.
|
|
89
|
+
*
|
|
90
|
+
* @param options - the audience, and the colour, glyphs, width, path display, links, neutralizing and link base
|
|
91
|
+
*/
|
|
92
|
+
static contextOf = (options) => {
|
|
93
|
+
const agent = options.audience === "agent";
|
|
94
|
+
const color = agent ? "none" : options.color ?? "none";
|
|
95
|
+
const links = agent ? "off" : options.links ?? "off";
|
|
96
|
+
return {
|
|
97
|
+
width: options.width ?? Number.POSITIVE_INFINITY,
|
|
98
|
+
audience: options.audience,
|
|
99
|
+
color,
|
|
100
|
+
paint: color === "none" ? (_token, text) => text : (token, text) => paintStyle(Token.resolve(token), color, text),
|
|
101
|
+
glyphs: options.glyphs ?? Glyphs.unicode,
|
|
102
|
+
link: links === "off" ? (_target, label) => label : CliLinks.linker({
|
|
103
|
+
links,
|
|
104
|
+
hyperlinks: true,
|
|
105
|
+
audience: options.audience
|
|
106
|
+
}),
|
|
107
|
+
displayPath: options.displayPath ?? ((absolute) => absolute),
|
|
108
|
+
...options.neutralizeWorkflowCommands ?? options.audience === "ci" ? { neutralizeWorkflowCommands: true } : options.neutralizeWorkflowCommands === false ? { neutralizeWorkflowCommands: false } : {},
|
|
109
|
+
...options.linkBase === void 0 ? {} : { linkBase: options.linkBase }
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Render a document as plain text for an agent.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* There are no escape sequences of any kind, whatever the context's colour or hyperlinks allow: `paint` and
|
|
117
|
+
* `link` are never called, and a control character in a document's text is removed. The audience is treated as
|
|
118
|
+
* `agent`, so a path joins with ` > `.
|
|
119
|
+
*
|
|
120
|
+
* - A heading is its text alone, code is in backticks, and a link is its label followed by the target in
|
|
121
|
+
* parentheses, as `path:line:col` through `displayPath` for a file, unless the label already is the target.
|
|
122
|
+
* - A paragraph wraps to the width and is never truncated; a word longer than the width, such as a URL, stays
|
|
123
|
+
* whole on its own line.
|
|
124
|
+
* - A list uses `- ` items, and past its cap the overflow row. A table is aligned text columns with a rule under
|
|
125
|
+
* the header; a short row is padded with empty cells, a cell holding line breaks shows its first line and an
|
|
126
|
+
* ellipsis, and cells are truncated only when the table is wider than the context, widest column first. A tree uses the glyph set's tree segments.
|
|
127
|
+
* - A collapsible is its title and the indented body, a callout its upper-case kind and the body, a code block
|
|
128
|
+
* four-space indented, and a diff `- expected` lines then `+ received` lines, the cap limiting each side.
|
|
129
|
+
* - Counts take their total and their visible counters from {@link Doc.total} and {@link Doc.visibleCounters}.
|
|
130
|
+
* Inline gives `3/5 passed, 1 failed (1.2s)`: the first counter is the headline and shows its share of the
|
|
131
|
+
* total, unless `share` is `false`. Columns gives aligned label and number pairs, and row one line of cells.
|
|
132
|
+
* - A link with `suffix: false` never has its target after the label, and one with `suffix: true` always does.
|
|
133
|
+
* - Verbatim text is its lines exactly, each indented by `indent` spaces, never wrapped; an annotation is nothing.
|
|
134
|
+
* - Strong and emphasised content is its text; a file is its display path, unlinked. `Lines` are one line per entry,
|
|
135
|
+
* a `Line` with `truncate` is cut to the width with the ellipsis, and diff text is its lines as given, the cap
|
|
136
|
+
* followed by `… N more lines`. A counts table is a table with a column per counter key and the total row last,
|
|
137
|
+
* and a counts `suffix` follows the duration.
|
|
138
|
+
* - A compact list has no blank lines inside an item. A `style: "pipe"` table is istanbul's shape: a rule of dashes
|
|
139
|
+
* meeting at `|` above and below the header and at the end, and cells joined with ` | `.
|
|
140
|
+
* - Top-level blocks are consecutive lines; a section separates its title and children with blank lines.
|
|
141
|
+
*
|
|
142
|
+
* @param doc - the document
|
|
143
|
+
* @param ctx - where the output is going
|
|
144
|
+
*/
|
|
145
|
+
static plain = (doc, ctx) => guarded(renderPlain(doc, ctx), ctx);
|
|
146
|
+
/**
|
|
147
|
+
* Render a document for a person: the same layout as {@link Render.plain}, painted and linked.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* The context's `paint` and `link` do the styling, so a context at colour `none` with links off gives exactly
|
|
151
|
+
* what `plain` gives, apart from two things: code has no backticks (it is painted `accent` instead), and a path
|
|
152
|
+
* joins with the audience's separator (`›` for a person) rather than ` > `. Tokens:
|
|
153
|
+
*
|
|
154
|
+
* - headings, section and collapsible titles, and table headers are `emphasis`; the rule under a header, tree
|
|
155
|
+
* lines and overflow rows are `muted`;
|
|
156
|
+
* - a status glyph takes its definition's token, and a diff's `-` lines are `failure` and `+` lines `success`;
|
|
157
|
+
* - a callout's label takes its kind's token (`note` info, `tip` success, `important` accent, `warning` warning,
|
|
158
|
+
* `caution` error), and a counter the token of its status, with the qualifier, the duration and the suffix `muted`.
|
|
159
|
+
* A `Counts` with `paint: "none"` paints none of it, and with `paint: "glyph"` only a status glyph.
|
|
160
|
+
* - strong content is bold and emphasised content italic, over any token it has; in diff text a `+` line is
|
|
161
|
+
* `success` and a `-` line `failure`; a counts table's counts take their status's token.
|
|
162
|
+
*
|
|
163
|
+
* A link goes through `ctx.link`, which makes an OSC 8 hyperlink only when the policy allows it. When it does
|
|
164
|
+
* not (it returns the label unchanged), the target follows the label in parentheses, muted, as in plain text.
|
|
165
|
+
*
|
|
166
|
+
* Text is cut and wrapped before it is painted, so a colour or a hyperlink is never cut in half, and a table
|
|
167
|
+
* cut to the width keeps the colour of what remains. Tables are plain aligned columns, never box drawing:
|
|
168
|
+
* box drawing costs two columns of every row for nothing a rule and the padding do not already say, and it
|
|
169
|
+
* cannot be matched to plain text.
|
|
170
|
+
*
|
|
171
|
+
* @param doc - the document
|
|
172
|
+
* @param ctx - where the output is going
|
|
173
|
+
*/
|
|
174
|
+
static ansi = (doc, ctx) => guarded(renderAnsi(doc, ctx), ctx);
|
|
175
|
+
/**
|
|
176
|
+
* Render a document as GitHub-flavoured markdown, for a step summary or a file.
|
|
177
|
+
*
|
|
178
|
+
* @remarks
|
|
179
|
+
* There is no ANSI and no OSC 8 (`paint` and `link` are never called), and the width does not apply: a reader
|
|
180
|
+
* wraps. Everything a document carries as text is escaped so that it cannot become markdown: the characters
|
|
181
|
+
* that mean something, `|` everywhere so text can never form a table, the marker at the start of a line (a
|
|
182
|
+
* heading, bullet, setext underline or ordered item) and the start of an autolink (a URL scheme or `www.`). An
|
|
183
|
+
* email address is not escaped: a reader may make a `mailto:` link of it, which is harmless. A leading indent is
|
|
184
|
+
* dropped, since markdown would read it as code.
|
|
185
|
+
*
|
|
186
|
+
* Under GitHub Actions (`neutralizeWorkflowCommands`) the same neutralizing applies, since markdown can be printed
|
|
187
|
+
* to the log. Markdown escapes `[` in text, so `##[` cannot appear outside code and the headings are untouched
|
|
188
|
+
* (a bare `##` is not a command); code spans and blocks, which are not escaped, get the zero-width space, which can
|
|
189
|
+
* also land inside code or table text where it would otherwise have formed a command.
|
|
190
|
+
*
|
|
191
|
+
* GitHub also turns `@user`, `@org/team`, `#123` and commit SHAs in rendered markdown into mentions and references.
|
|
192
|
+
* Nothing here escapes them: in a step summary they do not notify, but markdown posted as a comment could ping
|
|
193
|
+
* whoever the text names.
|
|
194
|
+
*
|
|
195
|
+
* - A heading is `#` repeated by its level. A section's title is a heading of level 2 for a section at the top,
|
|
196
|
+
* one deeper for each section nested inside it, to level 6.
|
|
197
|
+
* - A table is a GFM pipe table: `|` is `\|` in a cell and a line break in a cell is `<br>`. A short row is
|
|
198
|
+
* padded and a long one widens the table. With no header, the header row is empty.
|
|
199
|
+
* - A collapsible is `<details><summary>title</summary>`, a blank line, the body as markdown, a blank line and
|
|
200
|
+
* `</details>`. The title is HTML, so it is HTML-escaped and plain.
|
|
201
|
+
* - A callout is a quoted `[!KIND]` followed by its body. A code block is a fence longer than any backtick run it
|
|
202
|
+
* holds, and a diff a `diff` fence of `-` and `+` lines.
|
|
203
|
+
* - A link is `[label](url)` when it has a URL a reader can follow: an `http`, `https`, `mailto`, `file` or
|
|
204
|
+
* `vscode` URL, or a relative one. A file link has one when its path is absolute (`file://`). Otherwise, such as
|
|
205
|
+
* for a `javascript:` URL or a relative file path, it is the label followed by the target in inline code, as
|
|
206
|
+
* `path:line:col` for a file.
|
|
207
|
+
* - A list is bullets, a tree a nested bullet list under its root label, and overflow rows paragraphs after what
|
|
208
|
+
* they cap. Counts inline is a paragraph, columns a list of `label: n` and row a one-row table of the counter
|
|
209
|
+
* labels over their numbers, every header named (the duration as `duration`), with the label above the table and
|
|
210
|
+
* the qualifier and suffix below it.
|
|
211
|
+
* - Verbatim text is a fenced code block, so its indentation survives; an annotation is nothing.
|
|
212
|
+
* - Strong content is `**…**` and emphasised `*…*`, which GFM reads inside a word too, with the spaces at a run's
|
|
213
|
+
* edges kept outside the markers. `Lines` are one paragraph with a hard break between entries, an empty entry
|
|
214
|
+
* between two others an empty line (one at either end is dropped); a `Line` is one
|
|
215
|
+
* paragraph; diff text a `diff` fence; a counts table a pipe table; a file is its display path as text.
|
|
216
|
+
* - With `linkBase`, a file link goes to the base and the display path, plus `#L<line>`, in place of `file://`. A
|
|
217
|
+
* display path that is absolute or climbs out with `..` is not under the base, so its link has no URL form.
|
|
218
|
+
* - A compact list item joins its parts with no blank line, putting a hard break where two paragraphs would merge.
|
|
219
|
+
* A row of counts names its duration column `duration`.
|
|
220
|
+
*
|
|
221
|
+
* @param doc - the document
|
|
222
|
+
* @param ctx - where the output is going; its glyph set, audience and `displayPath` are used
|
|
223
|
+
*/
|
|
224
|
+
static markdown = (doc, ctx) => guarded(renderMarkdown(doc, ctx), ctx);
|
|
225
|
+
/**
|
|
226
|
+
* Render a document for a GitHub Actions log.
|
|
227
|
+
*
|
|
228
|
+
* @remarks
|
|
229
|
+
* Everything is what {@link Render.plain} renders, except an annotation, which is one workflow command
|
|
230
|
+
* (`::error file=…,line=…::message`), and a collapsible that starts a line, which is a group: `::group::title`, its
|
|
231
|
+
* body, `::endgroup::`. An annotation is a command at the top level, as a top-level section's child, and as a direct
|
|
232
|
+
* child of a group's body; anywhere deeper (inside a list, a callout, or a section within a group) it is nothing,
|
|
233
|
+
* as in plain. Its message and properties are escaped, so no text can end
|
|
234
|
+
* the command or start another, and the kit's own command is never neutralized. That is a top-level collapsible, or one that is a direct child of a
|
|
235
|
+
* top-level section. GitHub does not nest groups, so a collapsible inside a group, or inside a list or callout
|
|
236
|
+
* (where it would not start a line), keeps plain's rendering: its title on a line and its body indented.
|
|
237
|
+
*
|
|
238
|
+
* The runner has two command parsers, and a line is a command if either accepts it: after its leading whitespace it
|
|
239
|
+
* starts with `::`, or `##[` occurs ANYWHERE in it (a bare `##` is not one). A document's text must not be able to
|
|
240
|
+
* do that (`::add-mask::`, `::error::`, `##[error]`), so such a `::` line gets a zero-width space in front, which
|
|
241
|
+
* the runner does not treat as whitespace, and every `##[` gets one between the `##` and the `[`. The text is
|
|
242
|
+
* otherwise unchanged. A
|
|
243
|
+
* group's title is a command's data, so its `%`, CR and LF are escaped. Lines are split at CR, LF and CRLF before
|
|
244
|
+
* that check, as the runner splits them. There is no ANSI and `paint` and `link` are never called, and the audience
|
|
245
|
+
* is treated as `agent`, as `plain` does.
|
|
246
|
+
*
|
|
247
|
+
* @param doc - the document
|
|
248
|
+
* @param ctx - where the output is going; the width, glyph set and `displayPath` are used as plain uses them
|
|
249
|
+
*/
|
|
250
|
+
static githubLog = (doc, ctx) => renderGithubLog(doc, ctx);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
export { Render };
|
package/SchemaIssueRenderer.js
CHANGED
|
@@ -2,20 +2,17 @@ import { formatIssue } from "./internal/format.js";
|
|
|
2
2
|
|
|
3
3
|
//#region src/SchemaIssueRenderer.ts
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Turns a `SchemaIssue` tree into lines a user can act on.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
8
|
* A decode failure arrives as a structured tree; a person needs
|
|
9
|
-
* `unknown key at groups.g.cleanup.rulesetz`.
|
|
9
|
+
* `unknown key at groups.g.cleanup.rulesetz`. The formatters core ships for
|
|
10
|
+
* this live on `SchemaIssue` rather than on `SchemaError` or `Schema`, and
|
|
11
|
+
* `SchemaError.message` does not use them, so printing the error alone does not
|
|
12
|
+
* give these lines.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `Schema`, they are named `makeFormatter*` rather than anything containing
|
|
14
|
-
* "render" or "format issue", and `SchemaError.message` does not use them — so
|
|
15
|
-
* the obvious probe, printing the error, hints at nothing. Two engineers
|
|
16
|
-
* searched for two rounds and concluded core had none. This export exists to
|
|
17
|
-
* end that search, and the one phrasing override is a bonus rather than the
|
|
18
|
-
* point.
|
|
14
|
+
* The lines and `CliFailure`'s tree are two views of the same rejected values (`internal/format`), so a schema
|
|
15
|
+
* failure in the default report and these lines never disagree.
|
|
19
16
|
*
|
|
20
17
|
* @example
|
|
21
18
|
* ```ts
|
package/Status.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Array, Option } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/Status.ts
|
|
4
|
+
/**
|
|
5
|
+
* An open vocabulary of statuses.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Start from `Status.core` and add your own with `extend`. The names are a type parameter,
|
|
9
|
+
* so `def` and `worst` reject a name the vocabulary does not have at compile time.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { Status } from "@effected/cli"
|
|
14
|
+
*
|
|
15
|
+
* const vocab = Status.extend({
|
|
16
|
+
* timeout: { glyph: "⏱", ascii: "[time]", token: "warning", rank: 85 },
|
|
17
|
+
* })
|
|
18
|
+
* const worst = vocab.worst(["success", "timeout"])
|
|
19
|
+
* // => "timeout"
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
var Status = class Status {
|
|
25
|
+
defs;
|
|
26
|
+
constructor(defs) {
|
|
27
|
+
this.defs = defs;
|
|
28
|
+
}
|
|
29
|
+
/** The core vocabulary: success, skip, pending, info, warning and failure, by rank 10, 20, 30, 40, 60, 90. */
|
|
30
|
+
static core = new Status({
|
|
31
|
+
success: {
|
|
32
|
+
glyph: "✓",
|
|
33
|
+
ascii: "[ok]",
|
|
34
|
+
token: "success",
|
|
35
|
+
rank: 10
|
|
36
|
+
},
|
|
37
|
+
skip: {
|
|
38
|
+
glyph: "↷",
|
|
39
|
+
ascii: "[skip]",
|
|
40
|
+
token: "muted",
|
|
41
|
+
rank: 20
|
|
42
|
+
},
|
|
43
|
+
pending: {
|
|
44
|
+
glyph: "◯",
|
|
45
|
+
ascii: "[ ]",
|
|
46
|
+
token: "muted",
|
|
47
|
+
rank: 30
|
|
48
|
+
},
|
|
49
|
+
info: {
|
|
50
|
+
glyph: "ℹ",
|
|
51
|
+
ascii: "[info]",
|
|
52
|
+
token: "info",
|
|
53
|
+
rank: 40
|
|
54
|
+
},
|
|
55
|
+
warning: {
|
|
56
|
+
glyph: "⚠",
|
|
57
|
+
ascii: "[warn]",
|
|
58
|
+
token: "warning",
|
|
59
|
+
rank: 60
|
|
60
|
+
},
|
|
61
|
+
failure: {
|
|
62
|
+
glyph: "✗",
|
|
63
|
+
ascii: "[FAIL]",
|
|
64
|
+
token: "failure",
|
|
65
|
+
rank: 90
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* The core vocabulary plus `extra`; an entry that reuses a core name replaces it.
|
|
70
|
+
*
|
|
71
|
+
* @param extra - the statuses to add, by name
|
|
72
|
+
*/
|
|
73
|
+
static extend = (extra) => Status.core.extend(extra);
|
|
74
|
+
/**
|
|
75
|
+
* This vocabulary plus `extra`; an entry that reuses a name replaces it.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* An entry may replace a core name. Replacing `warning` with a lower rank moves the threshold at which
|
|
79
|
+
* `CliMessage.status` defaults to stderr for this vocabulary, since that threshold is `warning`'s rank in the
|
|
80
|
+
* vocabulary it is given.
|
|
81
|
+
*
|
|
82
|
+
* @param extra - the statuses to add, by name
|
|
83
|
+
*/
|
|
84
|
+
extend(extra) {
|
|
85
|
+
return new Status({
|
|
86
|
+
...this.defs,
|
|
87
|
+
...extra
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The definition of a status.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* A name the vocabulary does not have is a defect: it throws an `Error` naming it and the names that exist. The
|
|
95
|
+
* types already reject one, so it is reachable only through a cast.
|
|
96
|
+
*
|
|
97
|
+
* @param name - a name in this vocabulary
|
|
98
|
+
*/
|
|
99
|
+
def(name) {
|
|
100
|
+
if (!Object.hasOwn(this.defs, name)) throw new Error(`Unknown status "${name}"; this vocabulary has: ${Object.keys(this.defs).join(", ")}`);
|
|
101
|
+
return this.defs[name];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The full definition of a status as an immutable snapshot, for a caller that stores it.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* `def` answers the vocabulary's own entry. `resolve` answers a frozen copy, so a document node that holds
|
|
108
|
+
* the definition stays plain data and editing it cannot change the vocabulary. The copy is shallow: a
|
|
109
|
+
* `token` given as a `Style` keeps its own identity. Throws on an unknown name, as {@link Status.def} does;
|
|
110
|
+
* storing an empty definition in a document instead would fail far from the cause.
|
|
111
|
+
*
|
|
112
|
+
* @param name - a name in this vocabulary
|
|
113
|
+
*/
|
|
114
|
+
resolve(name) {
|
|
115
|
+
return Object.freeze({ ...this.def(name) });
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A status's glyph from a glyph set: `def.ascii` for an ASCII set, `def.glyph` otherwise. Unpainted, for a caller
|
|
119
|
+
* that draws it itself (an Ink tree, a reporter).
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* Throws on an unknown name, as {@link Status.def} does.
|
|
123
|
+
*
|
|
124
|
+
* @param name - a name in this vocabulary
|
|
125
|
+
* @param glyphs - the glyph set, such as `Glyphs.unicode`, `Glyphs.ascii` or a theme's
|
|
126
|
+
*/
|
|
127
|
+
glyph(name, glyphs) {
|
|
128
|
+
const def = this.def(name);
|
|
129
|
+
return glyphs.kind === "ascii" ? def.ascii : def.glyph;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The status with the highest rank; a tie goes to the one that comes first in `names`.
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* `rank` is SEVERITY, not an aggregation policy: the higher rank wins, so in `Status.core` a `skip` outranks a
|
|
136
|
+
* `success`. A consumer whose aggregate differs (a test run where passes dominate skips, say) folds its own
|
|
137
|
+
* rule over the names instead of reading this.
|
|
138
|
+
*
|
|
139
|
+
* Takes at least one name, so the answer is always a name. For an array that may be empty, use
|
|
140
|
+
* {@link Status.worstOption}. They are two methods because a literal and an array variable are the same
|
|
141
|
+
* array at runtime, so one method could not return a name for one and an `Option` for the other.
|
|
142
|
+
*
|
|
143
|
+
* @param names - the statuses to compare
|
|
144
|
+
*/
|
|
145
|
+
worst(names) {
|
|
146
|
+
let worst = names[0];
|
|
147
|
+
for (const name of names) if (this.def(name).rank > this.def(worst).rank) worst = name;
|
|
148
|
+
return worst;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The status with the highest rank of an array that may be empty: `None` when it is, otherwise `Some` of
|
|
152
|
+
* the worst, a tie going to the one that comes first in `names`. Rank is severity, not an aggregation policy; see
|
|
153
|
+
* {@link Status.worst}.
|
|
154
|
+
*
|
|
155
|
+
* @param names - the statuses to compare
|
|
156
|
+
*/
|
|
157
|
+
worstOption(names) {
|
|
158
|
+
return Array.isReadonlyArrayNonEmpty(names) ? Option.some(this.worst(names)) : Option.none();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
//#endregion
|
|
163
|
+
export { Status };
|
package/TestTerminal.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Effect, Layer, Option, Queue, Terminal } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/TestTerminal.ts
|
|
4
|
+
/**
|
|
5
|
+
* A scripted `Terminal` for testing prompts and anything that reads the terminal.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Queue keys with `input` or `type`, run the program under `layer`, then read `output`. To assert that a code path
|
|
9
|
+
* did NOT touch the terminal, queue some keys first and check `reads` is all zero (no subscription, no key, no
|
|
10
|
+
* line) and `pending` is unchanged afterwards. Only
|
|
11
|
+
* available from `@effected/cli/testing`.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
var TestTerminal = class {
|
|
16
|
+
constructor() {}
|
|
17
|
+
/**
|
|
18
|
+
* Build a test terminal.
|
|
19
|
+
*
|
|
20
|
+
* @param options - the reported size; 80 by 24 by default
|
|
21
|
+
*/
|
|
22
|
+
static make = (options) => Effect.gen(function* () {
|
|
23
|
+
const queue = yield* Queue.unbounded();
|
|
24
|
+
const written = [];
|
|
25
|
+
let offered = 0;
|
|
26
|
+
let lines = 0;
|
|
27
|
+
let subscriptions = 0;
|
|
28
|
+
const offer = (inputs) => Effect.suspend(() => {
|
|
29
|
+
offered += inputs.length;
|
|
30
|
+
return Queue.offerAll(queue, inputs);
|
|
31
|
+
}).pipe(Effect.asVoid);
|
|
32
|
+
const terminal = Terminal.make({
|
|
33
|
+
columns: Effect.succeed(options?.columns ?? 80),
|
|
34
|
+
rows: Effect.succeed(options?.rows ?? 24),
|
|
35
|
+
readInput: Effect.sync(() => {
|
|
36
|
+
subscriptions++;
|
|
37
|
+
return queue;
|
|
38
|
+
}),
|
|
39
|
+
readLine: Effect.suspend(() => {
|
|
40
|
+
lines++;
|
|
41
|
+
return Effect.fail(new Terminal.QuitError({}));
|
|
42
|
+
}),
|
|
43
|
+
display: (text) => Effect.sync(() => {
|
|
44
|
+
written.push(text);
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
layer: Layer.succeed(Terminal.Terminal, terminal),
|
|
49
|
+
input: (keys) => offer(keys.map((key) => ({
|
|
50
|
+
input: Option.none(),
|
|
51
|
+
key: {
|
|
52
|
+
name: key.name,
|
|
53
|
+
ctrl: key.ctrl ?? false,
|
|
54
|
+
meta: key.meta ?? false,
|
|
55
|
+
shift: key.shift ?? false
|
|
56
|
+
}
|
|
57
|
+
}))),
|
|
58
|
+
type: (text) => offer(Array.from(text, (char) => ({
|
|
59
|
+
input: Option.some(char),
|
|
60
|
+
key: {
|
|
61
|
+
name: char,
|
|
62
|
+
ctrl: false,
|
|
63
|
+
meta: false,
|
|
64
|
+
shift: false
|
|
65
|
+
}
|
|
66
|
+
}))),
|
|
67
|
+
end: Queue.end(queue).pipe(Effect.asVoid),
|
|
68
|
+
output: Effect.sync(() => written.join("")),
|
|
69
|
+
pending: Queue.size(queue),
|
|
70
|
+
reads: Effect.map(Queue.size(queue), (size) => ({
|
|
71
|
+
keys: offered - size,
|
|
72
|
+
lines,
|
|
73
|
+
subscriptions
|
|
74
|
+
}))
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
export { TestTerminal };
|
package/Token.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//#region src/Token.ts
|
|
2
|
+
/** The default style of every token. Deeply frozen: the record is shared. */
|
|
3
|
+
const DEFAULTS = Object.freeze({
|
|
4
|
+
success: Object.freeze({ fg: "green" }),
|
|
5
|
+
failure: Object.freeze({ fg: "red" }),
|
|
6
|
+
error: Object.freeze({
|
|
7
|
+
fg: "red",
|
|
8
|
+
bold: true
|
|
9
|
+
}),
|
|
10
|
+
warning: Object.freeze({ fg: "yellow" }),
|
|
11
|
+
info: Object.freeze({ fg: "cyan" }),
|
|
12
|
+
muted: Object.freeze({ dim: true }),
|
|
13
|
+
accent: Object.freeze({ fg: "cyan" }),
|
|
14
|
+
emphasis: Object.freeze({ bold: true })
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Constructors for {@link Style} values, and the pure resolution of a token to one.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
var Token = class {
|
|
22
|
+
constructor() {}
|
|
23
|
+
/**
|
|
24
|
+
* The default {@link Style} of every token, frozen.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Data, not a service: it is what `CliTheme` starts from, and a renderer with no Effect context (an Ink
|
|
28
|
+
* component) reads it directly.
|
|
29
|
+
*/
|
|
30
|
+
static defaults = DEFAULTS;
|
|
31
|
+
/**
|
|
32
|
+
* The style a token or style resolves to, as a pure function: no service, no terminal.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* An explicit style resolves to itself. A token name resolves to its override when `overrides` has one, else
|
|
36
|
+
* its default; a name that is not a token (including an `Object.prototype` member) resolves to the empty
|
|
37
|
+
* style. This is the same resolution `CliTheme.paint` applies, which is what `StreamTheme.style` reports.
|
|
38
|
+
*
|
|
39
|
+
* @param token - a token name or an explicit style
|
|
40
|
+
* @param overrides - styles that replace the default of a token, as `CliThemeOptions.tokens` does
|
|
41
|
+
*/
|
|
42
|
+
static resolve = (token, overrides) => {
|
|
43
|
+
if (typeof token !== "string") return token;
|
|
44
|
+
const own = overrides !== void 0 && Object.hasOwn(overrides, token) ? overrides[token] : void 0;
|
|
45
|
+
if (own !== void 0) return own;
|
|
46
|
+
return Object.hasOwn(DEFAULTS, token) ? DEFAULTS[token] : {};
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A foreground from a hex colour.
|
|
50
|
+
*
|
|
51
|
+
* @param hex - `#rrggbb` (or `#rgb`)
|
|
52
|
+
*/
|
|
53
|
+
static hex = (hex) => ({ fg: hex });
|
|
54
|
+
/**
|
|
55
|
+
* A foreground from a named colour.
|
|
56
|
+
*
|
|
57
|
+
* @param color - the colour
|
|
58
|
+
*/
|
|
59
|
+
static named = (color) => ({ fg: color });
|
|
60
|
+
/**
|
|
61
|
+
* A style, as written. Exists so a style reads as a token at a call site.
|
|
62
|
+
*
|
|
63
|
+
* @param style - the style
|
|
64
|
+
*/
|
|
65
|
+
static style = (style) => style;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
export { Token };
|