@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/index.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
+
import { Cancelled } from "./Cancelled.js";
|
|
2
|
+
import { CliInteractive } from "./CliInteractive.js";
|
|
3
|
+
import { Fmt } from "./Fmt.js";
|
|
4
|
+
import { CliLinks } from "./CliLinks.js";
|
|
5
|
+
import { Glyphs } from "./Glyphs.js";
|
|
6
|
+
import { Token } from "./Token.js";
|
|
7
|
+
import { CliTheme } from "./CliTheme.js";
|
|
8
|
+
import { Render } from "./Render.js";
|
|
9
|
+
import { Doc } from "./Doc.js";
|
|
10
|
+
import { NotInteractive } from "./NotInteractive.js";
|
|
11
|
+
import { Status } from "./Status.js";
|
|
12
|
+
import { CliDoc, CliFailure } from "./CliFailure.js";
|
|
13
|
+
import { CliAudience } from "./CliAudience.js";
|
|
1
14
|
import { CliColor } from "./CliColor.js";
|
|
15
|
+
import { CliPrompt } from "./CliPrompt.js";
|
|
16
|
+
import { CliEnv } from "./CliEnv.js";
|
|
2
17
|
import { CliExit } from "./CliExit.js";
|
|
3
18
|
import { CliLogger } from "./CliLogger.js";
|
|
19
|
+
import { CliLog } from "./CliLog.js";
|
|
20
|
+
import { CliMessage } from "./CliMessage.js";
|
|
4
21
|
import { CliRuntime } from "./CliRuntime.js";
|
|
5
22
|
import { ConfigIssueRenderer } from "./ConfigIssueRenderer.js";
|
|
23
|
+
import { GithubAnnotation } from "./GithubAnnotation.js";
|
|
6
24
|
import { SchemaIssueRenderer } from "./SchemaIssueRenderer.js";
|
|
7
25
|
|
|
8
|
-
export { CliColor, CliExit, CliLogger, CliRuntime, ConfigIssueRenderer, SchemaIssueRenderer };
|
|
26
|
+
export { Cancelled, CliAudience, CliColor, CliDoc, CliEnv, CliExit, CliFailure, CliInteractive, CliLinks, CliLog, CliLogger, CliMessage, CliPrompt, CliRuntime, CliTheme, ConfigIssueRenderer, Doc, Fmt, GithubAnnotation, Glyphs, NotInteractive, Render, SchemaIssueRenderer, Status, Token };
|
package/internal/ansi.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
//#region src/internal/ansi.ts
|
|
2
|
+
/** Foreground SGR parameter for each named colour: 30 to 37, then 90 to 97. */
|
|
3
|
+
const NAMED = {
|
|
4
|
+
black: 30,
|
|
5
|
+
red: 31,
|
|
6
|
+
green: 32,
|
|
7
|
+
yellow: 33,
|
|
8
|
+
blue: 34,
|
|
9
|
+
magenta: 35,
|
|
10
|
+
cyan: 36,
|
|
11
|
+
white: 37,
|
|
12
|
+
blackBright: 90,
|
|
13
|
+
redBright: 91,
|
|
14
|
+
greenBright: 92,
|
|
15
|
+
yellowBright: 93,
|
|
16
|
+
blueBright: 94,
|
|
17
|
+
magentaBright: 95,
|
|
18
|
+
cyanBright: 96,
|
|
19
|
+
whiteBright: 97,
|
|
20
|
+
gray: 90
|
|
21
|
+
};
|
|
22
|
+
/** The 16 ANSI colours as xterm draws them, in SGR order (0 to 7, then bright 8 to 15), for the basic fallback. */
|
|
23
|
+
const PALETTE16 = [
|
|
24
|
+
[
|
|
25
|
+
0,
|
|
26
|
+
0,
|
|
27
|
+
0
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
205,
|
|
31
|
+
0,
|
|
32
|
+
0
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
0,
|
|
36
|
+
205,
|
|
37
|
+
0
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
205,
|
|
41
|
+
205,
|
|
42
|
+
0
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
0,
|
|
46
|
+
0,
|
|
47
|
+
238
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
205,
|
|
51
|
+
0,
|
|
52
|
+
205
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
0,
|
|
56
|
+
205,
|
|
57
|
+
205
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
229,
|
|
61
|
+
229,
|
|
62
|
+
229
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
127,
|
|
66
|
+
127,
|
|
67
|
+
127
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
255,
|
|
71
|
+
0,
|
|
72
|
+
0
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
0,
|
|
76
|
+
255,
|
|
77
|
+
0
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
255,
|
|
81
|
+
255,
|
|
82
|
+
0
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
92,
|
|
86
|
+
92,
|
|
87
|
+
255
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
255,
|
|
91
|
+
0,
|
|
92
|
+
255
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
0,
|
|
96
|
+
255,
|
|
97
|
+
255
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
255,
|
|
101
|
+
255,
|
|
102
|
+
255
|
|
103
|
+
]
|
|
104
|
+
];
|
|
105
|
+
/** The six levels of each axis of the xterm 6x6x6 colour cube. */
|
|
106
|
+
const CUBE = [
|
|
107
|
+
0,
|
|
108
|
+
95,
|
|
109
|
+
135,
|
|
110
|
+
175,
|
|
111
|
+
215,
|
|
112
|
+
255
|
|
113
|
+
];
|
|
114
|
+
const ESC = "\x1B[";
|
|
115
|
+
/** `#rgb` or `#rrggbb` to channels, or `undefined` when it is neither. */
|
|
116
|
+
const parseHex = (hex) => {
|
|
117
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex);
|
|
118
|
+
if (short !== null) return [
|
|
119
|
+
short[1],
|
|
120
|
+
short[2],
|
|
121
|
+
short[3]
|
|
122
|
+
].map((c) => Number.parseInt((c ?? "0").repeat(2), 16));
|
|
123
|
+
const long = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
|
124
|
+
if (long === null) return void 0;
|
|
125
|
+
return [
|
|
126
|
+
Number.parseInt(long[1] ?? "0", 16),
|
|
127
|
+
Number.parseInt(long[2] ?? "0", 16),
|
|
128
|
+
Number.parseInt(long[3] ?? "0", 16)
|
|
129
|
+
];
|
|
130
|
+
};
|
|
131
|
+
const distance = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
|
|
132
|
+
/** The index of the cube level nearest a channel value; the lower level wins a tie. */
|
|
133
|
+
const nearestLevel = (value) => {
|
|
134
|
+
let best = 0;
|
|
135
|
+
for (let i = 1; i < CUBE.length; i++) if (Math.abs((CUBE[i] ?? 0) - value) < Math.abs((CUBE[best] ?? 0) - value)) best = i;
|
|
136
|
+
return best;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The nearest xterm 256-colour index for a colour: the closest of the 6x6x6 cube and the 24-step grayscale
|
|
140
|
+
* ramp (232 to 255) by squared RGB distance, the cube winning a tie.
|
|
141
|
+
*/
|
|
142
|
+
const nearest256 = (rgb) => {
|
|
143
|
+
const [r, g, b] = [
|
|
144
|
+
nearestLevel(rgb[0]),
|
|
145
|
+
nearestLevel(rgb[1]),
|
|
146
|
+
nearestLevel(rgb[2])
|
|
147
|
+
];
|
|
148
|
+
const cubeIndex = 16 + 36 * r + 6 * g + b;
|
|
149
|
+
const cubeDistance = distance(rgb, [
|
|
150
|
+
CUBE[r] ?? 0,
|
|
151
|
+
CUBE[g] ?? 0,
|
|
152
|
+
CUBE[b] ?? 0
|
|
153
|
+
]);
|
|
154
|
+
const average = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
155
|
+
const step = Math.min(23, Math.max(0, Math.round((average - 8) / 10)));
|
|
156
|
+
const gray = 8 + 10 * step;
|
|
157
|
+
return distance(rgb, [
|
|
158
|
+
gray,
|
|
159
|
+
gray,
|
|
160
|
+
gray
|
|
161
|
+
]) < cubeDistance ? 232 + step : cubeIndex;
|
|
162
|
+
};
|
|
163
|
+
/** The SGR parameter of the nearest of the 16 ANSI colours: 30 to 37 or 90 to 97. */
|
|
164
|
+
const nearest16 = (rgb) => {
|
|
165
|
+
let best = 0;
|
|
166
|
+
for (let i = 1; i < PALETTE16.length; i++) if (distance(rgb, PALETTE16[i] ?? [
|
|
167
|
+
0,
|
|
168
|
+
0,
|
|
169
|
+
0
|
|
170
|
+
]) < distance(rgb, PALETTE16[best] ?? [
|
|
171
|
+
0,
|
|
172
|
+
0,
|
|
173
|
+
0
|
|
174
|
+
])) best = i;
|
|
175
|
+
return best < 8 ? 30 + best : 90 + (best - 8);
|
|
176
|
+
};
|
|
177
|
+
/** The foreground SGR parameters for a colour at a level, or `undefined` for none. */
|
|
178
|
+
const foreground = (fg, level) => {
|
|
179
|
+
if (level === "none") return void 0;
|
|
180
|
+
if (!fg.startsWith("#")) return Object.hasOwn(NAMED, fg) ? String(NAMED[fg]) : void 0;
|
|
181
|
+
const rgb = parseHex(fg);
|
|
182
|
+
if (rgb === void 0) return void 0;
|
|
183
|
+
if (level === "truecolor") return `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
|
|
184
|
+
if (level === "256") return `38;5;${nearest256(rgb)}`;
|
|
185
|
+
return String(nearest16(rgb));
|
|
186
|
+
};
|
|
187
|
+
/** The wraps for a style, innermost first: foreground, then dim, bold, italic and underline outward. */
|
|
188
|
+
const wraps = (style, level) => {
|
|
189
|
+
const result = [];
|
|
190
|
+
const fg = style.fg === void 0 ? void 0 : foreground(style.fg, level);
|
|
191
|
+
if (fg !== void 0) result.push({
|
|
192
|
+
open: `${ESC}${fg}m`,
|
|
193
|
+
close: `${ESC}39m`
|
|
194
|
+
});
|
|
195
|
+
if (style.dim === true) result.push({
|
|
196
|
+
open: `${ESC}2m`,
|
|
197
|
+
close: `${ESC}22m`
|
|
198
|
+
});
|
|
199
|
+
if (style.bold === true) result.push({
|
|
200
|
+
open: `${ESC}1m`,
|
|
201
|
+
close: `${ESC}22m`
|
|
202
|
+
});
|
|
203
|
+
if (style.italic === true) result.push({
|
|
204
|
+
open: `${ESC}3m`,
|
|
205
|
+
close: `${ESC}23m`
|
|
206
|
+
});
|
|
207
|
+
if (style.underline === true) result.push({
|
|
208
|
+
open: `${ESC}4m`,
|
|
209
|
+
close: `${ESC}24m`
|
|
210
|
+
});
|
|
211
|
+
return result;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Render `text` in a style at a colour level; identity at `none`.
|
|
215
|
+
*
|
|
216
|
+
* Each attribute closes with its own code (39, 22, 23, 24), never a blanket reset, and a closer that occurs
|
|
217
|
+
* inside `text` is followed by the opener again, so a painted span nested in a painted span leaves the outer
|
|
218
|
+
* style in force for the text after it.
|
|
219
|
+
*/
|
|
220
|
+
const paintStyle = (style, level, text) => {
|
|
221
|
+
if (level === "none" || text === "") return text;
|
|
222
|
+
let out = text;
|
|
223
|
+
for (const { open, close } of wraps(style, level)) out = `${open}${out.replaceAll(close, `${close}${open}`)}${close}`;
|
|
224
|
+
return out;
|
|
225
|
+
};
|
|
226
|
+
/** The raw opening SGR sequence of a style at a level, `""` at `none` or for a style that paints nothing. */
|
|
227
|
+
const openSequence = (style, level) => level === "none" ? "" : wraps(style, level).toReversed().map((wrap) => wrap.open).join("");
|
|
228
|
+
|
|
229
|
+
//#endregion
|
|
230
|
+
export { nearest256, openSequence, paintStyle, parseHex };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { CurrentRuntimeEnv } from "@effected/env";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/autoFormat.ts
|
|
5
|
+
/**
|
|
6
|
+
* Whether the environment says the program runs under GitHub Actions, whose runner reads workflow commands out of
|
|
7
|
+
* the log.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* `CurrentRuntimeEnv` is read if the environment has it and is not required: without it, the answer is no.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const underGithubActions = Effect.gen(function* () {
|
|
15
|
+
const runtime = yield* Effect.serviceOption(CurrentRuntimeEnv);
|
|
16
|
+
return Option.contains(Option.flatMap(runtime, (env) => env.ci), "github-actions");
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* The renderer an audience gets when nothing says otherwise: a person is painted, a machine reads plain text.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* A CI gets GitHub's log format only where `CurrentRuntimeEnv` says it is GitHub Actions; that service is read if the
|
|
23
|
+
* environment has it and is not required. Shared by `Doc.print` and the failure report.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
const autoFormat = (audience) => Effect.gen(function* () {
|
|
28
|
+
if (audience === "human") return "ansi";
|
|
29
|
+
if (audience === "agent") return "plain";
|
|
30
|
+
return (yield* underGithubActions) ? "githubLog" : "plain";
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { autoFormat, underGithubActions };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Config, Effect, Option } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/canPrompt.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether the terminal facts let a run prompt: a terminal on standard input and on standard output, and a `TERM` that
|
|
6
|
+
* is not `dumb`. A dumb terminal is a terminal, but it cannot move the cursor or take synchronized output, which every
|
|
7
|
+
* redrawing prompt and screen needs. `TERM` is read through `Config` (never `process`), and only when both streams are
|
|
8
|
+
* terminals; a read that fails counts as unset, as the env package treats every read.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const canPrompt = (terminal) => terminal.stdinIsTerminal && terminal.stdout.isTerminal ? Effect.map(Config.option(Config.String("TERM")).pipe(Effect.orElseSucceed(() => Option.none())), (term) => !(Option.isSome(term) && term.value === "dumb")) : Effect.succeed(false);
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { canPrompt };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/counts.ts
|
|
4
|
+
/**
|
|
5
|
+
* The total of a `Counts` block: the caller's rule when it has one, otherwise the sum of `n` over every counter.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Lives here, not on `Doc`, so a renderer needs nothing from `Doc` at runtime: `Doc.print` imports the renderers, and
|
|
9
|
+
* a renderer importing `Doc` back would make a cycle. `Doc.total` is this function.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
const totalOf = (block) => block.total === void 0 ? block.counters.reduce((sum, counter) => sum + counter.n, 0) : block.total(block.counters);
|
|
14
|
+
/**
|
|
15
|
+
* The counters a renderer shows: every one except a zero counter that does not ask for `showZero`. `Doc.visibleCounters`
|
|
16
|
+
* is this function.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
const visibleCountersOf = (block) => block.counters.filter((counter) => counter.n !== 0 || counter.showZero === true);
|
|
21
|
+
/**
|
|
22
|
+
* A `CountsTable` as the `Table` it renders as: a label column, then a column per counter key in the order the keys
|
|
23
|
+
* first appear, headed by that counter's label; a cell is the count painted with its status token, empty where a row
|
|
24
|
+
* has no counter for the key; a duration column, when some row has a `durationMs`, formatted with `Fmt.duration`; and,
|
|
25
|
+
* with `totalRow`, a last row summing each column (a missing count or duration is zero).
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Plain literals, not `Doc` constructors: a renderer needs nothing from `Doc` at runtime (see {@link totalOf}).
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const countsTableOf = (block) => {
|
|
33
|
+
const keys = [];
|
|
34
|
+
for (const row of block.rows) for (const counter of row.counters) if (!keys.some((known) => known.key === counter.key)) keys.push(counter);
|
|
35
|
+
const text = (value, token) => token === void 0 ? {
|
|
36
|
+
_tag: "Text",
|
|
37
|
+
value
|
|
38
|
+
} : {
|
|
39
|
+
_tag: "Text",
|
|
40
|
+
value,
|
|
41
|
+
token
|
|
42
|
+
};
|
|
43
|
+
const countCell = (counter) => counter === void 0 ? [] : [text(String(counter.n), counter.status.def.token)];
|
|
44
|
+
const timed = block.rows.some((row) => row.durationMs !== void 0);
|
|
45
|
+
const durationCell = (ms) => timed ? [ms === void 0 ? [] : [text(Fmt.duration(ms))]] : [];
|
|
46
|
+
const rows = block.rows.map((row) => [
|
|
47
|
+
row.label,
|
|
48
|
+
...keys.map((key) => countCell(row.counters.find((counter) => counter.key === key.key))),
|
|
49
|
+
...durationCell(row.durationMs)
|
|
50
|
+
]);
|
|
51
|
+
const totalLabel = block.totalRow === void 0 || block.totalRow === false ? void 0 : block.totalRow === true ? [text("Total")] : block.totalRow;
|
|
52
|
+
const total = totalLabel === void 0 ? [] : [[
|
|
53
|
+
totalLabel,
|
|
54
|
+
...keys.map((key) => [text(String(block.rows.reduce((sum, row) => sum + (row.counters.find((counter) => counter.key === key.key)?.n ?? 0), 0)))]),
|
|
55
|
+
...durationCell(block.rows.reduce((sum, row) => sum + (row.durationMs ?? 0), 0))
|
|
56
|
+
]];
|
|
57
|
+
return {
|
|
58
|
+
_tag: "Table",
|
|
59
|
+
columns: [
|
|
60
|
+
{ header: block.labelHeader ?? [] },
|
|
61
|
+
...keys.map((key) => ({ header: [text(key.label)] })),
|
|
62
|
+
...timed ? [{ header: block.durationHeader ?? [text("duration")] }] : []
|
|
63
|
+
],
|
|
64
|
+
rows: [...rows, ...total]
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
export { countsTableOf, totalOf, visibleCountersOf };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context, LogLevel, Logger, References } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/diagnostics.ts
|
|
4
|
+
/**
|
|
5
|
+
* The diagnostics threshold reference behind `CliLog.Level`. Lives here so the stderr sink and the file sink share
|
|
6
|
+
* one definition without `CliLog` importing itself.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const Level = Context.Reference("@effected/cli/CliLog/Level", { defaultValue: () => "None" });
|
|
11
|
+
/**
|
|
12
|
+
* Whether a diagnostics sink writes `record`. `installed` is the `MinimumLogLevel` the diagnostics layer put in
|
|
13
|
+
* place: while the fiber still sees that value the sink filters on its own {@link Level}; a different value is taken
|
|
14
|
+
* to be core's `--log-level` flag and the sink follows it. A flag whose value EQUALS `installed` cannot be told from
|
|
15
|
+
* no flag, so the sink keeps filtering on its own level; the plain `CliLogger` prints those records anyway.
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
const passes = (record, installed) => {
|
|
20
|
+
const current = record.fiber.getRef(References.MinimumLogLevel);
|
|
21
|
+
const threshold = current === installed ? record.fiber.getRef(Level) : current;
|
|
22
|
+
return LogLevel.isGreaterThanOrEqualTo(record.logLevel, threshold);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The NDJSON line for a record, the one shape every diagnostics sink writes.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
const formatNdjson = (record) => Logger.formatJson.log(record);
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { Level, formatNdjson, passes };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/internal/displayWidth.ts
|
|
2
|
+
const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
3
|
+
const ANSI = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\u001B\[[0-?]*[ -/]*[@-~]/g;
|
|
4
|
+
const EMOJI = /^\p{RGI_Emoji}$/v;
|
|
5
|
+
const WIDE2 = /[\p{Emoji_Presentation}\u1100-\u115E\u2329\u232A\u2630-\u2637\u268A-\u268F\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u303E\u3041-\u3096\u3099-\u30FF\u3105-\u312F\u3131-\u3163\u3165-\u318E\u3190-\u31E5\u31EF-\u321E\u3220-\u3247\u3250-\u4DBF\u4DC0-\uA48C\uA490-\uA4C6\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{16FF6}\u{17000}-\u{191FF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1D300}-\u{1D376}\u{1F200}-\u{1F265}\u{20000}-\u{3FFFD}]/u;
|
|
6
|
+
const ZERO2 = /^[\p{Mn}\p{Me}\p{Cf}\p{Cc}\u115F\u1160\u3164\uFFA0]+$/u;
|
|
7
|
+
/**
|
|
8
|
+
* The text without its ANSI escape sequences: CSI (including SGR colour) and OSC (including OSC-8 hyperlinks).
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const stripAnsi = (input) => input.replace(ANSI, "");
|
|
13
|
+
/**
|
|
14
|
+
* The display width of `input` in terminal columns: graphemes, wide East Asian characters and emoji count two,
|
|
15
|
+
* combining marks, control characters and ANSI escapes count none.
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
const displayWidth = (input) => {
|
|
20
|
+
let width = 0;
|
|
21
|
+
for (const { segment } of segmenter.segment(stripAnsi(input))) {
|
|
22
|
+
if (ZERO2.test(segment)) continue;
|
|
23
|
+
width += EMOJI.test(segment) || /^\p{RI}{2}/u.test(segment) || WIDE2.test(Array.from(segment)[0] ?? "") ? 2 : 1;
|
|
24
|
+
}
|
|
25
|
+
return width;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The grapheme clusters of `input`, in order; a cluster is never split.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const graphemes = (input) => Array.from(segmenter.segment(input), (s) => s.segment);
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { displayWidth, graphemes, stripAnsi };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { autoFormat } from "./autoFormat.js";
|
|
2
|
+
import { sanitize } from "../Fmt.js";
|
|
3
|
+
import { CliLinks } from "../CliLinks.js";
|
|
4
|
+
import { Glyphs } from "../Glyphs.js";
|
|
5
|
+
import { CliTheme } from "../CliTheme.js";
|
|
6
|
+
import { Render } from "../Render.js";
|
|
7
|
+
import { CliFailure } from "../CliFailure.js";
|
|
8
|
+
import { Context, Effect, MutableRef, Option } from "effect";
|
|
9
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
10
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
11
|
+
|
|
12
|
+
//#region src/internal/failureTarget.ts
|
|
13
|
+
/**
|
|
14
|
+
* A cell `CliRuntime.main` provides outside failure reporting and fills from inside it.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* `reportFailures` catches OUTSIDE the layers `main` provides, so the theme, terminal, audience and links a report is
|
|
18
|
+
* rendered with are not in its context when a failure arrives. The environment layer writes the target here as it is
|
|
19
|
+
* built, and an audience flag rewrites it once the flag is read. A `Reference` defaulting to `undefined`, so a
|
|
20
|
+
* program run without `main` finds no cell and falls back, and no state is shared between runs.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
const FailureTargetCell = Context.Reference("@effected/cli/FailureTargetCell", { defaultValue: () => void 0 });
|
|
25
|
+
/** What a report is rendered with when nothing is known about the terminal: plain text, no limit, no escapes. */
|
|
26
|
+
const fallbackTarget = {
|
|
27
|
+
ctx: {
|
|
28
|
+
width: Number.POSITIVE_INFINITY,
|
|
29
|
+
audience: "agent",
|
|
30
|
+
color: "none",
|
|
31
|
+
paint: (_token, text) => text,
|
|
32
|
+
glyphs: Glyphs.ascii,
|
|
33
|
+
link: (_target, label) => label,
|
|
34
|
+
displayPath: (absolute) => absolute,
|
|
35
|
+
neutralizeWorkflowCommands: true
|
|
36
|
+
},
|
|
37
|
+
format: "plain",
|
|
38
|
+
assumed: true
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The target for the services in the current context, or `undefined` when it lacks any of the four.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Each is read with `serviceOption`, so this never adds a requirement. `audience` overrides the one in context: an
|
|
45
|
+
* audience flag is provided deeper than the environment layer, where the report cannot see it.
|
|
46
|
+
*/
|
|
47
|
+
const build = (audience, settings = {}) => Effect.gen(function* () {
|
|
48
|
+
const theme = yield* Effect.serviceOption(CliTheme);
|
|
49
|
+
const terminal = yield* Effect.serviceOption(TerminalEnv);
|
|
50
|
+
const current = yield* Effect.serviceOption(Audience);
|
|
51
|
+
const links = yield* Effect.serviceOption(CliLinks);
|
|
52
|
+
if (Option.isNone(theme) || Option.isNone(terminal) || Option.isNone(links)) return void 0;
|
|
53
|
+
const shape = audience ?? (Option.isSome(current) ? current.value : void 0);
|
|
54
|
+
if (shape === void 0) return void 0;
|
|
55
|
+
const { displayPath, stackFrames } = settings;
|
|
56
|
+
const ctx = yield* Render.context("stderr", displayPath === void 0 ? void 0 : { displayPath }).pipe(Effect.provideService(CliTheme, theme.value), Effect.provideService(TerminalEnv, terminal.value), Effect.provideService(CliLinks, links.value), Effect.provideService(Audience, shape));
|
|
57
|
+
const format = yield* autoFormat(ctx.audience);
|
|
58
|
+
return stackFrames === void 0 ? {
|
|
59
|
+
ctx,
|
|
60
|
+
format
|
|
61
|
+
} : {
|
|
62
|
+
ctx,
|
|
63
|
+
format,
|
|
64
|
+
stackFrames
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Record the target for the services in context in the cell, if there is one. A no-op without a cell or services.
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
const refreshFailureTarget = (audience, settings) => Effect.gen(function* () {
|
|
73
|
+
const cell = yield* FailureTargetCell;
|
|
74
|
+
if (cell === void 0) return;
|
|
75
|
+
const recorded = MutableRef.get(cell);
|
|
76
|
+
const target = yield* build(audience, settings ?? {
|
|
77
|
+
displayPath: recorded?.ctx.displayPath,
|
|
78
|
+
stackFrames: recorded?.stackFrames
|
|
79
|
+
});
|
|
80
|
+
if (target !== void 0) MutableRef.set(cell, target);
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* The target a report is rendered with: the cell, else the services in context, else the plain fallback.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
const currentTarget = Effect.gen(function* () {
|
|
88
|
+
const cell = yield* FailureTargetCell;
|
|
89
|
+
const recorded = cell === void 0 ? void 0 : MutableRef.get(cell);
|
|
90
|
+
if (recorded !== void 0) return recorded;
|
|
91
|
+
return (yield* build()) ?? fallbackTarget;
|
|
92
|
+
});
|
|
93
|
+
/** Content with its leading status mark (and the space after it) removed. */
|
|
94
|
+
const dropStatus = (content) => {
|
|
95
|
+
if (content[0]?._tag !== "StatusMark") return content;
|
|
96
|
+
const next = content[1];
|
|
97
|
+
return next?._tag === "Text" && next.value === " " ? content.slice(2) : content.slice(1);
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* A failure document without its status marks. A failure's status leads its first paragraph, or a schema failure's
|
|
101
|
+
* tree label; nothing else in the document carries one.
|
|
102
|
+
*/
|
|
103
|
+
const withoutStatus = (doc) => doc.map((block) => {
|
|
104
|
+
if (block._tag === "Paragraph") return {
|
|
105
|
+
...block,
|
|
106
|
+
content: dropStatus(block.content)
|
|
107
|
+
};
|
|
108
|
+
if (block._tag === "Tree") return {
|
|
109
|
+
...block,
|
|
110
|
+
root: {
|
|
111
|
+
...block.root,
|
|
112
|
+
label: dropStatus(block.root.label)
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return block;
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* The lines of a failure report for a target, with or without the leading status.
|
|
119
|
+
*
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
const linesOf = (cause, target, status = true) => {
|
|
123
|
+
const full = CliFailure.toDoc(cause, {
|
|
124
|
+
displayPath: target.ctx.displayPath,
|
|
125
|
+
...target.stackFrames === void 0 ? {} : { stackFrames: target.stackFrames }
|
|
126
|
+
});
|
|
127
|
+
const doc = status ? full : withoutStatus(full);
|
|
128
|
+
const text = Render[target.format](doc, target.ctx);
|
|
129
|
+
return text === "" ? [] : text.split("\n");
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* A consumer `render`'s lines, made safe: neutralized under GitHub Actions, and stripped of escapes for an agent.
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* What a consumer's `render` returns is text the kit did not build and cannot vouch for: it interpolates error
|
|
136
|
+
* messages, file names, whatever the failure carried. So it gets the output policy the kit's own report has. Under
|
|
137
|
+
* GitHub Actions (the target says so, and with no environment services at all it is assumed) every line is neutralized,
|
|
138
|
+
* a returned line break splitting it first. For an agent or a CI audience the escapes are removed too (GitHub Actions
|
|
139
|
+
* detects as `ci`, and the kit's own output for it has none). For a person they are kept: the kit cannot tell the consumer's own colour from an injected sequence, so the consumer's `render` is
|
|
140
|
+
* responsible for sanitising what it interpolates. An audience that was only assumed is not an agent.
|
|
141
|
+
*
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
const guardConsumerLines = (lines) => Effect.map(currentTarget, (target) => {
|
|
145
|
+
const stripped = target.assumed !== true && (target.ctx.audience === "agent" || target.ctx.audience === "ci") ? lines.map(sanitize) : lines;
|
|
146
|
+
return target.ctx.neutralizeWorkflowCommands === true ? stripped.flatMap((line) => CommandNeutralizer.lines(line)) : stripped;
|
|
147
|
+
});
|
|
148
|
+
/**
|
|
149
|
+
* The plain lines of a failure, for a caller with no services: what `CliRuntime.defaultRender` returns.
|
|
150
|
+
*
|
|
151
|
+
* @internal
|
|
152
|
+
*/
|
|
153
|
+
const plainFailureLines = (cause, status = true) => linesOf(cause, fallbackTarget, status);
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
export { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, refreshFailureTarget };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CliError, Prompt } from "effect/cli";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/fallbackAnswer.ts
|
|
5
|
+
/**
|
|
6
|
+
* What a fallback hands core when the run is not interactive: `otherwise`, answered, when given (`undefined` counts
|
|
7
|
+
* as not given); otherwise core's own missing-parameter error, so the parse fails exactly as it would with no
|
|
8
|
+
* fallback and `CliRuntime.main` exits `64`. Shared by `CliPrompt.fallback` and `CliUi.fallback`.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const answerWithoutPerson = (target) => {
|
|
13
|
+
if ("otherwise" in target && target.otherwise !== void 0) return Effect.succeed(Prompt.succeed(target.otherwise));
|
|
14
|
+
return Effect.fail("flag" in target ? new CliError.MissingOption({ option: target.flag }) : new CliError.MissingArgument({ argument: target.argument }));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { answerWithoutPerson };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sanitize } from "../Fmt.js";
|
|
2
|
+
import { formatNdjson, passes } from "./diagnostics.js";
|
|
3
|
+
import { Cause, Console, Effect, Exit, Fiber, FileSystem, Logger, Path, Queue } from "effect";
|
|
4
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/fileSink.ts
|
|
7
|
+
/** How long closing the scope waits for queued lines to reach the file before giving up on a hung filesystem. */
|
|
8
|
+
const CLOSE_TIMEOUT = "2 seconds";
|
|
9
|
+
/**
|
|
10
|
+
* An asynchronous NDJSON file logger.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* `Logger.make` takes a synchronous callback, so the logger only offers the line to a queue; a fiber scoped to the
|
|
14
|
+
* layer drains it and appends each batch with `FileSystem.writeFileString(..., { flag: "a" })`. The first write
|
|
15
|
+
* error prints one stderr line and disables the sink: later lines, including any still queued, are discarded
|
|
16
|
+
* without a message. A defect from the filesystem counts as a write error. Closing the scope ends the queue and
|
|
17
|
+
* waits for the drain for at most two seconds, so lines queued before the close are flushed unless the sink had
|
|
18
|
+
* already disabled itself or the filesystem hangs; past the bound the drain is interrupted and the rest is lost.
|
|
19
|
+
*
|
|
20
|
+
* The stderr line is held to the rules of every other kit write: the path (which a workflow can set through an env
|
|
21
|
+
* var) and the error's message are sanitised, and the line is neutralized when `underActions` says the drain's fiber
|
|
22
|
+
* runs under GitHub Actions, the same decision the diagnostics sink makes.
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
const makeFileSink = (path, installed, underActions) => Effect.gen(function* () {
|
|
27
|
+
const fs = yield* FileSystem.FileSystem;
|
|
28
|
+
const location = yield* Path.Path;
|
|
29
|
+
const queue = yield* Queue.unbounded();
|
|
30
|
+
let disabled = false;
|
|
31
|
+
let directoryMade = false;
|
|
32
|
+
const append = (lines) => Effect.gen(function* () {
|
|
33
|
+
if (!directoryMade) {
|
|
34
|
+
yield* fs.makeDirectory(location.dirname(path), { recursive: true });
|
|
35
|
+
directoryMade = true;
|
|
36
|
+
}
|
|
37
|
+
yield* fs.writeFileString(path, lines.map((line) => `${line}\n`).join(""), { flag: "a" });
|
|
38
|
+
});
|
|
39
|
+
const drain = Effect.gen(function* () {
|
|
40
|
+
while (true) {
|
|
41
|
+
const batch = yield* Queue.takeAll(queue);
|
|
42
|
+
if (disabled) continue;
|
|
43
|
+
const exit = yield* Effect.exit(append(batch));
|
|
44
|
+
if (Exit.isFailure(exit)) {
|
|
45
|
+
disabled = true;
|
|
46
|
+
const error = Cause.squash(exit.cause);
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
const line = sanitize(`diagnostics log file ${path} failed: ${message}; further file logging disabled`);
|
|
49
|
+
yield* Effect.withFiber((self) => Console.error(underActions(self) ? CommandNeutralizer.text(line) : line));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}).pipe(Effect.ignore);
|
|
53
|
+
const fiber = yield* Effect.forkScoped(drain);
|
|
54
|
+
yield* Effect.addFinalizer(() => Queue.end(queue).pipe(Effect.andThen(Fiber.join(fiber).pipe(Effect.timeout(CLOSE_TIMEOUT))), Effect.ignore));
|
|
55
|
+
return Logger.make((record) => {
|
|
56
|
+
if (!passes(record, installed)) return;
|
|
57
|
+
Queue.offerUnsafe(queue, formatNdjson(record));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { makeFileSink };
|