@effected/cli 0.9.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.
Files changed (90) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +14 -20
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +253 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +294 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +83 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +104 -54
  14. package/CliTest.js +18 -2
  15. package/CliTheme.js +128 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +512 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +129 -131
  23. package/Render.js +254 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +163 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +2923 -171
  29. package/index.js +19 -1
  30. package/internal/HelpRouting.js +1 -1
  31. package/internal/ansi.js +230 -0
  32. package/internal/autoFormat.js +34 -0
  33. package/internal/canPrompt.js +15 -0
  34. package/internal/counts.js +69 -0
  35. package/internal/diagnostics.js +32 -0
  36. package/internal/displayWidth.js +35 -0
  37. package/internal/failureTarget.js +156 -0
  38. package/internal/fallbackAnswer.js +18 -0
  39. package/internal/fileSink.js +62 -0
  40. package/internal/format.js +62 -7
  41. package/internal/layout.js +250 -0
  42. package/internal/linkScheme.js +30 -0
  43. package/internal/linkTarget.js +50 -0
  44. package/internal/logSafety.js +46 -0
  45. package/internal/renderAnsi.js +52 -0
  46. package/internal/renderDoc.js +319 -0
  47. package/internal/renderGithubLog.js +46 -0
  48. package/internal/renderMarkdown.js +367 -0
  49. package/internal/renderPlain.js +50 -0
  50. package/internal/scanAudience.js +106 -0
  51. package/internal/splitFrame.js +56 -0
  52. package/internal/wizardGate.js +18 -0
  53. package/package.json +35 -5
  54. package/testing.d.ts +90 -4
  55. package/testing.js +2 -1
  56. package/ui/CliUi.js +348 -0
  57. package/ui/CliUiLive.js +399 -0
  58. package/ui/Confirm.js +245 -0
  59. package/ui/DocView.js +74 -0
  60. package/ui/KeyHelp.js +62 -0
  61. package/ui/KeyTable.js +199 -0
  62. package/ui/MultiSelect.js +260 -0
  63. package/ui/Select.js +226 -0
  64. package/ui/Tabs.js +202 -0
  65. package/ui/TextInput.js +250 -0
  66. package/ui/Toggle.js +32 -0
  67. package/ui/UiKey.js +44 -0
  68. package/ui/UiProvider.js +60 -0
  69. package/ui/UiStreams.js +18 -0
  70. package/ui/UiTheme.js +119 -0
  71. package/ui/Viewport.js +204 -0
  72. package/ui/internal/ErrorBoundary.js +30 -0
  73. package/ui/internal/Holder.js +74 -0
  74. package/ui/internal/ScreenContext.js +52 -0
  75. package/ui/internal/UiProviders.js +21 -0
  76. package/ui/internal/ink.js +122 -0
  77. package/ui/internal/inkChalk.js +58 -0
  78. package/ui/internal/inkConsole.js +146 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +735 -0
  85. package/ui/testing/fakeStreams.js +76 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing.d.ts +446 -0
  88. package/ui-testing.js +3 -0
  89. package/ui.d.ts +1648 -0
  90. 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 };
@@ -2,20 +2,17 @@ import { formatIssue } from "./internal/format.js";
2
2
 
3
3
  //#region src/SchemaIssueRenderer.ts
4
4
  /**
5
- * Turn a `SchemaIssue` tree into lines a user can act on.
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
- * **Core already ships the formatters this wraps, and they are effectively
12
- * undiscoverable.** They live on `SchemaIssue` rather than `SchemaError` or
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 };
@@ -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 };