@effected/cli 0.11.0 → 0.13.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/CliFailure.js +57 -8
- package/CliLog.js +53 -1
- package/CliMessage.js +3 -6
- package/CliRuntime.js +20 -10
- package/CliTheme.js +28 -15
- package/Doc.js +31 -7
- package/README.md +22 -6
- package/Render.js +6 -5
- package/Status.js +5 -2
- package/index.d.ts +220 -28
- package/internal/counts.js +17 -2
- package/internal/failureTarget.js +53 -14
- package/internal/renderDoc.js +4 -3
- package/internal/renderMarkdown.js +6 -5
- package/package.json +7 -2
- package/ui/CliUi.js +91 -7
- package/ui/CliUiLive.js +63 -16
- package/ui/Select.js +5 -1
- package/ui/TextInput.js +51 -11
- package/ui/internal/lazyView.js +74 -0
- package/ui/testing/CliUiTest.js +47 -22
- package/ui/testing/fakeStreams.js +6 -3
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +93 -12
- package/ui.d.ts +154 -12
package/CliFailure.js
CHANGED
|
@@ -193,13 +193,60 @@ const failBlocks = (error, spans, options) => {
|
|
|
193
193
|
if (schema !== void 0) return [...schema, ...spans];
|
|
194
194
|
return [...failureBlocks(describe(error)), ...spans];
|
|
195
195
|
};
|
|
196
|
-
/**
|
|
197
|
-
const
|
|
196
|
+
/** A file of the kit's own packages or of Effect, as installed: a span defined there is not the program's. */
|
|
197
|
+
const isKitFile = (file) => /[\\/]node_modules[\\/](?:@effected[\\/]|effect[\\/])/.test(file);
|
|
198
|
+
/**
|
|
199
|
+
* The installed package directory that holds `module`: the path through the last `node_modules/<name>` or
|
|
200
|
+
* `node_modules/@scope/name` in it, or `undefined` when the module is not under `node_modules` (or not a path at all).
|
|
201
|
+
* Read from the path alone, with no file system.
|
|
202
|
+
*/
|
|
203
|
+
const packageDirOf = (module) => {
|
|
204
|
+
if (module === void 0) return void 0;
|
|
205
|
+
const path = asPath(module);
|
|
206
|
+
if (path === void 0) return void 0;
|
|
207
|
+
return /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(comparable(path))?.[1];
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* A path in the one spelling both sides of a comparison use: `/` separators, and a Windows drive letter lower-cased. A
|
|
211
|
+
* CommonJS frame on Windows reads `C:\…` where an `import.meta.url` reads `file:///C:/…`, and the drive's case is not
|
|
212
|
+
* fixed (`c:` and `C:` are one drive). Lexical only: no file system, no realpath.
|
|
213
|
+
*/
|
|
214
|
+
const comparable = (path) => path.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `${drive.toLowerCase()}:`);
|
|
215
|
+
/** Whether `file` is in the program's own package `appDir`, and not in a dependency nested in its `node_modules`. */
|
|
216
|
+
const isAppFile = (file, appDir) => {
|
|
217
|
+
if (appDir === void 0) return false;
|
|
218
|
+
const path = comparable(file);
|
|
219
|
+
if (!path.startsWith(appDir)) return false;
|
|
220
|
+
const rest = path.slice(appDir.length);
|
|
221
|
+
return rest.startsWith("/") && !rest.includes("/node_modules/");
|
|
222
|
+
};
|
|
223
|
+
/** The file a span frame's captured stack points at, or `undefined` when it captured none. */
|
|
224
|
+
const spanFile = (frame) => {
|
|
225
|
+
try {
|
|
226
|
+
const stack = frame.stack();
|
|
227
|
+
if (stack === void 0) return void 0;
|
|
228
|
+
const line = stack.split(/\r\n|\r|\n/).find((candidate) => /^\s*at\s/.test(candidate)) ?? stack;
|
|
229
|
+
return frameFile(line.trim().startsWith("at ") ? line : `at ${line.trim()}`);
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* `in: outer › inner`, from the span stack the runtime annotates a reason with, or none. With `app`, a span the kit or
|
|
236
|
+
* Effect defined is left out. An `Effect.fn` call carries its definition as its parent (`name (definition)`): the two
|
|
237
|
+
* are one entry, `name`, judged by the definition's site, so a kit function the program calls goes with its
|
|
238
|
+
* definition.
|
|
239
|
+
*/
|
|
240
|
+
const spanBlocks = (reason, mode, appDir) => {
|
|
241
|
+
if (mode === "off") return [];
|
|
198
242
|
const names = [];
|
|
199
243
|
let frame = Context.getOrUndefined(Cause.reasonAnnotations(reason), Cause.StackTrace);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
244
|
+
for (let seen = 0; frame !== void 0 && seen < MAX_SPANS; seen++) {
|
|
245
|
+
const parent = frame.parent;
|
|
246
|
+
const definition = parent !== void 0 && parent.name === `${frame.name} (definition)` ? parent : void 0;
|
|
247
|
+
const file = (definition === void 0 ? void 0 : spanFile(definition)) ?? spanFile(frame);
|
|
248
|
+
if (mode === "all" || file === void 0 || isAppFile(file, appDir) || !isKitFile(file)) names.push(frame.name);
|
|
249
|
+
frame = definition === void 0 ? parent : definition.parent;
|
|
203
250
|
}
|
|
204
251
|
if (names.length === 0) return [];
|
|
205
252
|
return [Doc.paragraph(Doc.text("in: ", "muted"), Doc.path(...names.reverse()))];
|
|
@@ -221,7 +268,7 @@ const spanBlocks = (reason) => {
|
|
|
221
268
|
* `Error.cause` chain as a tree. When cleaning leaves no frame the stack says
|
|
222
269
|
* `no user frames (N internal frames hidden)`, never an empty block; when frames survive and some were left out, the
|
|
223
270
|
* count follows them as `(+N internal frames hidden)`. A reason that ran under spans is followed by
|
|
224
|
-
* `in: outer › inner
|
|
271
|
+
* `in: outer › inner`: by default only the program's own spans, the kit's and Effect's left out (`spans`). Interrupts are not rendered beside a real failure, and a cause with only interrupts is the
|
|
225
272
|
* one line `interrupted`.
|
|
226
273
|
*
|
|
227
274
|
* All text goes through the document, so a control character in a message or a stack frame never reaches the terminal.
|
|
@@ -242,8 +289,10 @@ var CliFailure = class {
|
|
|
242
289
|
if (reasons.length > 0 && reasons.every(Cause.isInterruptReason)) return [Doc.paragraph("interrupted")];
|
|
243
290
|
const displayPath = options?.displayPath ?? ((absolute) => absolute);
|
|
244
291
|
return reasons.flatMap((reason) => {
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
const spans = options?.spans ?? "app";
|
|
293
|
+
const appDir = packageDirOf(options?.appModule);
|
|
294
|
+
if (Cause.isFailReason(reason)) return failBlocks(reason.error, spanBlocks(reason, spans, appDir), options);
|
|
295
|
+
if (Cause.isDieReason(reason)) return dieBlocks(reason.defect, spanBlocks(reason, spans, appDir), displayPath, options?.stackFrames ?? "app");
|
|
247
296
|
return [];
|
|
248
297
|
});
|
|
249
298
|
};
|
package/CliLog.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { sanitize } from "./Fmt.js";
|
|
2
2
|
import { paintStyle } from "./internal/ansi.js";
|
|
3
|
+
import { CliTheme } from "./CliTheme.js";
|
|
3
4
|
import { scanAudience } from "./internal/scanAudience.js";
|
|
4
|
-
import { neutralizeJson } from "./internal/logSafety.js";
|
|
5
|
+
import { TrustedLine, neutralizeJson } from "./internal/logSafety.js";
|
|
5
6
|
import { makeCliLogger } from "./CliLogger.js";
|
|
6
7
|
import { Level, passes } from "./internal/diagnostics.js";
|
|
7
8
|
import { makeFileSink } from "./internal/fileSink.js";
|
|
@@ -10,6 +11,14 @@ import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
|
|
|
10
11
|
import { CommandNeutralizer } from "@effected/github-commands";
|
|
11
12
|
|
|
12
13
|
//#region src/CliLog.ts
|
|
14
|
+
/** The most spaces a numeric `indent` writes. */
|
|
15
|
+
const MAX_INDENT = 64;
|
|
16
|
+
/** An indent as written before the glyph: spaces for a number, a string with nothing in it that is not text. */
|
|
17
|
+
const indentOf = (indent) => {
|
|
18
|
+
if (indent === void 0) return "";
|
|
19
|
+
if (typeof indent === "number") return Number.isFinite(indent) && indent > 0 ? " ".repeat(Math.min(MAX_INDENT, Math.floor(indent))) : "";
|
|
20
|
+
return sanitize(indent).replace(/\r\n|\r|\n/g, "");
|
|
21
|
+
};
|
|
13
22
|
/** The accepted spellings of a level, lower-cased, to the level they mean. */
|
|
14
23
|
const LEVELS = {
|
|
15
24
|
all: "All",
|
|
@@ -204,6 +213,49 @@ var CliLog = class CliLog {
|
|
|
204
213
|
}));
|
|
205
214
|
}
|
|
206
215
|
/**
|
|
216
|
+
* Log a status line: its glyph painted through the theme, then `text`, at a level that follows the status.
|
|
217
|
+
*
|
|
218
|
+
* @remarks
|
|
219
|
+
* The line goes through the logger, so it is a diagnostic like any `Effect.log*` call: filtered by the level in
|
|
220
|
+
* force (`--log-level`, `CliLog.Level`), routed by `CliLogger`'s `stderrFrom` (stderr by default) and neutralized
|
|
221
|
+
* under GitHub Actions. What differs is the glyph: the logger sanitises every line a program logs, which strips a
|
|
222
|
+
* colour a program painted itself, so a glyph on the log channel was always drawn bare. Here the kit paints it and
|
|
223
|
+
* marks the line as its own, so the plain `CliLogger` line keeps the colour, while `text` is still sanitised:
|
|
224
|
+
* escape sequences and control characters in it are removed, as in every line the kit writes.
|
|
225
|
+
*
|
|
226
|
+
* The glyph is painted with stderr's theme (where diagnostics go) through {@link CliTheme.forAudience}, so an agent
|
|
227
|
+
* gets it unpainted, and an `Audience` is read only when provided, so it stays out of the requirements. ASCII glyphs
|
|
228
|
+
* give the status's ASCII form. A diagnostics record carries the line as its message as any record does: the `CliLog`
|
|
229
|
+
* sink's pretty line sanitises it (the glyph is drawn bare there), and NDJSON keeps it, JSON-escaped.
|
|
230
|
+
*
|
|
231
|
+
* The level defaults to the status's rank in `vocab`: `Error` at or above `failure`'s, `Warn` at or above
|
|
232
|
+
* `warning`'s, `Info` below, so a custom status follows its own rank. Pass `level` to choose it, and `indent` (a
|
|
233
|
+
* number of spaces, or a string, sanitised) to start the line inside an indented block: ` ✗ error x: red`.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```ts
|
|
237
|
+
* import { CliLog, Status } from "@effected/cli"
|
|
238
|
+
*
|
|
239
|
+
* // ✗ in the failure colour, then the message: on stderr, filtered by the log level.
|
|
240
|
+
* const reportError = (resource: string, message: string) =>
|
|
241
|
+
* CliLog.status(Status.core, "failure", `${resource}: ${message}`)
|
|
242
|
+
* ```
|
|
243
|
+
*
|
|
244
|
+
* @param vocab - the vocabulary the status belongs to
|
|
245
|
+
* @param name - the status
|
|
246
|
+
* @param text - the text after the glyph, sanitised
|
|
247
|
+
* @param options - the level to log at, and the indent before the glyph
|
|
248
|
+
*/
|
|
249
|
+
static status = (vocab, name, text, options) => Effect.gen(function* () {
|
|
250
|
+
const audience = yield* Effect.serviceOption(Audience);
|
|
251
|
+
const theme = CliTheme.forAudience((yield* CliTheme).forStream("stderr"), Option.isSome(audience) ? audience.value.kind : void 0);
|
|
252
|
+
const line = `${indentOf(options?.indent)}${theme.status(vocab, name, sanitize(text))}`;
|
|
253
|
+
const core = vocab;
|
|
254
|
+
const rank = vocab.def(name).rank;
|
|
255
|
+
const level = options?.level ?? (rank >= core.def("failure").rank ? "Error" : rank >= core.def("warning").rank ? "Warn" : "Info");
|
|
256
|
+
yield* Effect.logWithLevel(level)(line).pipe(Effect.provideService(TrustedLine, true));
|
|
257
|
+
});
|
|
258
|
+
/**
|
|
207
259
|
* Mark the log records an effect emits as coming from `name`.
|
|
208
260
|
*
|
|
209
261
|
* @remarks
|
package/CliMessage.js
CHANGED
|
@@ -18,8 +18,8 @@ import { CommandNeutralizer } from "@effected/github-commands";
|
|
|
18
18
|
*
|
|
19
19
|
* The text is whatever the caller supplies, so it is sanitised: escape sequences and control characters are removed
|
|
20
20
|
* (a line break is kept as one, a tab becomes a space), as in a document. Under GitHub Actions, where
|
|
21
|
-
* `CurrentRuntimeEnv` says so, a line the runner would read as a workflow command is neutralized as well. The
|
|
22
|
-
*
|
|
21
|
+
* `CurrentRuntimeEnv` says so, a line the runner would read as a workflow command is neutralized as well. The glyph
|
|
22
|
+
* comes from the vocabulary, sanitised too (`Status.glyph`), so a glyph built from data cannot inject an escape either.
|
|
23
23
|
*
|
|
24
24
|
* @public
|
|
25
25
|
*/
|
|
@@ -46,10 +46,7 @@ var CliMessage = class CliMessage {
|
|
|
46
46
|
const stream = options?.stream ?? (def.rank >= warning ? "stderr" : "stdout");
|
|
47
47
|
const streamTheme = theme.forStream(stream);
|
|
48
48
|
let line;
|
|
49
|
-
|
|
50
|
-
const glyph = streamTheme.glyphs.kind === "ascii" ? def.ascii : def.glyph;
|
|
51
|
-
line = message === "" ? glyph : `${glyph} ${message}`;
|
|
52
|
-
} else line = streamTheme.status(vocab, name, message);
|
|
49
|
+
line = CliTheme.forAudience(streamTheme, audience.kind).status(vocab, name, message);
|
|
53
50
|
if (yield* underGithubActions) line = CommandNeutralizer.text(line);
|
|
54
51
|
yield* stream === "stderr" ? Console.error(line) : Console.log(line);
|
|
55
52
|
});
|
package/CliRuntime.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import { Cancelled } from "./Cancelled.js";
|
|
1
2
|
import { sanitize } from "./Fmt.js";
|
|
2
|
-
import {
|
|
3
|
+
import { NotInteractive } from "./NotInteractive.js";
|
|
4
|
+
import { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, readSpans, refreshFailureTarget } from "./internal/failureTarget.js";
|
|
3
5
|
import { CliColor } from "./CliColor.js";
|
|
4
6
|
import { CliEnv } from "./CliEnv.js";
|
|
5
7
|
import { isExitCode } from "./internal/isExitCode.js";
|
|
6
8
|
import { CliExit } from "./CliExit.js";
|
|
7
9
|
import { TrustedLine } from "./internal/logSafety.js";
|
|
8
|
-
import { CliLogger } from "./CliLogger.js";
|
|
10
|
+
import { CliLogger, makeCliLogger } from "./CliLogger.js";
|
|
9
11
|
import { CliLog, envBuildLogLayer, platformLogLayer } from "./CliLog.js";
|
|
10
12
|
import { ExitRequested } from "./internal/ExitRequested.js";
|
|
11
13
|
import { routeHelpOnUsageError } from "./internal/HelpRouting.js";
|
|
12
|
-
import { Cause, Effect, Layer, MutableRef, Runtime } from "effect";
|
|
14
|
+
import { Cause, Effect, Layer, Logger, MutableRef, Runtime } from "effect";
|
|
13
15
|
import { CliError } from "effect/cli";
|
|
14
16
|
import { CommandNeutralizer } from "@effected/github-commands";
|
|
15
17
|
|
|
@@ -158,12 +160,12 @@ var CliRuntime = class CliRuntime {
|
|
|
158
160
|
const render = options.render;
|
|
159
161
|
return Effect.gen(function* () {
|
|
160
162
|
const target = yield* currentTarget.pipe(Effect.catchCause(() => Effect.succeed(fallbackTarget)));
|
|
161
|
-
const reportLines = (status) => {
|
|
163
|
+
const reportLines = (status, spans) => {
|
|
162
164
|
try {
|
|
163
|
-
return linesOf(cause, target, status);
|
|
165
|
+
return linesOf(cause, target, status, spans ?? target.spans);
|
|
164
166
|
} catch {
|
|
165
167
|
try {
|
|
166
|
-
return plainFailureLines(cause, status);
|
|
168
|
+
return plainFailureLines(cause, status, spans ?? target.spans);
|
|
167
169
|
} catch {
|
|
168
170
|
return lastResort(error);
|
|
169
171
|
}
|
|
@@ -173,8 +175,10 @@ var CliRuntime = class CliRuntime {
|
|
|
173
175
|
const details = {
|
|
174
176
|
cause,
|
|
175
177
|
isDefect: !Cause.hasFails(cause),
|
|
178
|
+
isCancelled: error instanceof Cancelled,
|
|
179
|
+
isNotInteractive: error instanceof NotInteractive,
|
|
176
180
|
defaultLines,
|
|
177
|
-
lines: (options) => options?.status === false ? reportLines(false) : defaultLines
|
|
181
|
+
lines: (options) => options?.status === false || options?.spans !== void 0 ? reportLines(options?.status !== false, options?.spans) : defaultLines
|
|
178
182
|
};
|
|
179
183
|
const lines = render === void 0 ? defaultLines : yield* guardConsumerLines(toLines(render(error, details)));
|
|
180
184
|
for (const line of lines) yield* Effect.logError(line).pipe(Effect.provideService(TrustedLine, true));
|
|
@@ -185,9 +189,15 @@ var CliRuntime = class CliRuntime {
|
|
|
185
189
|
const env = options.env === void 0 ? void 0 : CliEnv.layer(options.env);
|
|
186
190
|
const envLog = options.env?.log;
|
|
187
191
|
const logger = options.logger ?? (env === void 0 || envLog === void 0 ? CliLogger.layer() : CliLog.layer(envLog).pipe(Layer.provide(env), Layer.provide(Layer.provideMerge(options.platform.pipe(Layer.provide(platformLogLayer(envLog, options.env?.audienceEnvVar))), envBuildLogLayer(envLog, options.env?.audienceEnvVar))), Layer.catchCause(() => CliLogger.layer(envLog.logger))));
|
|
188
|
-
const inside = env === void 0 ? Layer.empty : Layer.mergeAll(CliColor.formatterLayer(options.env?.formatter), Layer.effectDiscard(
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
const inside = env === void 0 ? Layer.empty : Layer.mergeAll(CliColor.formatterLayer(options.env?.formatter), Layer.effectDiscard(Effect.gen(function* () {
|
|
193
|
+
const { spans, invalid } = yield* readSpans(options.env?.spans, options.env?.spansEnvVar);
|
|
194
|
+
if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([makeCliLogger(envLog?.logger)])));
|
|
195
|
+
yield* refreshFailureTarget(void 0, {
|
|
196
|
+
displayPath: options.env?.displayPath,
|
|
197
|
+
stackFrames: options.env?.stackFrames,
|
|
198
|
+
spans,
|
|
199
|
+
appModule: options.env?.appModule
|
|
200
|
+
});
|
|
191
201
|
}))).pipe(Layer.provideMerge(env));
|
|
192
202
|
const run = Effect.gen(function* () {
|
|
193
203
|
yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(program) : program;
|
package/CliTheme.js
CHANGED
|
@@ -20,24 +20,13 @@ const streamThemeAt = (resolve, glyphs, color) => {
|
|
|
20
20
|
glyphs,
|
|
21
21
|
color,
|
|
22
22
|
status: (vocab, name, text) => {
|
|
23
|
-
const
|
|
24
|
-
const glyph = paint(def.token, glyphs.kind === "ascii" ? def.ascii : def.glyph);
|
|
23
|
+
const glyph = paint(vocab.def(name).token, vocab.glyph(name, glyphs));
|
|
25
24
|
return text === void 0 || text === "" ? glyph : `${glyph} ${text}`;
|
|
26
25
|
}
|
|
27
26
|
};
|
|
28
27
|
};
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
* empty, `status` unpainted), whatever the terminal could do, because an agent never gets an escape of any kind; for
|
|
32
|
-
* anyone else, or when the audience is not known, `theme` itself.
|
|
33
|
-
*
|
|
34
|
-
* @remarks
|
|
35
|
-
* The one place that rule is applied to a theme: `Render.context` takes its colour and `paint` from it, and `./ui`
|
|
36
|
-
* gives it to the trees it mounts, so `useTheme`, `Styled` and the widgets' colour-`none` text markers all agree.
|
|
37
|
-
*
|
|
38
|
-
* @internal
|
|
39
|
-
*/
|
|
40
|
-
const themeForAudience = (theme, audience) => audience === "agent" && theme.color !== "none" ? streamThemeAt(theme.style, theme.glyphs, "none") : theme;
|
|
28
|
+
/** The audience rule, shared by `CliTheme.forAudience` and the class's own docs. */
|
|
29
|
+
const forAudience = (theme, audience) => audience === "agent" && theme.color !== "none" ? streamThemeAt(theme.style, theme.glyphs, "none") : theme;
|
|
41
30
|
const make = (colors, glyphs, overrides) => {
|
|
42
31
|
const resolve = (token) => Token.resolve(token, overrides);
|
|
43
32
|
const stdout = streamThemeAt(resolve, glyphs, colors.stdout);
|
|
@@ -93,6 +82,30 @@ var CliTheme = class CliTheme extends Context.Service()("@effected/cli/CliTheme"
|
|
|
93
82
|
}, glyphs, options?.tokens);
|
|
94
83
|
}));
|
|
95
84
|
/**
|
|
85
|
+
* The theme an audience sees of `theme`: for an agent, the same theme at colour `none` (`paint` the identity, `sgr`
|
|
86
|
+
* empty, `status` unpainted), whatever the terminal could do, because an agent never gets an escape of any kind; for
|
|
87
|
+
* anyone else, or when the audience is not known, `theme` itself.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* The one rule the kit applies wherever it paints for an audience: `Render.context` takes its colour and `paint`
|
|
91
|
+
* from it, `CliMessage` and `CliLog.status` paint their glyphs through it, and `./ui` gives it to the trees it
|
|
92
|
+
* mounts, so `useTheme`, `Styled` and the widgets' colour-`none` text markers all agree. A program that paints its
|
|
93
|
+
* own lines applies the same rule with it rather than re-implementing it:
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const line = Effect.gen(function* () {
|
|
97
|
+
* const theme = CliTheme.forAudience((yield* CliTheme).forStream("stdout"), (yield* Audience).kind)
|
|
98
|
+
* return theme.status(Status.core, "success", Fmt.sanitize(name))
|
|
99
|
+
* })
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* Pure: it reads nothing, so the audience is the caller's to pass, `undefined` when it is not known.
|
|
103
|
+
*
|
|
104
|
+
* @param theme - a stream's theme, such as `CliTheme.forStream("stdout")`
|
|
105
|
+
* @param audience - who the output is for, or `undefined` when that is not known
|
|
106
|
+
*/
|
|
107
|
+
static forAudience = forAudience;
|
|
108
|
+
/**
|
|
96
109
|
* A fixed theme that needs nothing; `none` colour and Unicode glyphs unless told otherwise.
|
|
97
110
|
*
|
|
98
111
|
* @param options - the colour level and glyph set
|
|
@@ -125,4 +138,4 @@ var CliTheme = class CliTheme extends Context.Service()("@effected/cli/CliTheme"
|
|
|
125
138
|
};
|
|
126
139
|
|
|
127
140
|
//#endregion
|
|
128
|
-
export { CliTheme, streamThemeAt
|
|
141
|
+
export { CliTheme, streamThemeAt };
|
package/Doc.js
CHANGED
|
@@ -25,6 +25,10 @@ const treeNode = (input) => freeze({
|
|
|
25
25
|
});
|
|
26
26
|
const counterOf = (counter) => freeze({
|
|
27
27
|
...counter,
|
|
28
|
+
label: typeof counter.label === "string" ? counter.label : freeze({
|
|
29
|
+
one: counter.label.one,
|
|
30
|
+
other: counter.label.other
|
|
31
|
+
}),
|
|
28
32
|
status: freeze({
|
|
29
33
|
name: counter.status.name,
|
|
30
34
|
def: freeze({ ...counter.status.def })
|
|
@@ -278,6 +282,11 @@ var Doc = class {
|
|
|
278
282
|
/**
|
|
279
283
|
* Children under an optional title.
|
|
280
284
|
*
|
|
285
|
+
* @remarks
|
|
286
|
+
* The children are separated by blank lines (unless the document is compact); a title sits directly above the first.
|
|
287
|
+
* `Doc.section(undefined, blocks)` is the way to space a document's top-level blocks, which are otherwise joined with
|
|
288
|
+
* no blank line.
|
|
289
|
+
*
|
|
281
290
|
* @param title - the title, or `undefined` for none
|
|
282
291
|
* @param children - the blocks
|
|
283
292
|
*/
|
|
@@ -294,9 +303,15 @@ var Doc = class {
|
|
|
294
303
|
* @remarks
|
|
295
304
|
* A name the vocabulary does not have is a compile error.
|
|
296
305
|
*
|
|
306
|
+
* The label is one string, or `{ one, other }` to pluralise by count: `one` when the count is exactly 1 and `other`
|
|
307
|
+
* for every other count, 0 included. A count standing alone reads by its own `n` (`1 change`, `2 changes`); a
|
|
308
|
+
* headline shown as a share of the total reads by that total, the noun it counts (`1/1 repo`, `1/3 repos`,
|
|
309
|
+
* `2/3 repos`).
|
|
310
|
+
*
|
|
297
311
|
* @param vocab - the vocabulary the status belongs to
|
|
298
312
|
* @param name - a status name in it
|
|
299
|
-
* @param options - the counter's `key`, `label`
|
|
313
|
+
* @param options - the counter's `key`, its `label` (one string, or `{ one, other }`), its count `n`, and `showZero`
|
|
314
|
+
* to keep it when `n` is zero
|
|
300
315
|
*/
|
|
301
316
|
static counter(vocab, name, options) {
|
|
302
317
|
return counterOf({
|
|
@@ -373,20 +388,25 @@ var Doc = class {
|
|
|
373
388
|
});
|
|
374
389
|
}
|
|
375
390
|
/**
|
|
376
|
-
* One line of content; with `truncate`, it is cut to the width with the glyph set's ellipsis instead of wrapping
|
|
391
|
+
* One line of content; with `truncate`, it is cut to the width with the glyph set's ellipsis instead of wrapping,
|
|
392
|
+
* and with `wrap: false` it is kept whole on one line whatever the width.
|
|
377
393
|
*
|
|
378
394
|
* @remarks
|
|
379
|
-
*
|
|
380
|
-
*
|
|
395
|
+
* By default a line longer than the width wraps. `wrap: false` keeps it atomic in every audience and renderer, still
|
|
396
|
+
* carrying its status glyphs, theme tokens and links, which {@link Doc.verbatim} (a plain string) cannot: the tool for
|
|
397
|
+
* a finding such as `✗ path:line:col rule message` that a reader greps or reads line by line, while the prose around
|
|
398
|
+
* it still wraps. A line break inside it is still a space. With both `truncate` and `wrap: false`, `truncate` wins:
|
|
399
|
+
* the line is cut to the width.
|
|
381
400
|
*
|
|
382
401
|
* @param content - the line
|
|
383
|
-
* @param options - `truncate`
|
|
402
|
+
* @param options - `truncate`, to cut it to the width; `wrap: false`, to keep it whole
|
|
384
403
|
*/
|
|
385
404
|
static line(content, options) {
|
|
386
405
|
return freeze({
|
|
387
406
|
_tag: "Line",
|
|
388
407
|
content: inlines(content),
|
|
389
|
-
...options?.truncate === void 0 ? {} : { truncate: options.truncate }
|
|
408
|
+
...options?.truncate === void 0 ? {} : { truncate: options.truncate },
|
|
409
|
+
...options?.wrap === void 0 ? {} : { wrap: options.wrap }
|
|
390
410
|
});
|
|
391
411
|
}
|
|
392
412
|
/**
|
|
@@ -482,11 +502,15 @@ var Doc = class {
|
|
|
482
502
|
* The context is {@link Render.context} for the stream, so the width, the colour, the links and the audience
|
|
483
503
|
* come from the services the program already has, and the text is written with `Console.log` or
|
|
484
504
|
* `Console.error`: a test captures it by swapping the `Console`. With `format: "auto"` the renderer follows
|
|
485
|
-
* the audience, and the width is unbounded for an agent and a
|
|
505
|
+
* the audience, and the width is unbounded for an agent, a CI, and a human whose stream is not a terminal.
|
|
486
506
|
*
|
|
487
507
|
* An agent is never written an escape of any kind, even with an explicit `format: "ansi"`: its context is
|
|
488
508
|
* colourless and its links are off. A document that renders to nothing prints nothing.
|
|
489
509
|
*
|
|
510
|
+
* The whole document is written as one `Console.log` (or `Console.error`) call, with its line breaks embedded, so a
|
|
511
|
+
* captured `Console` holds one entry per document, not one per line. Top-level blocks are joined with no blank
|
|
512
|
+
* line between them; wrap them in `Doc.section(undefined, [...])` to space them.
|
|
513
|
+
*
|
|
490
514
|
* `CurrentRuntimeEnv` is read if the environment has one and is not required: a `ci` audience prints
|
|
491
515
|
* GitHub's log format only when it says GitHub Actions, and plain text otherwise, including when it is
|
|
492
516
|
* absent. An explicit `format` is honoured whatever the audience.
|
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ Colour follows Node's precedence, per stream: `FORCE_COLOR` decides first and be
|
|
|
123
123
|
## Output
|
|
124
124
|
|
|
125
125
|
- **`CliMessage`**: `success`, `info`, `warning`, `failure` and `status(vocab, name, text)`. One themed line each, through `Console` rather than the logger, so no log level silences them. Warnings and failures go to stderr.
|
|
126
|
-
- **`Doc` and `Render`**: a document IR (headings, paragraphs, lists, tables, trees, counts, count tables, collapsibles, callouts, code blocks, diffs, GitHub annotations) and pure `plain`, `ansi`, `markdown` and `githubLog` renderers. `Doc.print` picks the renderer for the audience. `Render.contextOf` renders outside Effect, for example markdown for a step summary.
|
|
126
|
+
- **`Doc` and `Render`**: a document IR (headings, paragraphs, lists, tables, trees, counts, count tables, collapsibles, callouts, code blocks, diffs, GitHub annotations) and pure `plain`, `ansi`, `markdown` and `githubLog` renderers. `Doc.print` picks the renderer for the audience, and lays out at the terminal's width only when the stream is a terminal: piped output (`tool | grep`) never wraps. `Doc.line(content, { wrap: false })` keeps one line whole at any width, glyph and colour included. `Render.contextOf` renders outside Effect, for example markdown for a step summary.
|
|
127
127
|
- **`CliTheme`, `Token`, `Status`, `Glyphs`**: semantic tokens (`success`, `failure`, `warning`, `info`, `error`, `muted`, `accent`, `emphasis`), an extendable status vocabulary with glyphs and ranks, and Unicode or ASCII glyph sets. Override tokens with `env.theme`.
|
|
128
128
|
- **`CliLinks`**: file links that open in VS Code (`vscode://file/…`) or as `file://` URLs, as OSC 8 hyperlinks where the terminal renders them, never for an agent.
|
|
129
129
|
- **`Fmt`**: `sanitize`, `width`, `truncate`, `duration`, `percent` and `plural`.
|
|
@@ -132,7 +132,7 @@ Every string that enters a document or a message is sanitised: escape sequences
|
|
|
132
132
|
|
|
133
133
|
## Failures and exit codes
|
|
134
134
|
|
|
135
|
-
`CliRuntime.main` reports a failure as a document on stderr, through the audience's renderer: a status line for a typed failure, a tree of rejected values for a schema failure, and a defect's message with a collapsible stack of your own frames (Effect's, Node's and `node_modules` frames hidden). Give an error class a `[CliDoc]()` method to draw itself, or pass a `render` option. Its `details.lines({ status: false })` keeps the run's colour and paths behind your own prefix.
|
|
135
|
+
`CliRuntime.main` reports a failure as a document on stderr, through the audience's renderer: a status line for a typed failure, a tree of rejected values for a schema failure, and a defect's message with a collapsible stack of your own frames (Effect's, Node's and `node_modules` frames hidden). Give an error class a `[CliDoc]()` method to draw itself, or pass a `render` option. Its `details.lines({ status: false })` keeps the run's colour and paths behind your own prefix. The `in: outer › inner` span trail after a failure names only your own spans by default; `env.spans` (`"app"`, `"all"` or `"off"`) chooses, as `env.stackFrames` does for a defect's frames. `"app"` leaves out spans defined in files under `node_modules/@effected/` or `node_modules/effect/`, and fails open: a kit package linked into a workspace, or a bundled program, shows more, never less. A program that is itself installed under `node_modules/@effected/` passes its bin's `import.meta.url` as `env.appModule` to keep its own spans. `env.spansEnvVar` names a variable (say `TOOL_SPANS`) that sets it at run time, as `log.envVar` sets the level. An `Effect.fn` call and its definition are one entry in the trail.
|
|
136
136
|
|
|
137
137
|
- `CliExit.set(code)` records a findings exit code from a handler that still succeeds. Do not provide `CliExit.layer` yourself under `main`, or the code goes to a second, unread cell.
|
|
138
138
|
- `Cancelled` (a prompt quit) exits `130`, and `NotInteractive` (a prompt with nobody to ask) exits `64`, each as one fixed line.
|
|
@@ -143,7 +143,7 @@ Every string that enters a document or a message is sanitised: escape sequences
|
|
|
143
143
|
|
|
144
144
|
## Logging
|
|
145
145
|
|
|
146
|
-
`CliLogger` writes plain lines, with no timestamp, level or fiber id, and routes every level to stderr by default (`stderrFrom` narrows it), so stdout carries only the program's output. Pass `env.log` to `main` for **`CliLog`**: a diagnostics level of its own (`level`, or `envVar` such as `TOOL_LOG_LEVEL`, with core's `--log-level` beating both), pretty lines for a person and NDJSON for an agent or CI, an optional NDJSON log file, and `CliLog.component(name)` tags.
|
|
146
|
+
`CliLogger` writes plain lines, with no timestamp, level or fiber id, and routes every level to stderr by default (`stderrFrom` narrows it), so stdout carries only the program's output. Pass `env.log` to `main` for **`CliLog`**: a diagnostics level of its own (`level`, or `envVar` such as `TOOL_LOG_LEVEL`, with core's `--log-level` beating both), pretty lines for a person and NDJSON for an agent or CI, an optional NDJSON log file, and `CliLog.component(name)` tags. `CliLog.status(vocab, name, text)` logs a diagnostic with a painted status glyph (its text still sanitised), and `CliTheme.forAudience` applies the kit's "an agent never gets an escape" rule to a theme you paint with yourself.
|
|
147
147
|
|
|
148
148
|
## Prompts and screens
|
|
149
149
|
|
|
@@ -164,7 +164,7 @@ const profile = Flag.String("profile").pipe(
|
|
|
164
164
|
);
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
`@effected/cli/ui` adds Ink screens: `CliUi.run`, `prompt` (with an `otherwise`) and `fallback` (for a flag), over the widgets `Select`, `TextInput
|
|
167
|
+
`@effected/cli/ui` adds Ink screens: `CliUi.run`, `prompt` (with an `otherwise`) and `fallback` (for a flag), over the widgets `Select`, `TextInput` (with a `mask` for secrets, always, or from the moment a predicate spots one anywhere in the value, latched until the value is cleared: its `validate` message is drawn unmasked, so never echo the value in it), `MultiSelect`, `Confirm` (with toggles), `Toggle`, `Tabs` and `Viewport`. Your own screens use the key layer (`KeyTable`, `useKeys`, `KeyHelp`) and the theme bridge (`Styled`, `useTheme`, `useGlyphs`, `useTerminalSize`).
|
|
168
168
|
|
|
169
169
|
```tsx
|
|
170
170
|
import { CliUi, Select } from "@effected/cli/ui";
|
|
@@ -180,14 +180,30 @@ const pickProfile = Select.screen({
|
|
|
180
180
|
const profile = CliUi.prompt(pickProfile, { otherwise: "library" });
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
+
`CliUi.map(screen, f)` maps a screen's answer and leaves a cancel alone, so a `Confirm` can back a boolean flag ("confirm, or `--yes`"):
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { CliUi, Confirm } from "@effected/cli/ui";
|
|
187
|
+
import { Flag } from "effect/cli";
|
|
188
|
+
|
|
189
|
+
const yes = Flag.Boolean("yes").pipe(
|
|
190
|
+
Flag.withFallbackPrompt(
|
|
191
|
+
CliUi.fallback(
|
|
192
|
+
CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
|
|
193
|
+
{ flag: "yes", otherwise: false },
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
```
|
|
198
|
+
|
|
183
199
|
## Live views
|
|
184
200
|
|
|
185
|
-
`CliUi.live` folds a stream or a `PubSub` subscription of events into state, and draws **runs** with Ink while they are going: a run starts at `isStart`, redraws on a tick, and commits its final frame at `isTerminal`. Log lines go above the frame through `handle.logConsole`. End with `handle.close`, which folds everything still queued. When nobody is watching (a pipe, an agent, CI), each run's final frame prints once instead. `DocView` draws a `Doc` document inside a view byte for byte as `Doc.print` would, and `UiProvider` with `CliUi.context` gives an Ink tree you mount yourself the same theme.
|
|
201
|
+
`CliUi.live` folds a stream or a `PubSub` subscription of events into state, and draws **runs** with Ink while they are going: a run starts at `isStart`, redraws on a tick, and commits its final frame at `isTerminal`. Log lines go above the frame through `handle.logConsole`. End with `handle.close`, which folds everything still queued. When nobody is watching (a pipe, an agent, CI), each run's final frame prints once instead: give the view a `final: (state) => Document` and that run prints the document with no Ink or React loaded at all; `render` is then never called on such a run, not even to build an unused string. `render: CliUi.lazyView(() => import("./view.js"))` keeps the view's module, and React, off every run until one draws. `DocView` draws a `Doc` document inside a view byte for byte as `Doc.print` would, and `UiProvider` with `CliUi.context` gives an Ink tree you mount yourself the same theme.
|
|
186
202
|
|
|
187
203
|
## Testing
|
|
188
204
|
|
|
189
205
|
- **`@effected/cli/testing`**: `CliTest.sandbox` and `CliTest.run` spawn a built bin hermetically and return `{ exitCode, stdout, stderr }` as data. `TestTerminal` drives core's prompts.
|
|
190
|
-
- **`@effected/cli/ui/testing`**: `CliUiTest.render` mounts a screen on in-memory streams (`press`, `type`, `chunk`, `frame`, `result`). `view` mounts a display-only element, `session` drives a whole command's screens, and `live` mounts a live view on the production render path with a `TestClock` tick.
|
|
206
|
+
- **`@effected/cli/ui/testing`**: `CliUiTest.render` mounts a screen on in-memory streams (`press`, `type`, `chunk`, `frame`, `result`). `view` mounts a display-only element, `session` drives a whole command's screens (its `transcript` shows what reached the terminal, a live view's `logConsole` lines included, `stdoutWritten`/`stderrWritten` each stream alone as raw bytes, `stdoutTranscript`/`stderrTranscript` each stream alone as plain text, and `renderPath: "production"` makes `clear` observable), and `live` mounts a live view on the production render path with a `TestClock` tick. `CliUiTest.serializer` prints frames as token markup in snapshots: register it in the Vitest config with `snapshotSerializers: ["@effected/cli/ui/testing/serializer"]`, or with `expect.addSnapshotSerializer`. Snapshots are the one place a test needs `expect`, since `assert` has no snapshot form.
|
|
191
207
|
|
|
192
208
|
In-process, provide `layerTest`s from `@effected/env` and `CliTheme.layerTest`, swap in a capturing `Console`, and assert on both streams. Neither testing entrypoint is reachable from a CLI's runtime imports.
|
|
193
209
|
|
package/Render.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CliLinks } from "./CliLinks.js";
|
|
|
3
3
|
import { Glyphs } from "./Glyphs.js";
|
|
4
4
|
import { paintStyle } from "./internal/ansi.js";
|
|
5
5
|
import { Token } from "./Token.js";
|
|
6
|
-
import { CliTheme
|
|
6
|
+
import { CliTheme } from "./CliTheme.js";
|
|
7
7
|
import { renderAnsi } from "./internal/renderAnsi.js";
|
|
8
8
|
import { renderPlain } from "./internal/renderPlain.js";
|
|
9
9
|
import { renderGithubLog } from "./internal/renderGithubLog.js";
|
|
@@ -51,8 +51,9 @@ var Render = class {
|
|
|
51
51
|
* gets an escape and a terminal without OSC 8 gets the label;
|
|
52
52
|
* - `neutralizeWorkflowCommands` is set when `CurrentRuntimeEnv` says GitHub Actions (read if present, not
|
|
53
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**
|
|
55
|
-
*
|
|
54
|
+
* - `width` is the option, else `TerminalEnv.width()` for a human whose stream is a terminal, and **unbounded**
|
|
55
|
+
* (`Infinity`) for a human whose stream is not one (`tool | grep`, `tool > out.txt`), an agent or a CI, so nothing
|
|
56
|
+
* a reader needs is truncated or wrapped for a terminal that is not there: a pipe has no width to honour.
|
|
56
57
|
*
|
|
57
58
|
* @param stream - the stream the output is for
|
|
58
59
|
* @param options - an explicit width and a path display function
|
|
@@ -61,11 +62,11 @@ var Render = class {
|
|
|
61
62
|
const terminal = yield* TerminalEnv;
|
|
62
63
|
const { kind } = yield* Audience;
|
|
63
64
|
const theme = (yield* CliTheme).forStream(stream);
|
|
64
|
-
const seen =
|
|
65
|
+
const seen = CliTheme.forAudience(theme, kind);
|
|
65
66
|
const links = yield* CliLinks;
|
|
66
67
|
return {
|
|
67
68
|
...(yield* underGithubActions) ? { neutralizeWorkflowCommands: true } : {},
|
|
68
|
-
width: options?.width ?? (kind === "human" ? terminal.width() : Number.POSITIVE_INFINITY),
|
|
69
|
+
width: options?.width ?? (kind === "human" && terminal[stream].isTerminal ? terminal.width() : Number.POSITIVE_INFINITY),
|
|
69
70
|
audience: kind,
|
|
70
71
|
color: seen.color,
|
|
71
72
|
paint: seen.paint,
|
package/Status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitize } from "./Fmt.js";
|
|
1
2
|
import { Array, Option } from "effect";
|
|
2
3
|
|
|
3
4
|
//#region src/Status.ts
|
|
@@ -119,14 +120,16 @@ var Status = class Status {
|
|
|
119
120
|
* that draws it itself (an Ink tree, a reporter).
|
|
120
121
|
*
|
|
121
122
|
* @remarks
|
|
122
|
-
* Throws on an unknown name, as {@link Status.def} does.
|
|
123
|
+
* Throws on an unknown name, as {@link Status.def} does. The glyph is sanitised, as text in a document is: escape
|
|
124
|
+
* sequences and control characters in a vocabulary's glyph are removed, so a glyph built from data cannot paint the
|
|
125
|
+
* terminal, plant a hyperlink or move the cursor. Every kit path that draws a status glyph takes it from here.
|
|
123
126
|
*
|
|
124
127
|
* @param name - a name in this vocabulary
|
|
125
128
|
* @param glyphs - the glyph set, such as `Glyphs.unicode`, `Glyphs.ascii` or a theme's
|
|
126
129
|
*/
|
|
127
130
|
glyph(name, glyphs) {
|
|
128
131
|
const def = this.def(name);
|
|
129
|
-
return glyphs.kind === "ascii" ? def.ascii : def.glyph;
|
|
132
|
+
return sanitize(glyphs.kind === "ascii" ? def.ascii : def.glyph);
|
|
130
133
|
}
|
|
131
134
|
/**
|
|
132
135
|
* The status with the highest rank; a tie goes to the one that comes first in `names`.
|