@effected/cli-kit 0.0.0-stage → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cancelled.js +43 -0
- package/CliAudience.js +177 -0
- package/CliColor.js +59 -0
- package/CliEnv.js +101 -0
- package/CliExit.js +64 -0
- package/CliFailure.js +287 -0
- package/CliFormat.js +124 -0
- package/CliInteractive.js +94 -0
- package/CliLinks.js +153 -0
- package/CliLog.js +367 -0
- package/CliLogger.js +91 -0
- package/CliMessage.js +79 -0
- package/CliPrompt.js +110 -0
- package/CliRuntime.js +254 -0
- package/CliTest.js +206 -0
- package/CliTheme.js +142 -0
- package/ConfigIssueRenderer.js +63 -0
- package/Doc.js +548 -0
- package/Fmt.js +335 -0
- package/GithubAnnotation.js +39 -0
- package/Glyphs.js +82 -0
- package/LICENSE +21 -0
- package/NotInteractive.js +41 -0
- package/README.md +215 -2
- package/Render.js +280 -0
- package/SchemaIssueRenderer.js +47 -0
- package/Status.js +165 -0
- package/TestTerminal.js +101 -0
- package/Token.js +68 -0
- package/index.d.ts +3951 -0
- package/index.js +27 -0
- package/internal/ExitRequested.js +21 -0
- package/internal/HelpRouting.js +35 -0
- package/internal/ansi.js +229 -0
- package/internal/autoFormat.js +33 -0
- package/internal/canPrompt.js +14 -0
- package/internal/counts.js +83 -0
- package/internal/diagnostics.js +31 -0
- package/internal/displayPath.js +46 -0
- package/internal/displayWidth.js +42 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +17 -0
- package/internal/fileSink.js +61 -0
- package/internal/format.js +175 -0
- package/internal/inspect.js +40 -0
- package/internal/isExitCode.js +11 -0
- package/internal/layout.js +249 -0
- package/internal/linkScheme.js +29 -0
- package/internal/linkTarget.js +49 -0
- package/internal/logSafety.js +45 -0
- package/internal/renderAnsi.js +51 -0
- package/internal/renderDoc.js +322 -0
- package/internal/renderGithubLog.js +59 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +49 -0
- package/internal/scanAudience.js +105 -0
- package/internal/splitFrame.js +55 -0
- package/internal/terminalCapabilities.js +23 -0
- package/internal/wizardGate.js +17 -0
- package/package.json +90 -5
- package/testing.d.ts +246 -0
- package/testing.js +4 -0
- package/tsdoc-metadata.json +11 -0
- package/ui/CliUi.js +507 -0
- package/ui/CliUiDetached.js +36 -0
- package/ui/CliUiLive.js +436 -0
- package/ui/CliUiRouteStream.js +72 -0
- package/ui/Confirm.js +244 -0
- package/ui/DocView.js +76 -0
- package/ui/KeyHelp.js +61 -0
- package/ui/KeyTable.js +198 -0
- package/ui/MultiSelect.js +259 -0
- package/ui/Select.js +229 -0
- package/ui/Tabs.js +201 -0
- package/ui/TextInput.js +289 -0
- package/ui/Toggle.js +31 -0
- package/ui/UiKey.js +48 -0
- package/ui/UiProvider.js +62 -0
- package/ui/UiStreams.js +17 -0
- package/ui/UiTheme.js +147 -0
- package/ui/Viewport.js +203 -0
- package/ui/internal/ErrorBoundary.js +29 -0
- package/ui/internal/Holder.js +73 -0
- package/ui/internal/ScreenContext.js +51 -0
- package/ui/internal/UiProviders.js +38 -0
- package/ui/internal/ink.js +121 -0
- package/ui/internal/inkChalk.js +47 -0
- package/ui/internal/inkConsole.js +150 -0
- package/ui/internal/lazyView.js +73 -0
- package/ui/internal/lineRouter.js +70 -0
- package/ui/internal/lineText.js +18 -0
- package/ui/internal/mountPermit.js +15 -0
- package/ui/internal/processStreams.js +18 -0
- package/ui/internal/renderOptions.js +12 -0
- package/ui/testing/CliUiTest.js +792 -0
- package/ui/testing/fakeStreams.js +104 -0
- package/ui/testing/terminalModel.js +67 -0
- package/ui-testing-serializer.d.ts +12 -0
- package/ui-testing-serializer.js +32 -0
- package/ui-testing.d.ts +558 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1990 -0
- package/ui.js +17 -0
package/CliRuntime.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { Cancelled } from "./Cancelled.js";
|
|
2
|
+
import { CliColor } from "./CliColor.js";
|
|
3
|
+
import { CliEnv } from "./CliEnv.js";
|
|
4
|
+
import { CliExit } from "./CliExit.js";
|
|
5
|
+
import { CliLog, envBuildLogLayer, platformLogLayer } from "./CliLog.js";
|
|
6
|
+
import { CliLogger, makeCliLogger } from "./CliLogger.js";
|
|
7
|
+
import { sanitize } from "./Fmt.js";
|
|
8
|
+
import { ExitRequested } from "./internal/ExitRequested.js";
|
|
9
|
+
import { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, readSpans, refreshFailureTarget } from "./internal/failureTarget.js";
|
|
10
|
+
import { routeHelpOnUsageError } from "./internal/HelpRouting.js";
|
|
11
|
+
import { failureText, tagOf } from "./internal/inspect.js";
|
|
12
|
+
import { isExitCode } from "./internal/isExitCode.js";
|
|
13
|
+
import { TrustedLine } from "./internal/logSafety.js";
|
|
14
|
+
import { NotInteractive } from "./NotInteractive.js";
|
|
15
|
+
import { Cause, Effect, Layer, Logger, MutableRef, Option, Runtime } from "effect";
|
|
16
|
+
import { CliError, CliOutput } from "effect/cli";
|
|
17
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
18
|
+
import { withDistribution, withDistributionVersion } from "@effected/kit";
|
|
19
|
+
|
|
20
|
+
// src/CliRuntime.ts
|
|
21
|
+
const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
|
|
22
|
+
/** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
|
|
23
|
+
const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
|
|
24
|
+
/**
|
|
25
|
+
* A handler failure carrying a `CliError.userMessage` that `Command.runWith` already printed: it renders any such
|
|
26
|
+
* failure and sets the mark to `false`, keeping the error's own tag.
|
|
27
|
+
*/
|
|
28
|
+
const isRenderedUserMessage = (u) => typeof u === "object" && u !== null && CliError.getUserMessage(u) !== void 0 && Runtime.getErrorReported(u) === false;
|
|
29
|
+
/**
|
|
30
|
+
* The program with `CurrentDistribution` set (engine's own `withDistribution`), and `--version` decorated with the
|
|
31
|
+
* carrier's suffix (`withDistributionVersion`), wrapping whatever `formatVersion` is in effect rather than replacing a
|
|
32
|
+
* coloured or custom one.
|
|
33
|
+
*/
|
|
34
|
+
const withCarrier = (program, distribution) => {
|
|
35
|
+
const provided = program.pipe(withDistribution(distribution));
|
|
36
|
+
return distribution === null ? provided : Effect.updateService(provided, CliOutput.Formatter, (formatter) => ({
|
|
37
|
+
...formatter,
|
|
38
|
+
formatVersion: withDistributionVersion(Option.some(distribution))(formatter.formatVersion)
|
|
39
|
+
}));
|
|
40
|
+
};
|
|
41
|
+
/** The last line of defence of a failure report: the error's text, sanitised and neutralized, whatever else broke. */
|
|
42
|
+
const lastResort = (error) => CommandNeutralizer.lines(sanitize(failureText(error, "[unprintable failure]")));
|
|
43
|
+
const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
|
|
44
|
+
/**
|
|
45
|
+
* The error's own exit code when it carries one, otherwise the fallback.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* `Runtime.getErrorExitCode` cannot serve alone here: it answers `1` both for
|
|
49
|
+
* an error marked `1` and for an unmarked one, so an `exitCode` option would
|
|
50
|
+
* silently override a deliberate `1`. Testing for the marker keeps "the error
|
|
51
|
+
* chose its code" distinct from "nothing chose".
|
|
52
|
+
*/
|
|
53
|
+
const chooseExitCode = (error, fallback, byTag) => {
|
|
54
|
+
if (typeof error === "object" && error !== null && Runtime.errorExitCode in error) return Runtime.getErrorExitCode(error);
|
|
55
|
+
if (byTag === void 0) return fallback ?? 1;
|
|
56
|
+
const tag = tagOf(error);
|
|
57
|
+
return tag !== void 0 && Object.hasOwn(byTag, tag) ? byTag[tag] : fallback ?? 1;
|
|
58
|
+
};
|
|
59
|
+
/** Every `exitCodes` entry an integer in `1..255`, or a `RangeError` naming the first that is not. */
|
|
60
|
+
const checkExitCodes = (exitCodes) => {
|
|
61
|
+
if (exitCodes === void 0) return;
|
|
62
|
+
for (const [tag, code] of Object.entries(exitCodes)) if (!isExitCode(code) || code === 0) throw new RangeError(`CliRuntime: exitCodes.${tag} must be an integer 1..255, received ${String(code)}`);
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Report a CLI program's failures through the program's own logger.
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* ## What it prevents
|
|
69
|
+
*
|
|
70
|
+
* A platform `runMain` reports an unhandled failure using Effect's **default**
|
|
71
|
+
* logger. That logger sits **outside** the layers the program was provided —
|
|
72
|
+
* `makeRunMain` composes its reporting `tapCause` around the already-provided
|
|
73
|
+
* effect — so a program that carefully installs {@link CliLogger} still prints
|
|
74
|
+
* its failures in the structured format that logger exists to replace, and
|
|
75
|
+
* prints them on **stdout**, the one stream errors must not use.
|
|
76
|
+
*
|
|
77
|
+
* Nothing about the call site suggests this. The program looks correct, the
|
|
78
|
+
* logger is installed, every success path is right, and only a failure reveals
|
|
79
|
+
* it.
|
|
80
|
+
*
|
|
81
|
+
* ## Why a combinator, and not a `runMain`
|
|
82
|
+
*
|
|
83
|
+
* The fix has to happen **inside** the effect, before any `runMain` sees it. So
|
|
84
|
+
* this is a combinator you apply to your program, and you still call your own
|
|
85
|
+
* platform's runner. Wrapping `runMain` itself would drag a platform choice into a library that
|
|
86
|
+
* has no business making one, and would make this package unusable from Bun or
|
|
87
|
+
* Deno for no gain.
|
|
88
|
+
*
|
|
89
|
+
* ## What it does with the failure
|
|
90
|
+
*
|
|
91
|
+
* Renders it through the ambient logger, then **re-fails with a marked error**
|
|
92
|
+
* so the runtime still sees a failure — the exit code is the runtime's to set,
|
|
93
|
+
* and swallowing the failure would make a broken run exit `0`.
|
|
94
|
+
*
|
|
95
|
+
* The marks are core's own, and they are what make this work without a platform
|
|
96
|
+
* import:
|
|
97
|
+
*
|
|
98
|
+
* - `Runtime.errorExitCode` — `defaultTeardown` takes the squashed error's
|
|
99
|
+
* value as the process exit code. An error that already carries one **keeps
|
|
100
|
+
* it**: that was a deliberate choice by whatever raised it, and flattening
|
|
101
|
+
* every failure to a single code would discard it.
|
|
102
|
+
* - `Runtime.errorReported` — set to `false`, which suppresses the runtime's own
|
|
103
|
+
* duplicate report. **The polarity is inverted relative to the name**: the
|
|
104
|
+
* marker means "should this be reported", so the intuitive
|
|
105
|
+
* `errorReported: true` — "I have reported it, stay quiet" — produces exactly
|
|
106
|
+
* the double report it was meant to prevent.
|
|
107
|
+
*
|
|
108
|
+
* An **interrupt is left alone**: it is not a failure to report, and the
|
|
109
|
+
* default teardown already maps an interrupt-only cause to `130`.
|
|
110
|
+
*
|
|
111
|
+
* A `CliError.ShowHelp` is never rendered: `Command.runWith` already printed
|
|
112
|
+
* the help text (and any parse errors) before re-failing with it, so
|
|
113
|
+
* rendering it again would print nothing but a stray "Help requested" line.
|
|
114
|
+
* A `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`,
|
|
115
|
+
* BSD `EX_USAGE`); a bare `--help` or root invocation — `errors` empty —
|
|
116
|
+
* keeps exit `0`. A `CliError.UserError` whose `Runtime.errorReported` mark
|
|
117
|
+
* is `false` is likewise skipped: that is how `Command.runWith` leaves one it
|
|
118
|
+
* already rendered through its `CliOutput` formatter. It exits with its own
|
|
119
|
+
* `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
|
|
120
|
+
* under runWith's `renderErrors: false` the mark stays set, and it renders
|
|
121
|
+
* here like any other failure. A handler failure carrying a
|
|
122
|
+
* `CliError.userMessage` is skipped the same way, since `Command.runWith`
|
|
123
|
+
* renders it and sets the mark to `false`; it keeps the exit code a
|
|
124
|
+
* handler failure gets (`exitCodes` by tag, then `exitCode`). Every other
|
|
125
|
+
* error renders even when it already carries the reported mark — a gate
|
|
126
|
+
* failure marked with `CliRuntime.reported` still prints its line. The
|
|
127
|
+
* private `ExitRequested` sentinel `CliRuntime.main`
|
|
128
|
+
* raises is likewise never rendered; it only carries the exit code a
|
|
129
|
+
* successful program recorded through `CliExit`.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* import { CliRuntime } from "@effected/cli-kit"
|
|
134
|
+
* import { NodeRuntime } from "@effect/platform-node"
|
|
135
|
+
* import { Effect } from "effect"
|
|
136
|
+
*
|
|
137
|
+
* NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)))
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @public
|
|
141
|
+
*/
|
|
142
|
+
var CliRuntime = class CliRuntime {
|
|
143
|
+
constructor() {}
|
|
144
|
+
/**
|
|
145
|
+
* What `reportFailures` and `main` render a failure as when no `render` option is given, for a consumer's own
|
|
146
|
+
* `render` to hand a failure back to.
|
|
147
|
+
*
|
|
148
|
+
* @remarks
|
|
149
|
+
* The plain lines of `CliFailure.toDoc(details.cause)`: a failure status line, a `Tree` for a schema failure, a
|
|
150
|
+
* defect's message with its cleaned `stack`, and the one fixed line each for `Cancelled` and `NotInteractive`.
|
|
151
|
+
* It has no terminal to ask, so it is the plain rendering for an agent, with absolute paths; the report `main` writes
|
|
152
|
+
* with no `render` option is the same document in the renderer the audience gets (painted for a person), and that
|
|
153
|
+
* report is `details.defaultLines`: return those to hand a failure back with the run's colour, links and path
|
|
154
|
+
* display. With `status: false` the leading status (the glyph, or `[FAIL]` in plain text) is left off, so a prefix
|
|
155
|
+
* such as the program's name reads cleanly; for that AND the run's settings, use `details.lines({ status: false })`.
|
|
156
|
+
* A custom `render` that only cares about its own errors delegates the rest:
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* const render = (error: unknown, details: FailureDetails) =>
|
|
160
|
+
* error instanceof MyError ? myLines(error) : CliRuntime.defaultRender(error, details)
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* @param error - the squashed failure
|
|
164
|
+
* @param details - what `render` is told about the failure; accepted so a delegating `render` passes both
|
|
165
|
+
* arguments through unchanged (only `cause` and `isDefect` are read)
|
|
166
|
+
* @param options - `status: false` leaves off the leading status glyph or `[FAIL]` tag
|
|
167
|
+
*/
|
|
168
|
+
static defaultRender = (error, details, options) => plainFailureLines(details.cause.reasons.length > 0 ? details.cause : Cause.fail(error), options?.status !== false);
|
|
169
|
+
/**
|
|
170
|
+
* Catch a program's failure, render it through the ambient logger, and re-fail with the exit code and the
|
|
171
|
+
* no-double-report mark.
|
|
172
|
+
*
|
|
173
|
+
* @param options - how to render the failure and which exit codes to use
|
|
174
|
+
*/
|
|
175
|
+
static reportFailures = (options = {}) => {
|
|
176
|
+
checkExitCodes(options.exitCodes);
|
|
177
|
+
return (effect) => effect.pipe(Effect.catchCause((cause) => {
|
|
178
|
+
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
|
|
179
|
+
const error = Cause.squash(cause);
|
|
180
|
+
if (error instanceof ExitRequested) return Effect.fail(error);
|
|
181
|
+
if (isShowHelp(error)) {
|
|
182
|
+
const code = error.errors.length > 0 ? options.usageExitCode ?? 64 : 0;
|
|
183
|
+
return Effect.fail(CliRuntime.reported(error, code));
|
|
184
|
+
}
|
|
185
|
+
if (isRenderedUserError(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.usageExitCode ?? 64)));
|
|
186
|
+
if (isRenderedUserMessage(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode, options.exitCodes)));
|
|
187
|
+
const render = options.render;
|
|
188
|
+
return Effect.gen(function* () {
|
|
189
|
+
const target = yield* currentTarget.pipe(Effect.catchCause(() => Effect.succeed(fallbackTarget)));
|
|
190
|
+
const reportLines = (status, spans) => {
|
|
191
|
+
try {
|
|
192
|
+
return linesOf(cause, target, status, spans ?? target.spans);
|
|
193
|
+
} catch {
|
|
194
|
+
try {
|
|
195
|
+
return plainFailureLines(cause, status, spans ?? target.spans);
|
|
196
|
+
} catch {
|
|
197
|
+
return lastResort(error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const defaultLines = reportLines(true);
|
|
202
|
+
const isDefect = !Cause.hasFails(cause);
|
|
203
|
+
const exitCode = chooseExitCode(error, options.exitCode, isDefect ? void 0 : options.exitCodes);
|
|
204
|
+
const details = {
|
|
205
|
+
cause,
|
|
206
|
+
isDefect,
|
|
207
|
+
exitCode,
|
|
208
|
+
isCancelled: error instanceof Cancelled,
|
|
209
|
+
isNotInteractive: error instanceof NotInteractive,
|
|
210
|
+
defaultLines,
|
|
211
|
+
lines: (options) => options?.status === false || options?.spans !== void 0 ? reportLines(options?.status !== false, options?.spans) : defaultLines
|
|
212
|
+
};
|
|
213
|
+
const lines = render === void 0 ? defaultLines : yield* guardConsumerLines(toLines(render(error, details)));
|
|
214
|
+
for (const line of lines) yield* Effect.logError(line).pipe(Effect.provideService(TrustedLine, true));
|
|
215
|
+
return yield* Effect.fail(CliRuntime.reported(error, exitCode));
|
|
216
|
+
});
|
|
217
|
+
}));
|
|
218
|
+
};
|
|
219
|
+
static main(program, options) {
|
|
220
|
+
const env = options.env === void 0 ? void 0 : CliEnv.layer(options.env);
|
|
221
|
+
const envLog = options.env?.log;
|
|
222
|
+
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))));
|
|
223
|
+
const inside = env === void 0 ? Layer.empty : Layer.mergeAll(CliColor.formatterLayer(options.env?.formatter), Layer.effectDiscard(Effect.gen(function* () {
|
|
224
|
+
const { spans, invalid } = yield* readSpans(options.env?.spans, options.env?.spansEnvVar);
|
|
225
|
+
if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([makeCliLogger(envLog?.logger)])));
|
|
226
|
+
yield* refreshFailureTarget(void 0, {
|
|
227
|
+
stackFrames: options.env?.stackFrames,
|
|
228
|
+
spans,
|
|
229
|
+
appModule: options.env?.appModule
|
|
230
|
+
});
|
|
231
|
+
}))).pipe(Layer.provideMerge(env));
|
|
232
|
+
const carried = options.distribution === void 0 ? program : withCarrier(program, options.distribution);
|
|
233
|
+
const run = Effect.gen(function* () {
|
|
234
|
+
yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(carried) : carried;
|
|
235
|
+
const exit = yield* CliExit;
|
|
236
|
+
const code = MutableRef.get(exit.code);
|
|
237
|
+
if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
|
|
238
|
+
if (code !== 0) return yield* Effect.fail(new ExitRequested(code));
|
|
239
|
+
}).pipe(Effect.provide(CliExit.layer), Effect.provide(inside), Effect.provide(options.platform), CliRuntime.reportFailures(options), Effect.provide(logger));
|
|
240
|
+
return Effect.suspend(() => Effect.provideService(run, FailureTargetCell, MutableRef.make(void 0)));
|
|
241
|
+
}
|
|
242
|
+
static reported(error, exitCode = 1) {
|
|
243
|
+
const marked = error instanceof Error ? error : new Error(String(error));
|
|
244
|
+
for (const [key, value] of [[Runtime.errorReported, false], [Runtime.errorExitCode, exitCode]]) Object.defineProperty(marked, key, {
|
|
245
|
+
value,
|
|
246
|
+
writable: true,
|
|
247
|
+
configurable: true,
|
|
248
|
+
enumerable: false
|
|
249
|
+
});
|
|
250
|
+
return marked;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export { CliRuntime };
|
package/CliTest.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Fmt } from "./Fmt.js";
|
|
2
|
+
import { Duration, Effect, Fiber, FileSystem, Option, Path, Stream, SubscriptionRef } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
|
+
|
|
5
|
+
// src/CliTest.ts
|
|
6
|
+
/** The complete lines of `captured` from `offset`, and the rest too once it has ended; with the offset after them. */
|
|
7
|
+
const linesFrom = (offset, captured) => {
|
|
8
|
+
const lines = [];
|
|
9
|
+
let start = offset;
|
|
10
|
+
let end = captured.text.indexOf("\n", start);
|
|
11
|
+
while (end !== -1) {
|
|
12
|
+
lines.push(captured.text.slice(start, captured.text[end - 1] === "\r" ? end - 1 : end));
|
|
13
|
+
start = end + 1;
|
|
14
|
+
end = captured.text.indexOf("\n", start);
|
|
15
|
+
}
|
|
16
|
+
if (captured.done && start < captured.text.length) {
|
|
17
|
+
lines.push(captured.text.slice(start));
|
|
18
|
+
start = captured.text.length;
|
|
19
|
+
}
|
|
20
|
+
return [start, lines];
|
|
21
|
+
};
|
|
22
|
+
/** The bound on a child that ignores its termination signal, when the caller gives none. */
|
|
23
|
+
const FORCE_KILL_AFTER = "2 seconds";
|
|
24
|
+
/** `options.forceKillAfter` or the default; a duration that is not finite is a wiring mistake and throws. */
|
|
25
|
+
const forceKillAfterOf = (options) => {
|
|
26
|
+
const input = options.forceKillAfter ?? FORCE_KILL_AFTER;
|
|
27
|
+
const duration = Duration.fromInput(input);
|
|
28
|
+
if (typeof input === "number" && Number.isNaN(input) || Option.isNone(duration) || !Duration.isFinite(duration.value) || Duration.toMillis(duration.value) < 0) throw new RangeError(`CliTest: forceKillAfter must be a finite, non-negative duration, received ${String(input)}`);
|
|
29
|
+
return duration.value;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Core's handle fails `exitCode` for a child a signal ended, naming the signal only in the error's text; this reads it
|
|
33
|
+
* back out, so a signal is an outcome as an exit code is. Any other failure (a failed stdin stream) stays a failure.
|
|
34
|
+
*/
|
|
35
|
+
const SIGNALLED = /receipt of signal: '(SIG[A-Z0-9]+)'/;
|
|
36
|
+
const endingOf = (exitCode) => exitCode.pipe(Effect.map((code) => ({
|
|
37
|
+
exitCode: Number(code),
|
|
38
|
+
signal: null
|
|
39
|
+
})), Effect.catchTag("PlatformError", (error) => {
|
|
40
|
+
const match = SIGNALLED.exec(`${error.message} ${Fmt.causeMessage(error) ?? ""}`);
|
|
41
|
+
return match === null ? Effect.fail(error) : Effect.succeed({
|
|
42
|
+
exitCode: null,
|
|
43
|
+
signal: match[1]
|
|
44
|
+
});
|
|
45
|
+
}));
|
|
46
|
+
const commandOf = (bin, args, options, forceKillAfter) => ChildProcess.make(options.execPath, [bin, ...args], {
|
|
47
|
+
forceKillAfter,
|
|
48
|
+
cwd: options.cwd ?? options.sandbox.root,
|
|
49
|
+
env: {
|
|
50
|
+
...options.sandbox.env,
|
|
51
|
+
...options.env
|
|
52
|
+
},
|
|
53
|
+
stdin: options.stdin === void 0 || options.stdin === "" ? Stream.empty : Stream.make(new TextEncoder().encode(options.stdin))
|
|
54
|
+
});
|
|
55
|
+
const text = (stream) => Stream.mkString(Stream.decodeText(stream));
|
|
56
|
+
/**
|
|
57
|
+
* Spawn a built CLI bin hermetically and read its exit code and streams as data.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
62
|
+
* import { assert, it } from "@effect/vitest"
|
|
63
|
+
* import { CliTest } from "@effected/cli-kit/testing"
|
|
64
|
+
* import { Effect } from "effect"
|
|
65
|
+
*
|
|
66
|
+
* it.effect("prints its version", () =>
|
|
67
|
+
* Effect.gen(function* () {
|
|
68
|
+
* const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" })
|
|
69
|
+
* const result = yield* CliTest.run("dist/bin.js", ["--version"], { sandbox, execPath: process.execPath })
|
|
70
|
+
* assert.strictEqual(result.exitCode, 0)
|
|
71
|
+
* }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
|
|
72
|
+
* )
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @public
|
|
76
|
+
*/
|
|
77
|
+
var CliTest = class {
|
|
78
|
+
constructor() {}
|
|
79
|
+
/**
|
|
80
|
+
* A scoped temp directory with a fresh `HOME` and XDG tree, `NO_COLOR=1`,
|
|
81
|
+
* and the `PATH` you pass — the host environment is never inherited.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* Each call mints a fresh temp directory; bind the result to a `const`
|
|
85
|
+
* within one test rather than calling this more than once per assertion.
|
|
86
|
+
*/
|
|
87
|
+
static sandbox = (options) => Effect.gen(function* () {
|
|
88
|
+
const fs = yield* FileSystem.FileSystem;
|
|
89
|
+
const path = yield* Path.Path;
|
|
90
|
+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "effected-cli-test-" });
|
|
91
|
+
const home = path.join(root, "home");
|
|
92
|
+
const xdg = {
|
|
93
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
94
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
95
|
+
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
96
|
+
XDG_CACHE_HOME: path.join(home, ".cache")
|
|
97
|
+
};
|
|
98
|
+
for (const dir of Object.values(xdg)) yield* fs.makeDirectory(dir, { recursive: true });
|
|
99
|
+
return {
|
|
100
|
+
root,
|
|
101
|
+
home,
|
|
102
|
+
env: {
|
|
103
|
+
HOME: home,
|
|
104
|
+
...xdg,
|
|
105
|
+
PATH: options.path,
|
|
106
|
+
NO_COLOR: "1"
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* Run `execPath bin ...args`; a non-zero exit is returned, never failed.
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* `stdin` is never left as an inherited open pipe: when omitted or `""`
|
|
115
|
+
* the child gets an already-ended empty input (`Stream.empty`), so a
|
|
116
|
+
* stdin-reading bin exits instead of hanging on `effect/process`'s
|
|
117
|
+
* default `"pipe"` stdio, which stays open until something writes to and
|
|
118
|
+
* ends it.
|
|
119
|
+
*/
|
|
120
|
+
static run = (bin, args, options) => {
|
|
121
|
+
const forceKillAfter = forceKillAfterOf(options);
|
|
122
|
+
return Effect.scoped(Effect.gen(function* () {
|
|
123
|
+
const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(commandOf(bin, args, options, forceKillAfter));
|
|
124
|
+
const [stdout, stderr, ending] = yield* Effect.all([
|
|
125
|
+
text(handle.stdout),
|
|
126
|
+
text(handle.stderr),
|
|
127
|
+
endingOf(handle.exitCode)
|
|
128
|
+
], { concurrency: "unbounded" });
|
|
129
|
+
return {
|
|
130
|
+
...ending,
|
|
131
|
+
stdout,
|
|
132
|
+
stderr
|
|
133
|
+
};
|
|
134
|
+
}));
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Start `execPath bin ...args` and return at once with a handle on it: its output as lines while it runs, a signal,
|
|
138
|
+
* and its exit, for a long-running command (a `--follow`, a watch mode, a server) that {@link CliTest.run} would wait
|
|
139
|
+
* on forever.
|
|
140
|
+
*
|
|
141
|
+
* @remarks
|
|
142
|
+
* The child lives in the caller's scope: when the scope closes first, the child is sent `SIGTERM`, then `SIGKILL`
|
|
143
|
+
* after `forceKillAfter` (`2 seconds` by default), so a test that ends early leaves nothing running and never waits
|
|
144
|
+
* on a child that ignores the signal. A child a signal ended reports `exitCode: null` and the signal in `result`. Both streams are drained from the start whether or not the test reads them, so a
|
|
145
|
+
* chatty child never blocks on a full pipe, and each stream replays from its first line to every reader. The
|
|
146
|
+
* options are {@link CliTest.run}'s, `stdin` included: the child gets that text, then an ended input.
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* const child = yield* CliTest.spawn("dist/bin.js", ["logs", "--follow"], { sandbox, execPath: process.execPath })
|
|
150
|
+
* yield* Stream.runHead(child.stdout) // the first line: it is following
|
|
151
|
+
* yield* child.kill("SIGINT")
|
|
152
|
+
* assert.strictEqual(yield* child.exitCode, 0)
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @param bin - the built bin to run
|
|
156
|
+
* @param args - its arguments
|
|
157
|
+
* @param options - the sandbox, the node binary, and the working directory, environment and stdin
|
|
158
|
+
*/
|
|
159
|
+
static spawn = (bin, args, options) => {
|
|
160
|
+
const forceKillAfter = forceKillAfterOf(options);
|
|
161
|
+
return Effect.gen(function* () {
|
|
162
|
+
const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(commandOf(bin, args, options, forceKillAfter));
|
|
163
|
+
const capture = (stream) => Effect.gen(function* () {
|
|
164
|
+
const ref = yield* SubscriptionRef.make({
|
|
165
|
+
text: "",
|
|
166
|
+
done: false
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
ref,
|
|
170
|
+
pump: yield* Stream.decodeText(stream).pipe(Stream.runForEach((chunk) => SubscriptionRef.update(ref, (s) => ({
|
|
171
|
+
text: s.text + chunk,
|
|
172
|
+
done: false
|
|
173
|
+
}))), Effect.ensuring(SubscriptionRef.update(ref, (s) => ({
|
|
174
|
+
text: s.text,
|
|
175
|
+
done: true
|
|
176
|
+
}))), Effect.forkScoped),
|
|
177
|
+
lines: SubscriptionRef.changes(ref).pipe(Stream.takeUntil((s) => s.done), Stream.mapAccum(() => 0, linesFrom))
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const out = yield* capture(handle.stdout);
|
|
181
|
+
const err = yield* capture(handle.stderr);
|
|
182
|
+
const ending = endingOf(handle.exitCode);
|
|
183
|
+
return {
|
|
184
|
+
stdout: out.lines,
|
|
185
|
+
stderr: err.lines,
|
|
186
|
+
kill: (signal) => handle.kill({
|
|
187
|
+
killSignal: signal ?? "SIGTERM",
|
|
188
|
+
forceKillAfter
|
|
189
|
+
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.flatMap(handle.isRunning, (running) => running ? Effect.fail(error) : Effect.void))),
|
|
190
|
+
exitCode: Effect.map(ending, (end) => end.exitCode),
|
|
191
|
+
result: Effect.gen(function* () {
|
|
192
|
+
const end = yield* ending;
|
|
193
|
+
yield* Fiber.join(out.pump);
|
|
194
|
+
yield* Fiber.join(err.pump);
|
|
195
|
+
return {
|
|
196
|
+
...end,
|
|
197
|
+
stdout: (yield* SubscriptionRef.get(out.ref)).text,
|
|
198
|
+
stderr: (yield* SubscriptionRef.get(err.ref)).text
|
|
199
|
+
};
|
|
200
|
+
})
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export { CliTest };
|
package/CliTheme.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Glyphs } from "./Glyphs.js";
|
|
2
|
+
import { openSequence, paintStyle } from "./internal/ansi.js";
|
|
3
|
+
import { Token } from "./Token.js";
|
|
4
|
+
import { Config, Context, Effect, Layer, Option } from "effect";
|
|
5
|
+
import { TerminalEnv } from "@effected/env";
|
|
6
|
+
import { Prompt } from "effect/cli";
|
|
7
|
+
|
|
8
|
+
// src/CliTheme.ts
|
|
9
|
+
/**
|
|
10
|
+
* A stream theme at `color`, over a style resolution and a glyph set: the one way a {@link StreamTheme} is built.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const streamThemeAt = (resolve, glyphs, color) => {
|
|
15
|
+
const paint = (token, text) => paintStyle(resolve(token), color, text);
|
|
16
|
+
return {
|
|
17
|
+
paint,
|
|
18
|
+
style: resolve,
|
|
19
|
+
sgr: (token) => openSequence(resolve(token), color),
|
|
20
|
+
glyphs,
|
|
21
|
+
color,
|
|
22
|
+
status: (vocab, name, text) => {
|
|
23
|
+
const glyph = paint(vocab.def(name).token, vocab.glyph(name, glyphs));
|
|
24
|
+
return text === void 0 || text === "" ? glyph : `${glyph} ${text}`;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
};
|
|
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;
|
|
30
|
+
const make = (colors, glyphs, overrides) => {
|
|
31
|
+
const resolve = (token) => Token.resolve(token, overrides);
|
|
32
|
+
const stdout = streamThemeAt(resolve, glyphs, colors.stdout);
|
|
33
|
+
const stderr = streamThemeAt(resolve, glyphs, colors.stderr);
|
|
34
|
+
return {
|
|
35
|
+
...stdout,
|
|
36
|
+
forStream: (stream) => stream === "stdout" ? stdout : stderr
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/** The prompt glyphs that differ under ASCII; `Prompt.makeTheme` already holds the Unicode ones. */
|
|
40
|
+
const ASCII_PROMPT_GLYPHS = {
|
|
41
|
+
prefix: "?",
|
|
42
|
+
arrowUp: "^",
|
|
43
|
+
arrowDown: "v",
|
|
44
|
+
checkboxOn: "[x]",
|
|
45
|
+
checkboxOff: "[ ]",
|
|
46
|
+
tick: "+",
|
|
47
|
+
pointerSmall: ">",
|
|
48
|
+
pointer: ">"
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The presentation of a CLI: colour tokens, glyphs and statuses, decided once from the terminal.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* A `Context.Service`, not a `Reference`. A colour level is a fact about the terminal, not a preference with a
|
|
55
|
+
* safe default, so there is nothing sensible for an unprovided theme to read; requiring it puts `CliTheme` in
|
|
56
|
+
* `R` and a program that forgot to wire it fails to compile rather than printing plain text to a colour
|
|
57
|
+
* terminal. {@link CliTheme.layer} reads `TerminalEnv`.
|
|
58
|
+
*
|
|
59
|
+
* @public
|
|
60
|
+
*/
|
|
61
|
+
var CliTheme = class CliTheme extends Context.Service()("@effected/cli-kit/CliTheme") {
|
|
62
|
+
/**
|
|
63
|
+
* The theme for the terminal `TerminalEnv` describes.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Bind the layer to a constant and provide it once. With `glyphs: "auto"` the glyph set is ASCII only when
|
|
67
|
+
* `TERM=dumb`, read through `Config`.
|
|
68
|
+
*
|
|
69
|
+
* @param options - token overrides and the glyph set
|
|
70
|
+
*/
|
|
71
|
+
static layer = (options) => Layer.effect(CliTheme, Effect.gen(function* () {
|
|
72
|
+
const terminal = yield* TerminalEnv;
|
|
73
|
+
const choice = options?.glyphs ?? "auto";
|
|
74
|
+
const term = choice === "auto" ? Option.getOrUndefined(yield* Config.option(Config.String("TERM")).pipe(Effect.orElseSucceed(() => Option.none()))) : void 0;
|
|
75
|
+
const glyphs = Glyphs.select({
|
|
76
|
+
ascii: choice === "ascii" ? true : choice === "unicode" ? false : "auto",
|
|
77
|
+
...term === void 0 ? {} : { term }
|
|
78
|
+
});
|
|
79
|
+
return make({
|
|
80
|
+
stdout: terminal.stdout.color,
|
|
81
|
+
stderr: terminal.stderr.color
|
|
82
|
+
}, glyphs, options?.tokens);
|
|
83
|
+
}));
|
|
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
|
+
/**
|
|
109
|
+
* A fixed theme that needs nothing; `none` colour and Unicode glyphs unless told otherwise.
|
|
110
|
+
*
|
|
111
|
+
* @param options - the colour level and glyph set
|
|
112
|
+
*/
|
|
113
|
+
static layerTest = (options) => Layer.succeed(CliTheme, make({
|
|
114
|
+
stdout: options?.color ?? "none",
|
|
115
|
+
stderr: options?.stderrColor ?? options?.color ?? "none"
|
|
116
|
+
}, options?.glyphs === "ascii" ? Glyphs.ascii : Glyphs.unicode, void 0));
|
|
117
|
+
/**
|
|
118
|
+
* Sets core's `Prompt.Theme` from the tokens, so built-in prompts match the rest of the output.
|
|
119
|
+
*
|
|
120
|
+
* @remarks
|
|
121
|
+
* The colour fields are raw SGR openers and are empty strings when colour is `none`, and `colors` is the theme's
|
|
122
|
+
* own decision (`false` at `none`), so core's prompts and its command wizard write no colour or style escape, not
|
|
123
|
+
* even a reset, and core never re-reads the host for `NO_COLOR` or a TTY. Cursor and redraw controls are still
|
|
124
|
+
* written, whatever the theme says. Under ASCII glyphs the prompt symbols fall back to ASCII too.
|
|
125
|
+
*/
|
|
126
|
+
static promptTheme = Layer.effect(Prompt.Theme, Effect.gen(function* () {
|
|
127
|
+
const theme = yield* CliTheme;
|
|
128
|
+
const open = (token) => theme.sgr(token);
|
|
129
|
+
return Prompt.makeTheme({
|
|
130
|
+
...theme.glyphs.kind === "ascii" ? ASCII_PROMPT_GLYPHS : {},
|
|
131
|
+
ellipsis: theme.glyphs.ellipsis,
|
|
132
|
+
colors: theme.color !== "none",
|
|
133
|
+
primaryColor: open("accent"),
|
|
134
|
+
mutedColor: open("muted"),
|
|
135
|
+
successColor: open("success"),
|
|
136
|
+
errorColor: open("error"),
|
|
137
|
+
submittedColor: open("emphasis")
|
|
138
|
+
});
|
|
139
|
+
}));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export { CliTheme, streamThemeAt };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { formatIssueWithHints } from "./internal/format.js";
|
|
2
|
+
|
|
3
|
+
// src/ConfigIssueRenderer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Turns a `@effected/config-file` `ConfigValidationError` into one line per rejected value.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* `ConfigValidationError` carries the structured `issue` tree rather than a
|
|
9
|
+
* string, so a caller holds a tree it has to turn into sentences. This is that
|
|
10
|
+
* step, the same treatment {@link SchemaIssueRenderer} gives a bare issue.
|
|
11
|
+
*
|
|
12
|
+
* `ConfigValidationError.message` names the file but not the value, and the
|
|
13
|
+
* value is the diagnostic: printing only the message tells a user their config
|
|
14
|
+
* is invalid without saying which value is wrong or how it is shaped.
|
|
15
|
+
*
|
|
16
|
+
* `@effected/config-file` is an optional peer, and this module only
|
|
17
|
+
* `import type`s it, so the import is erased at build time. A consumer without
|
|
18
|
+
* the package installed can import this module without the resolver being asked
|
|
19
|
+
* for it.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { ConfigIssueRenderer } from "@effected/cli-kit"
|
|
24
|
+
* import { Effect } from "effect"
|
|
25
|
+
*
|
|
26
|
+
* const load = configFile.load.pipe(
|
|
27
|
+
* Effect.catchTag("ConfigValidationError", (error) =>
|
|
28
|
+
* Effect.gen(function* () {
|
|
29
|
+
* yield* Effect.logError(String(error))
|
|
30
|
+
* for (const line of ConfigIssueRenderer.render(error)) yield* Effect.logError(` ${line}`)
|
|
31
|
+
* }),
|
|
32
|
+
* ),
|
|
33
|
+
* )
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
var ConfigIssueRenderer = class {
|
|
39
|
+
constructor() {}
|
|
40
|
+
/**
|
|
41
|
+
* One line per rejected value.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Takes the **error**, not its `issue`, because that is what a `catchTag`
|
|
45
|
+
* hands you and because `issue` is typed `Schema.Defect`, which every call
|
|
46
|
+
* site would otherwise have to reach into. Inside
|
|
47
|
+
* `Effect.catchTag("ConfigValidationError", …)` the error is already this
|
|
48
|
+
* type.
|
|
49
|
+
*
|
|
50
|
+
* An unknown key the schema has a near match for at the same path ends in a
|
|
51
|
+
* hint, `unknown key at theme.colour (did you mean "color"?)`, drawn from the
|
|
52
|
+
* keys the struct declares there (every branch's, under a union) with
|
|
53
|
+
* {@link Fmt.suggest}'s threshold. A key with no near match is drawn exactly
|
|
54
|
+
* as {@link SchemaIssueRenderer} draws it.
|
|
55
|
+
*
|
|
56
|
+
* It cannot throw on a malformed value: the issue tree is validated by
|
|
57
|
+
* a guard before it is read, so a renderer on an error path never becomes the
|
|
58
|
+
* reason a program dies.
|
|
59
|
+
*/
|
|
60
|
+
static render = (error) => formatIssueWithHints(error?.issue);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export { ConfigIssueRenderer };
|