@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/CliMessage.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { underGithubActions } from "./internal/autoFormat.js";
|
|
2
|
+
import { sanitize } from "./Fmt.js";
|
|
3
|
+
import { CliTheme } from "./CliTheme.js";
|
|
4
|
+
import { Status } from "./Status.js";
|
|
5
|
+
import { Console, Effect } from "effect";
|
|
6
|
+
import { Audience } from "@effected/env";
|
|
7
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
8
|
+
|
|
9
|
+
//#region src/CliMessage.ts
|
|
10
|
+
/**
|
|
11
|
+
* One-line status messages: a glyph and some text, themed for a person and plain for an agent.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Each line goes through `Console`, `log` for stdout and `error` for stderr, never through the logger, so no
|
|
15
|
+
* log level can silence it. Only the glyph is painted; the text stays plain. An `agent` audience gets the glyph
|
|
16
|
+
* and the text and never colour, even when the theme has colour. `success` and `info` go to stdout, `warning`
|
|
17
|
+
* and `failure` to stderr.
|
|
18
|
+
*
|
|
19
|
+
* The text is whatever the caller supplies, so it is sanitised: escape sequences and control characters are removed
|
|
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 glyphs
|
|
22
|
+
* come from the vocabulary, which is configuration, and are not.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
var CliMessage = class CliMessage {
|
|
27
|
+
constructor() {}
|
|
28
|
+
/**
|
|
29
|
+
* Print a status line from a vocabulary.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* The stream defaults to stderr when the status ranks at or above `warning` in `vocab`, and stdout
|
|
33
|
+
* otherwise, so a custom status follows its own rank: a `timeout` ranked 85 goes to stderr.
|
|
34
|
+
*
|
|
35
|
+
* @param vocab - the vocabulary the status belongs to
|
|
36
|
+
* @param name - the status
|
|
37
|
+
* @param text - the text after the glyph
|
|
38
|
+
* @param options - the stream override
|
|
39
|
+
*/
|
|
40
|
+
static status = (vocab, name, text, options) => Effect.gen(function* () {
|
|
41
|
+
const theme = yield* CliTheme;
|
|
42
|
+
const audience = yield* Audience;
|
|
43
|
+
const def = vocab.def(name);
|
|
44
|
+
const message = sanitize(text);
|
|
45
|
+
const warning = vocab.def("warning").rank;
|
|
46
|
+
const stream = options?.stream ?? (def.rank >= warning ? "stderr" : "stdout");
|
|
47
|
+
const streamTheme = theme.forStream(stream);
|
|
48
|
+
let line;
|
|
49
|
+
if (audience.kind === "agent") {
|
|
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);
|
|
53
|
+
if (yield* underGithubActions) line = CommandNeutralizer.text(line);
|
|
54
|
+
yield* stream === "stderr" ? Console.error(line) : Console.log(line);
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* A success line, on stdout.
|
|
58
|
+
*
|
|
59
|
+
* @param text - the text after the glyph
|
|
60
|
+
*/
|
|
61
|
+
static success = (text) => CliMessage.status(Status.core, "success", text);
|
|
62
|
+
/**
|
|
63
|
+
* An informational line, on stdout.
|
|
64
|
+
*
|
|
65
|
+
* @param text - the text after the glyph
|
|
66
|
+
*/
|
|
67
|
+
static info = (text) => CliMessage.status(Status.core, "info", text);
|
|
68
|
+
/**
|
|
69
|
+
* A warning line, on stderr.
|
|
70
|
+
*
|
|
71
|
+
* @param text - the text after the glyph
|
|
72
|
+
*/
|
|
73
|
+
static warning = (text) => CliMessage.status(Status.core, "warning", text);
|
|
74
|
+
/**
|
|
75
|
+
* A failure line, on stderr.
|
|
76
|
+
*
|
|
77
|
+
* @param text - the text after the glyph
|
|
78
|
+
*/
|
|
79
|
+
static failure = (text) => CliMessage.status(Status.core, "failure", text);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { CliMessage };
|
package/CliPrompt.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Cancelled } from "./Cancelled.js";
|
|
2
|
+
import { CliInteractive } from "./CliInteractive.js";
|
|
3
|
+
import { WizardDropped } from "./internal/wizardGate.js";
|
|
4
|
+
import { answerWithoutPerson } from "./internal/fallbackAnswer.js";
|
|
5
|
+
import { Context, Effect, Layer, Queue, Terminal } from "effect";
|
|
6
|
+
import { CliConfig, GlobalFlag, Prompt } from "effect/cli";
|
|
7
|
+
|
|
8
|
+
//#region src/CliPrompt.ts
|
|
9
|
+
/**
|
|
10
|
+
* Prompts that know whether there is a person to ask.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var CliPrompt = class {
|
|
15
|
+
constructor() {}
|
|
16
|
+
/**
|
|
17
|
+
* A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that only prompts when the run is
|
|
18
|
+
* interactive.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Interactive (`CliInteractive`): the prompt runs and its answer is used. Not interactive: `otherwise` is used
|
|
22
|
+
* when given, and the terminal is never read; without it the parameter fails as missing, exactly as it would
|
|
23
|
+
* with no fallback, so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with
|
|
24
|
+
* `flag` (the name without dashes, as given to `Flag.String`) or `argument` so that error can be built.
|
|
25
|
+
*
|
|
26
|
+
* Why the prompt runs inside this function rather than being handed back to core: core's
|
|
27
|
+
* `withFallbackPrompt` turns a quit, such as Ctrl-C, into the original missing-parameter error, which would
|
|
28
|
+
* exit `64` as if the flag had been forgotten. Here a quit is `Cancelled` with reason `interrupt`, exit `130`.
|
|
29
|
+
* It is raised as a defect because core's parse step turns every typed failure into a usage error. Core then
|
|
30
|
+
* runs the already-answered `Prompt.succeed` it is handed. Because it travels as a defect, a handler's
|
|
31
|
+
* `Effect.catchTag("Cancelled", ...)` cannot see it, and only `CliRuntime.main` (or
|
|
32
|
+
* `CliRuntime.reportFailures`) renders it as one line with exit `130`; under a bare `runMain` it prints a
|
|
33
|
+
* stack.
|
|
34
|
+
*
|
|
35
|
+
* Pair it with `CliPrompt.gateTerminal`, which `CliEnv.layer` installs: core still runs `Prompt.run` on the
|
|
36
|
+
* answered prompt it is handed, and `Prompt.run` subscribes the terminal's input, which on a real terminal
|
|
37
|
+
* attaches a reader to stdin and drops piped input. The gate makes that subscription harmless when the run is
|
|
38
|
+
* not interactive.
|
|
39
|
+
*
|
|
40
|
+
* @param prompt - the prompt to show
|
|
41
|
+
* @param options - the parameter it stands in for, and the non-interactive default
|
|
42
|
+
*/
|
|
43
|
+
static fallback = (prompt, options) => Effect.gen(function* () {
|
|
44
|
+
if (yield* CliInteractive) {
|
|
45
|
+
const answer = yield* Prompt.run(prompt).pipe(Effect.catchTag("QuitError", () => Effect.die(new Cancelled({ reason: "interrupt" }))));
|
|
46
|
+
return Prompt.succeed(answer);
|
|
47
|
+
}
|
|
48
|
+
return yield* answerWithoutPerson(options);
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* Gates core's `Terminal` on `CliInteractive`, so a run that is not interactive never touches the real one.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* Core's prompt runner subscribes the terminal's input even for a prompt that is already answered, and the
|
|
55
|
+
* real Node terminal then attaches a readline to stdin, which drops piped input and puts a TTY stdin into raw
|
|
56
|
+
* mode. Not interactive, this terminal's input is an already-ended queue, its `readLine` fails as quit and its
|
|
57
|
+
* `display` writes nothing, so any prompt, the wizard included, is quit at once and the real terminal is never
|
|
58
|
+
* read. Its `columns` and `rows` always come from the real one, so layout keeps working. Interactive, every
|
|
59
|
+
* call passes through to the real terminal.
|
|
60
|
+
*
|
|
61
|
+
* The decision is made on every call, not when the layer is built, so a scope that narrows `CliInteractive`
|
|
62
|
+
* later, such as an audience flag under `CliAudience.runWith`, gates it too.
|
|
63
|
+
*
|
|
64
|
+
* It requires the real `Terminal`. `CliEnv.layer` installs it, after `TerminalEnv` is built from the real
|
|
65
|
+
* terminal, so consumers do not compose it.
|
|
66
|
+
*/
|
|
67
|
+
static gateTerminal = Layer.effect(Terminal.Terminal, Effect.gen(function* () {
|
|
68
|
+
const real = yield* Terminal.Terminal;
|
|
69
|
+
const endedInput = Effect.map(Queue.unbounded(), (queue) => {
|
|
70
|
+
Queue.endUnsafe(queue);
|
|
71
|
+
return queue;
|
|
72
|
+
});
|
|
73
|
+
return Terminal.make({
|
|
74
|
+
columns: real.columns,
|
|
75
|
+
rows: real.rows,
|
|
76
|
+
readInput: Effect.flatMap(CliInteractive, (interactive) => interactive ? real.readInput : endedInput),
|
|
77
|
+
readLine: Effect.flatMap(CliInteractive, (interactive) => interactive ? real.readLine : Effect.fail(new Terminal.QuitError({}))),
|
|
78
|
+
display: (text) => Effect.flatMap(CliInteractive, (interactive) => interactive ? real.display(text) : Effect.void)
|
|
79
|
+
});
|
|
80
|
+
}));
|
|
81
|
+
/**
|
|
82
|
+
* Drops core's `--wizard` built-in flag when the run is not interactive.
|
|
83
|
+
*
|
|
84
|
+
* @remarks
|
|
85
|
+
* The wizard prompts, so offering it without a terminal only leads to a dead end. With the flag gone, core
|
|
86
|
+
* treats `--wizard` as an unknown flag, a usage error that exits `64`, and lists it nowhere in `--help`.
|
|
87
|
+
* The layer reads `CliInteractive` and the ambient `CliConfig` when it is built, so provide those first, for
|
|
88
|
+
* example `CliPrompt.gateWizard.pipe(Layer.provide(CliInteractive.layer))`. It filters the ambient `CliConfig`,
|
|
89
|
+
* so a consumer's own `builtIns` survive, and returns it untouched when interactive. An audience flag read
|
|
90
|
+
* later is covered by `CliAudience.runWith`, which drops the wizard too. When this layer removes the flag it records
|
|
91
|
+
* that it did, so `runWith` puts it back only when a flag turns interactivity on where this took it away: a
|
|
92
|
+
* consumer who never had `Wizard` in their `builtIns` keeps it out.
|
|
93
|
+
*/
|
|
94
|
+
static gateWizard = Layer.effectContext(Effect.gen(function* () {
|
|
95
|
+
const ambient = yield* CliConfig.CliConfig;
|
|
96
|
+
const unchanged = Context.make(CliConfig.CliConfig, ambient);
|
|
97
|
+
if ((yield* CliInteractive) || !ambient.builtIns.includes(GlobalFlag.Wizard)) return unchanged;
|
|
98
|
+
const dropped = CliConfig.make({ builtIns: ambient.builtIns.filter((flag) => flag !== GlobalFlag.Wizard) });
|
|
99
|
+
return Context.make(CliConfig.CliConfig, dropped).pipe(Context.add(WizardDropped, dropped));
|
|
100
|
+
}));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
export { CliPrompt };
|
package/CliRuntime.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import { sanitize } from "./Fmt.js";
|
|
2
|
+
import { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, refreshFailureTarget } from "./internal/failureTarget.js";
|
|
3
|
+
import { CliColor } from "./CliColor.js";
|
|
4
|
+
import { CliEnv } from "./CliEnv.js";
|
|
1
5
|
import { isExitCode } from "./internal/isExitCode.js";
|
|
2
6
|
import { CliExit } from "./CliExit.js";
|
|
7
|
+
import { TrustedLine } from "./internal/logSafety.js";
|
|
3
8
|
import { CliLogger } from "./CliLogger.js";
|
|
9
|
+
import { CliLog, envBuildLogLayer, platformLogLayer } from "./CliLog.js";
|
|
4
10
|
import { ExitRequested } from "./internal/ExitRequested.js";
|
|
5
11
|
import { routeHelpOnUsageError } from "./internal/HelpRouting.js";
|
|
6
|
-
import { Cause, Effect, MutableRef, Runtime } from "effect";
|
|
12
|
+
import { Cause, Effect, Layer, MutableRef, Runtime } from "effect";
|
|
7
13
|
import { CliError } from "effect/cli";
|
|
14
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
8
15
|
|
|
9
16
|
//#region src/CliRuntime.ts
|
|
10
17
|
const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
|
|
11
18
|
/** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
|
|
12
19
|
const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
|
|
20
|
+
/** The last line of defence of a failure report: the error's text, sanitised and neutralized, whatever else broke. */
|
|
21
|
+
const lastResort = (error) => {
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = String(error);
|
|
25
|
+
} catch {
|
|
26
|
+
text = "[unprintable failure]";
|
|
27
|
+
}
|
|
28
|
+
return CommandNeutralizer.lines(sanitize(text));
|
|
29
|
+
};
|
|
13
30
|
const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
|
|
14
31
|
/**
|
|
15
32
|
* The error's own exit code when it carries one, otherwise the fallback.
|
|
@@ -25,7 +42,7 @@ const chooseExitCode = (error, fallback) => typeof error === "object" && error !
|
|
|
25
42
|
* Report a CLI program's failures through the program's own logger.
|
|
26
43
|
*
|
|
27
44
|
* @remarks
|
|
28
|
-
* ##
|
|
45
|
+
* ## What it prevents
|
|
29
46
|
*
|
|
30
47
|
* A platform `runMain` reports an unhandled failure using Effect's **default**
|
|
31
48
|
* logger. That logger sits **outside** the layers the program was provided —
|
|
@@ -42,18 +59,7 @@ const chooseExitCode = (error, fallback) => typeof error === "object" && error !
|
|
|
42
59
|
*
|
|
43
60
|
* The fix has to happen **inside** the effect, before any `runMain` sees it. So
|
|
44
61
|
* this is a combinator you apply to your program, and you still call your own
|
|
45
|
-
* platform's runner
|
|
46
|
-
*
|
|
47
|
-
* @example
|
|
48
|
-
* ```ts
|
|
49
|
-
* import { CliRuntime } from "@effected/cli"
|
|
50
|
-
* import { NodeRuntime } from "@effect/platform-node"
|
|
51
|
-
* import { Effect } from "effect"
|
|
52
|
-
*
|
|
53
|
-
* NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)))
|
|
54
|
-
* ```
|
|
55
|
-
*
|
|
56
|
-
* Wrapping `runMain` itself would drag a platform choice into a library that
|
|
62
|
+
* platform's runner. Wrapping `runMain` itself would drag a platform choice into a library that
|
|
57
63
|
* has no business making one, and would make this package unusable from Bun or
|
|
58
64
|
* Deno for no gain.
|
|
59
65
|
*
|
|
@@ -96,13 +102,49 @@ const chooseExitCode = (error, fallback) => typeof error === "object" && error !
|
|
|
96
102
|
* raises is likewise never rendered; it only carries the exit code a
|
|
97
103
|
* successful program recorded through `CliExit`.
|
|
98
104
|
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* import { CliRuntime } from "@effected/cli"
|
|
108
|
+
* import { NodeRuntime } from "@effect/platform-node"
|
|
109
|
+
* import { Effect } from "effect"
|
|
110
|
+
*
|
|
111
|
+
* NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)))
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
99
114
|
* @public
|
|
100
115
|
*/
|
|
101
116
|
var CliRuntime = class CliRuntime {
|
|
102
117
|
constructor() {}
|
|
103
118
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
119
|
+
* What `reportFailures` and `main` render a failure as when no `render` option is given, for a consumer's own
|
|
120
|
+
* `render` to hand a failure back to.
|
|
121
|
+
*
|
|
122
|
+
* @remarks
|
|
123
|
+
* The plain lines of `CliFailure.toDoc(details.cause)`: a failure status line, a `Tree` for a schema failure, a
|
|
124
|
+
* defect's message with its cleaned `stack`, and the one fixed line each for `Cancelled` and `NotInteractive`.
|
|
125
|
+
* It has no terminal to ask, so it is the plain rendering for an agent, with absolute paths; the report `main` writes
|
|
126
|
+
* with no `render` option is the same document in the renderer the audience gets (painted for a person), and that
|
|
127
|
+
* report is `details.defaultLines`: return those to hand a failure back with the run's colour, links and path
|
|
128
|
+
* display. With `status: false` the leading status (the glyph, or `[FAIL]` in plain text) is left off, so a prefix
|
|
129
|
+
* such as the program's name reads cleanly; for that AND the run's settings, use `details.lines({ status: false })`.
|
|
130
|
+
* A custom `render` that only cares about its own errors delegates the rest:
|
|
131
|
+
*
|
|
132
|
+
* ```ts
|
|
133
|
+
* const render = (error: unknown, details: FailureDetails) =>
|
|
134
|
+
* error instanceof MyError ? myLines(error) : CliRuntime.defaultRender(error, details)
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* @param error - the squashed failure
|
|
138
|
+
* @param details - what `render` is told about the failure; accepted so a delegating `render` passes both
|
|
139
|
+
* arguments through unchanged (only `cause` and `isDefect` are read)
|
|
140
|
+
* @param options - `status: false` leaves off the leading status glyph or `[FAIL]` tag
|
|
141
|
+
*/
|
|
142
|
+
static defaultRender = (error, details, options) => plainFailureLines(details.cause.reasons.length > 0 ? details.cause : Cause.fail(error), options?.status !== false);
|
|
143
|
+
/**
|
|
144
|
+
* Catch a program's failure, render it through the ambient logger, and re-fail with the exit code and the
|
|
145
|
+
* no-double-report mark.
|
|
146
|
+
*
|
|
147
|
+
* @param options - how to render the failure and which exit codes to use
|
|
106
148
|
*/
|
|
107
149
|
static reportFailures = (options = {}) => (effect) => effect.pipe(Effect.catchCause((cause) => {
|
|
108
150
|
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
|
|
@@ -113,50 +155,58 @@ var CliRuntime = class CliRuntime {
|
|
|
113
155
|
return Effect.fail(CliRuntime.reported(error, code));
|
|
114
156
|
}
|
|
115
157
|
if (isRenderedUserError(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.usageExitCode ?? 64)));
|
|
116
|
-
const render = options.render
|
|
117
|
-
const details = {
|
|
118
|
-
cause,
|
|
119
|
-
isDefect: !Cause.hasFails(cause)
|
|
120
|
-
};
|
|
158
|
+
const render = options.render;
|
|
121
159
|
return Effect.gen(function* () {
|
|
122
|
-
|
|
160
|
+
const target = yield* currentTarget.pipe(Effect.catchCause(() => Effect.succeed(fallbackTarget)));
|
|
161
|
+
const reportLines = (status) => {
|
|
162
|
+
try {
|
|
163
|
+
return linesOf(cause, target, status);
|
|
164
|
+
} catch {
|
|
165
|
+
try {
|
|
166
|
+
return plainFailureLines(cause, status);
|
|
167
|
+
} catch {
|
|
168
|
+
return lastResort(error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const defaultLines = reportLines(true);
|
|
173
|
+
const details = {
|
|
174
|
+
cause,
|
|
175
|
+
isDefect: !Cause.hasFails(cause),
|
|
176
|
+
defaultLines,
|
|
177
|
+
lines: (options) => options?.status === false ? reportLines(false) : defaultLines
|
|
178
|
+
};
|
|
179
|
+
const lines = render === void 0 ? defaultLines : yield* guardConsumerLines(toLines(render(error, details)));
|
|
180
|
+
for (const line of lines) yield* Effect.logError(line).pipe(Effect.provideService(TrustedLine, true));
|
|
123
181
|
return yield* Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode)));
|
|
124
182
|
});
|
|
125
183
|
}));
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
* CliRuntime.main(Command.run(root, { version }), { platform: NodeServices.layer, exitCode: 3 }),
|
|
144
|
-
* )
|
|
145
|
-
* ```
|
|
146
|
-
*/
|
|
147
|
-
static main = (program, options) => Effect.gen(function* () {
|
|
148
|
-
yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(program) : program;
|
|
149
|
-
const exit = yield* CliExit;
|
|
150
|
-
const code = MutableRef.get(exit.code);
|
|
151
|
-
if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
|
|
152
|
-
if (code !== 0) return yield* Effect.fail(new ExitRequested(code));
|
|
153
|
-
}).pipe(Effect.provide(CliExit.layer), Effect.provide(options.platform), CliRuntime.reportFailures(options), Effect.provide(options.logger ?? CliLogger.layer()));
|
|
184
|
+
static main(program, options) {
|
|
185
|
+
const env = options.env === void 0 ? void 0 : CliEnv.layer(options.env);
|
|
186
|
+
const envLog = options.env?.log;
|
|
187
|
+
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(refreshFailureTarget(void 0, {
|
|
189
|
+
displayPath: options.env?.displayPath,
|
|
190
|
+
stackFrames: options.env?.stackFrames
|
|
191
|
+
}))).pipe(Layer.provideMerge(env));
|
|
192
|
+
const run = Effect.gen(function* () {
|
|
193
|
+
yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(program) : program;
|
|
194
|
+
const exit = yield* CliExit;
|
|
195
|
+
const code = MutableRef.get(exit.code);
|
|
196
|
+
if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
|
|
197
|
+
if (code !== 0) return yield* Effect.fail(new ExitRequested(code));
|
|
198
|
+
}).pipe(Effect.provide(CliExit.layer), Effect.provide(inside), Effect.provide(options.platform), CliRuntime.reportFailures(options), Effect.provide(logger));
|
|
199
|
+
return Effect.suspend(() => Effect.provideService(run, FailureTargetCell, MutableRef.make(void 0)));
|
|
200
|
+
}
|
|
154
201
|
static reported(error, exitCode = 1) {
|
|
155
202
|
const marked = error instanceof Error ? error : new Error(String(error));
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
203
|
+
for (const [key, value] of [[Runtime.errorReported, false], [Runtime.errorExitCode, exitCode]]) Object.defineProperty(marked, key, {
|
|
204
|
+
value,
|
|
205
|
+
writable: true,
|
|
206
|
+
configurable: true,
|
|
207
|
+
enumerable: false
|
|
159
208
|
});
|
|
209
|
+
return marked;
|
|
160
210
|
}
|
|
161
211
|
};
|
|
162
212
|
|
package/CliTest.js
CHANGED
|
@@ -6,6 +6,22 @@ const text = (stream) => Stream.mkString(Stream.decodeText(stream));
|
|
|
6
6
|
/**
|
|
7
7
|
* Spawn a built CLI bin hermetically and read its exit code and streams as data.
|
|
8
8
|
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
12
|
+
* import { assert, it } from "@effect/vitest"
|
|
13
|
+
* import { CliTest } from "@effected/cli/testing"
|
|
14
|
+
* import { Effect } from "effect"
|
|
15
|
+
*
|
|
16
|
+
* it.effect("prints its version", () =>
|
|
17
|
+
* Effect.gen(function* () {
|
|
18
|
+
* const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" })
|
|
19
|
+
* const result = yield* CliTest.run("dist/bin.js", ["--version"], { sandbox, execPath: process.execPath })
|
|
20
|
+
* assert.strictEqual(result.exitCode, 0)
|
|
21
|
+
* }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
|
|
22
|
+
* )
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
9
25
|
* @public
|
|
10
26
|
*/
|
|
11
27
|
var CliTest = class {
|
package/CliTheme.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
//#region 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 def = vocab.def(name);
|
|
24
|
+
const glyph = paint(def.token, glyphs.kind === "ascii" ? def.ascii : def.glyph);
|
|
25
|
+
return text === void 0 || text === "" ? glyph : `${glyph} ${text}`;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The theme an audience sees of `theme`: for an agent, the same theme at colour `none` (`paint` the identity, `sgr`
|
|
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;
|
|
41
|
+
const make = (colors, glyphs, overrides) => {
|
|
42
|
+
const resolve = (token) => Token.resolve(token, overrides);
|
|
43
|
+
const stdout = streamThemeAt(resolve, glyphs, colors.stdout);
|
|
44
|
+
const stderr = streamThemeAt(resolve, glyphs, colors.stderr);
|
|
45
|
+
return {
|
|
46
|
+
...stdout,
|
|
47
|
+
forStream: (stream) => stream === "stdout" ? stdout : stderr
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/** The prompt glyphs that differ under ASCII; `Prompt.makeTheme` already holds the Unicode ones. */
|
|
51
|
+
const ASCII_PROMPT_GLYPHS = {
|
|
52
|
+
prefix: "?",
|
|
53
|
+
arrowUp: "^",
|
|
54
|
+
arrowDown: "v",
|
|
55
|
+
checkboxOn: "[x]",
|
|
56
|
+
checkboxOff: "[ ]",
|
|
57
|
+
tick: "+",
|
|
58
|
+
pointerSmall: ">",
|
|
59
|
+
pointer: ">"
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The presentation of a CLI: colour tokens, glyphs and statuses, decided once from the terminal.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* A `Context.Service`, not a `Reference`. A colour level is a fact about the terminal, not a preference with a
|
|
66
|
+
* safe default, so there is nothing sensible for an unprovided theme to read; requiring it puts `CliTheme` in
|
|
67
|
+
* `R` and a program that forgot to wire it fails to compile rather than printing plain text to a colour
|
|
68
|
+
* terminal. {@link CliTheme.layer} reads `TerminalEnv`.
|
|
69
|
+
*
|
|
70
|
+
* @public
|
|
71
|
+
*/
|
|
72
|
+
var CliTheme = class CliTheme extends Context.Service()("@effected/cli/CliTheme") {
|
|
73
|
+
/**
|
|
74
|
+
* The theme for the terminal `TerminalEnv` describes.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* Bind the layer to a constant and provide it once. With `glyphs: "auto"` the glyph set is ASCII only when
|
|
78
|
+
* `TERM=dumb`, read through `Config`.
|
|
79
|
+
*
|
|
80
|
+
* @param options - token overrides and the glyph set
|
|
81
|
+
*/
|
|
82
|
+
static layer = (options) => Layer.effect(CliTheme, Effect.gen(function* () {
|
|
83
|
+
const terminal = yield* TerminalEnv;
|
|
84
|
+
const choice = options?.glyphs ?? "auto";
|
|
85
|
+
const term = choice === "auto" ? Option.getOrUndefined(yield* Config.option(Config.String("TERM")).pipe(Effect.orElseSucceed(() => Option.none()))) : void 0;
|
|
86
|
+
const glyphs = Glyphs.select({
|
|
87
|
+
ascii: choice === "ascii" ? true : choice === "unicode" ? false : "auto",
|
|
88
|
+
...term === void 0 ? {} : { term }
|
|
89
|
+
});
|
|
90
|
+
return make({
|
|
91
|
+
stdout: terminal.stdout.color,
|
|
92
|
+
stderr: terminal.stderr.color
|
|
93
|
+
}, glyphs, options?.tokens);
|
|
94
|
+
}));
|
|
95
|
+
/**
|
|
96
|
+
* A fixed theme that needs nothing; `none` colour and Unicode glyphs unless told otherwise.
|
|
97
|
+
*
|
|
98
|
+
* @param options - the colour level and glyph set
|
|
99
|
+
*/
|
|
100
|
+
static layerTest = (options) => Layer.succeed(CliTheme, make({
|
|
101
|
+
stdout: options?.color ?? "none",
|
|
102
|
+
stderr: options?.stderrColor ?? options?.color ?? "none"
|
|
103
|
+
}, options?.glyphs === "ascii" ? Glyphs.ascii : Glyphs.unicode, void 0));
|
|
104
|
+
/**
|
|
105
|
+
* Sets core's `Prompt.Theme` from the tokens, so built-in prompts match the rest of the output.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* The colour fields are raw SGR openers and are empty strings when colour is `none`. Under ASCII glyphs the
|
|
109
|
+
* prompt symbols fall back to ASCII too. A colourless theme is not byte-clean: core's `Ansi.annotate`
|
|
110
|
+
* appends a `\x1b[0m` reset, and prompts write cursor and underline codes, whatever the theme says.
|
|
111
|
+
*/
|
|
112
|
+
static promptTheme = Layer.effect(Prompt.Theme, Effect.gen(function* () {
|
|
113
|
+
const theme = yield* CliTheme;
|
|
114
|
+
const open = (token) => theme.sgr(token);
|
|
115
|
+
return Prompt.makeTheme({
|
|
116
|
+
...theme.glyphs.kind === "ascii" ? ASCII_PROMPT_GLYPHS : {},
|
|
117
|
+
ellipsis: theme.glyphs.ellipsis,
|
|
118
|
+
primaryColor: open("accent"),
|
|
119
|
+
mutedColor: open("muted"),
|
|
120
|
+
successColor: open("success"),
|
|
121
|
+
errorColor: open("error"),
|
|
122
|
+
submittedColor: open("emphasis")
|
|
123
|
+
});
|
|
124
|
+
}));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
export { CliTheme, streamThemeAt, themeForAudience };
|
package/ConfigIssueRenderer.js
CHANGED
|
@@ -2,35 +2,21 @@ import { formatIssue } from "./internal/format.js";
|
|
|
2
2
|
|
|
3
3
|
//#region src/ConfigIssueRenderer.ts
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Turns a `@effected/config-file` `ConfigValidationError` into one line per rejected value.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
8
|
* `ConfigValidationError` carries the structured `issue` tree rather than a
|
|
9
|
-
* string,
|
|
10
|
-
*
|
|
11
|
-
* {@link SchemaIssueRenderer} gives a bare issue.
|
|
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.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* optional — it is a crash for every consumer who took the manifest at its word
|
|
17
|
-
* and did not install it. So nothing else in this package imports this module;
|
|
18
|
-
* only the entrypoint re-exports it, and the shared rendering lives in
|
|
19
|
-
* `internal/format`.
|
|
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.
|
|
20
15
|
*
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
23
|
-
* installed can import this module
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* ## Why this and not just the error's message
|
|
27
|
-
*
|
|
28
|
-
* `ConfigValidationError.message` names the file. It does not name the value,
|
|
29
|
-
* and the difference is the whole diagnostic: a consumer that printed only the
|
|
30
|
-
* message told users "your config is invalid, run the doctor command", and the
|
|
31
|
-
* doctor command — which guessed from a hand-written list of known keys — could
|
|
32
|
-
* only ever find a *misspelling*. A wrongly **shaped** value left the two
|
|
33
|
-
* commands pointing at each other and neither saying what was wrong.
|
|
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.
|
|
34
20
|
*
|
|
35
21
|
* @example
|
|
36
22
|
* ```ts
|
|
@@ -56,17 +42,12 @@ var ConfigIssueRenderer = class {
|
|
|
56
42
|
*
|
|
57
43
|
* @remarks
|
|
58
44
|
* Takes the **error**, not its `issue`, because that is what a `catchTag`
|
|
59
|
-
* hands you and because `issue` is typed `Schema.Defect
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* The parameter is the **typed** error rather than `unknown`. An earlier
|
|
63
|
-
* draft wrote `ConfigValidationError | unknown` to be accommodating, which
|
|
64
|
-
* collapses to plain `unknown` in TypeScript — so it accepted anything, said
|
|
65
|
-
* nothing, and left the type import in the `.d.ts` earning nothing. Inside
|
|
45
|
+
* hands you and because `issue` is typed `Schema.Defect`, which every call
|
|
46
|
+
* site would otherwise have to reach into. Inside
|
|
66
47
|
* `Effect.catchTag("ConfigValidationError", …)` the error is already this
|
|
67
|
-
* type
|
|
48
|
+
* type.
|
|
68
49
|
*
|
|
69
|
-
* It
|
|
50
|
+
* It cannot throw on a malformed value: the issue tree is validated by
|
|
70
51
|
* a guard before it is read, so a renderer on an error path never becomes the
|
|
71
52
|
* reason a program dies.
|
|
72
53
|
*/
|