@effected/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cancelled.js +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +14 -20
- 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 +104 -54
- package/CliTest.js +18 -2
- 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/HelpRouting.js +1 -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 +90 -4
- 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/Cancelled.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Runtime, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/Cancelled.ts
|
|
4
|
+
/**
|
|
5
|
+
* A person backed out of an interactive prompt: they pressed escape, or the
|
|
6
|
+
* prompt was interrupted.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Exits `130`, the conventional status for a run ended by the user, through
|
|
10
|
+
* core's own `Runtime.errorExitCode` marker, so `CliRuntime.reportFailures`
|
|
11
|
+
* keeps it. Its default rendering is one line, `cancelled; nothing written`,
|
|
12
|
+
* because nothing has been written by the time a prompt is cancelled and a
|
|
13
|
+
* stack trace would only alarm. A consumer `render` still overrides the line, and can hand off to it: the line is
|
|
14
|
+
* the error's `message`, so `error.message` and `String(error)` carry it.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
var Cancelled = class extends Schema.TaggedError()("Cancelled", { reason: Schema.Literals(["escape", "interrupt"]) }) {
|
|
19
|
+
/**
|
|
20
|
+
* The one line, `cancelled; nothing written`.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* A prototype getter, not a field, so it is not part of the encoded form, equality or a JSON dump. Assigning to
|
|
24
|
+
* it is ignored: a library that rewrites `error.message` (to prefix a context, say) must not make this error throw,
|
|
25
|
+
* which a getter-only property does in strict mode. The line is fixed.
|
|
26
|
+
*/
|
|
27
|
+
get message() {
|
|
28
|
+
return "cancelled; nothing written";
|
|
29
|
+
}
|
|
30
|
+
set message(_value) {}
|
|
31
|
+
/**
|
|
32
|
+
* The process exit code: `130`.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* A prototype getter rather than an own field, so a JSON or logger dump of the error does not carry the
|
|
36
|
+
* runtime marker.
|
|
37
|
+
*/
|
|
38
|
+
get [Runtime.errorExitCode]() {
|
|
39
|
+
return 130;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { Cancelled };
|
package/CliAudience.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { canPrompt } from "./internal/canPrompt.js";
|
|
2
|
+
import { CliInteractive } from "./CliInteractive.js";
|
|
3
|
+
import { refreshFailureTarget } from "./internal/failureTarget.js";
|
|
4
|
+
import { scanAudience, tallyAudience } from "./internal/scanAudience.js";
|
|
5
|
+
import { WizardDropped } from "./internal/wizardGate.js";
|
|
6
|
+
import { Effect, Option, Stdio } from "effect";
|
|
7
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
8
|
+
import { CliConfig, CliError, Command, Flag, GlobalFlag } from "effect/cli";
|
|
9
|
+
|
|
10
|
+
//#region src/CliAudience.ts
|
|
11
|
+
const KINDS = [
|
|
12
|
+
"human",
|
|
13
|
+
"agent",
|
|
14
|
+
"ci"
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Whether the run may prompt once a flag has named the audience.
|
|
18
|
+
*
|
|
19
|
+
* Only a `human` audience prompts, and only with a terminal on both standard input and standard output and a `TERM`
|
|
20
|
+
* that is not `dumb` (`canPrompt`, the decision `CliInteractive.layer` makes). With the
|
|
21
|
+
* terminal facts in the environment (`TerminalEnv`) that is decided from them, not from the ambient value, so a
|
|
22
|
+
* flag can WIDEN: `--human` under a detected agent on real terminals prompts, and in a pipe it still cannot. With no
|
|
23
|
+
* `TerminalEnv` there is nothing to decide from and the flag only narrows.
|
|
24
|
+
*/
|
|
25
|
+
const interactiveWhenFlagged = (kind, current) => Effect.flatMap(Effect.serviceOption(TerminalEnv), (terminal) => {
|
|
26
|
+
if (kind !== "human") return Effect.succeed(false);
|
|
27
|
+
return Option.isSome(terminal) ? canPrompt(terminal.value) : Effect.succeed(current);
|
|
28
|
+
});
|
|
29
|
+
const CONFLICT = "Give at most one of --audience, --human, --agent, --ci (once).";
|
|
30
|
+
/** Resolve the four flags into the audience to provide, failing when more than one occurrence was given. */
|
|
31
|
+
const resolve = (input) => {
|
|
32
|
+
const { given: named, conflict } = tallyAudience(input);
|
|
33
|
+
if (conflict) return Effect.fail(new CliError.UserError({
|
|
34
|
+
cause: /* @__PURE__ */ new Error(CONFLICT),
|
|
35
|
+
userMessage: CONFLICT
|
|
36
|
+
}));
|
|
37
|
+
const [kind] = named;
|
|
38
|
+
return kind === void 0 ? Audience : Effect.succeed({
|
|
39
|
+
kind,
|
|
40
|
+
source: "flag"
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The audience flags of a CLI: `--audience <human|agent|ci>` and the shorthands `--human`, `--agent`, `--ci`,
|
|
45
|
+
* resolved into `@effected/env`'s `Audience`.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* The one wiring: share the flags on the root command and hand the root to {@link CliAudience.run} (or
|
|
49
|
+
* {@link CliAudience.runWith}), which resolves the flags from argv before core parses and applies
|
|
50
|
+
* {@link CliAudience.provide} itself:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* const root = Command.make("tool").pipe(
|
|
54
|
+
* Command.withSharedFlags(CliAudience.flags()),
|
|
55
|
+
* Command.withSubcommands([verify]),
|
|
56
|
+
* )
|
|
57
|
+
* NodeRuntime.runMain(
|
|
58
|
+
* CliRuntime.main(CliAudience.run(root, { version }), { platform: NodeServices.layer, env: {} }),
|
|
59
|
+
* )
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* Core lists a shared flag in the help of every subcommand, not only at the root: that is upstream (Effect-TS/effect
|
|
63
|
+
* issue 8642), and `flags({ hidden: true })` is the way to keep them out of help altogether.
|
|
64
|
+
*
|
|
65
|
+
* A root that forgot `Command.withSharedFlags(CliAudience.flags())` does not compile. Giving more than one
|
|
66
|
+
* occurrence across the four flags is a usage error even when they agree; a boolean set to false (`--no-agent`,
|
|
67
|
+
* `--agent=false`) counts as not given. A bad `--audience` value is core's own parse error. Both exit `64` under
|
|
68
|
+
* `CliRuntime.main`. A conflicting audience together with `--help` exits `0` and prints help, because core handles
|
|
69
|
+
* its action flags before the resolver runs.
|
|
70
|
+
*
|
|
71
|
+
* A flag decides `CliInteractive` from the audience it names and the terminal facts: `--human` is interactive when
|
|
72
|
+
* `TerminalEnv` says there is a terminal on stdin and on stdout, even where the environment detected an agent, so a
|
|
73
|
+
* person running the tool inside an agent can ask for the human experience; in a pipe it still cannot prompt. A
|
|
74
|
+
* non-human flag (`--agent`, `--ci`, `--audience agent|ci`) turns it off and drops `--wizard`, and switches
|
|
75
|
+
* diagnostics to NDJSON, for the whole run including the parse step where a fallback prompt fires. Without
|
|
76
|
+
* `TerminalEnv` in the environment a flag only narrows. `--wizard` follows the decision: a run a flag makes
|
|
77
|
+
* interactive gets it back where the environment's gate had dropped it, and only there: a consumer's own `builtIns`
|
|
78
|
+
* without it stay without it.
|
|
79
|
+
* {@link CliAudience.provide} on its own, the path for a bare `Command.run`, acts only on the subcommand handler,
|
|
80
|
+
* because core parses the root flags into a local context before any of it is visible.
|
|
81
|
+
*
|
|
82
|
+
* @public
|
|
83
|
+
*/
|
|
84
|
+
var CliAudience = class CliAudience {
|
|
85
|
+
constructor() {}
|
|
86
|
+
/**
|
|
87
|
+
* The four flags, for `Command.withSharedFlags` on the root command.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* Each is repeatable, so every occurrence is counted; a boolean given as `false` (`--agent=false`,
|
|
91
|
+
* `--no-agent`) is not an occurrence. Core lists shared flags in every command's help; pass
|
|
92
|
+
* `hidden` to remove them from all of them.
|
|
93
|
+
*/
|
|
94
|
+
static flags = (options) => {
|
|
95
|
+
const hide = options?.hidden === true;
|
|
96
|
+
const maybeHide = (flag) => hide ? Flag.withHidden(flag) : flag;
|
|
97
|
+
return {
|
|
98
|
+
audience: maybeHide(Flag.Literals("audience", KINDS).pipe(Flag.atLeast(0), Flag.withDescription("Who the output is for"))),
|
|
99
|
+
human: maybeHide(Flag.Boolean("human").pipe(Flag.atLeast(0), Flag.withDescription("Shorthand for --audience human"))),
|
|
100
|
+
agent: maybeHide(Flag.Boolean("agent").pipe(Flag.atLeast(0), Flag.withDescription("Shorthand for --audience agent"))),
|
|
101
|
+
ci: maybeHide(Flag.Boolean("ci").pipe(Flag.atLeast(0), Flag.withDescription("Shorthand for --audience ci")))
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the flags before every subcommand handler and re-provide `Audience`.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* `CliAudience.run` and `runWith` apply this themselves, so a program run through them never needs it. Use it
|
|
109
|
+
* directly only with a bare `Command.run`, which leaves a fallback prompt blind to the flags (see the class
|
|
110
|
+
* remarks).
|
|
111
|
+
*
|
|
112
|
+
* A failure report is written outside the run, where the flag is not in force, so only `runWith` and `run` carry
|
|
113
|
+
* the flag's audience to it; with this on its own the report follows the environment's audience.
|
|
114
|
+
*
|
|
115
|
+
* Pipe it onto the composite root, after `withSubcommands`, since a parent's handler does not run when a
|
|
116
|
+
* subcommand is selected. With exactly one flag the audience is `{ kind, source: "flag" }`; with none the
|
|
117
|
+
* ambient `Audience` is read and provided back unchanged, so `Audience` stays in the requirement a handler
|
|
118
|
+
* reading it already has, and is added to a program whose handlers do not read it.
|
|
119
|
+
*/
|
|
120
|
+
static provide = (command) => Command.provideEffect(command, Audience, (input) => resolve(input)).pipe(Command.provideEffect(CliInteractive, (input) => Effect.gen(function* () {
|
|
121
|
+
const current = yield* CliInteractive;
|
|
122
|
+
const [flagged] = tallyAudience(input).given;
|
|
123
|
+
return flagged === void 0 ? current : yield* interactiveWhenFlagged(flagged, current);
|
|
124
|
+
})));
|
|
125
|
+
/**
|
|
126
|
+
* Run a command the way `Command.runWith` does, with the audience flag resolved BEFORE core parses.
|
|
127
|
+
*
|
|
128
|
+
* @remarks
|
|
129
|
+
* It scans `argv` for the four audience flags first, then runs core around a provided `Audience` (when exactly
|
|
130
|
+
* one is given: `{ kind, source: "flag" }`) and a `CliInteractive` decided from it: `--human` is interactive when
|
|
131
|
+
* `TerminalEnv` reports a terminal on stdin and stdout and `TERM` is not `dumb` (it can turn prompting on under a detected agent), a
|
|
132
|
+
* non-human flag or a conflict makes it false. A fallback prompt fires while core parses, earlier than
|
|
133
|
+
* anything `CliAudience.provide` can reach, so `--agent init` on a terminal would otherwise still prompt. No
|
|
134
|
+
* flag leaves the ambient values untouched. A conflict still gets core's own usage error, exit `64`, from
|
|
135
|
+
* `CliAudience.provide`'s resolver.
|
|
136
|
+
*
|
|
137
|
+
* @param command - the composite root, with the flags shared and `CliAudience.provide` piped on
|
|
138
|
+
* @param config - the same `version` and `renderErrors` as core's
|
|
139
|
+
*/
|
|
140
|
+
static runWith = (command, config) => {
|
|
141
|
+
const core = Command.runWith(CliAudience.provide(command), config);
|
|
142
|
+
return (argv) => {
|
|
143
|
+
const run = core(argv);
|
|
144
|
+
const { given, conflict } = scanAudience(argv);
|
|
145
|
+
const [kind] = given;
|
|
146
|
+
if (kind === void 0) return run;
|
|
147
|
+
const withAudience = conflict ? run : Effect.provideService(run, Audience, {
|
|
148
|
+
kind,
|
|
149
|
+
source: "flag"
|
|
150
|
+
});
|
|
151
|
+
return Effect.gen(function* () {
|
|
152
|
+
const current = yield* CliInteractive;
|
|
153
|
+
const ambient = yield* CliConfig.CliConfig;
|
|
154
|
+
if (!conflict) yield* refreshFailureTarget({
|
|
155
|
+
kind,
|
|
156
|
+
source: "flag"
|
|
157
|
+
});
|
|
158
|
+
const interactive = !conflict && (yield* interactiveWhenFlagged(kind, current));
|
|
159
|
+
const decided = Effect.provideService(withAudience, CliInteractive, interactive);
|
|
160
|
+
const hasWizard = ambient.builtIns.includes(GlobalFlag.Wizard);
|
|
161
|
+
if (!(interactive && !hasWizard && (yield* WizardDropped) === ambient) && !(!interactive && hasWizard)) return yield* decided;
|
|
162
|
+
const builtIns = interactive ? [...ambient.builtIns, GlobalFlag.Wizard] : ambient.builtIns.filter((flag) => flag !== GlobalFlag.Wizard);
|
|
163
|
+
return yield* Effect.provideService(decided, CliConfig.CliConfig, CliConfig.make({ builtIns }));
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* `Command.run` with the audience flag resolved before parsing: reads `Stdio.args` and calls
|
|
169
|
+
* {@link CliAudience.runWith}.
|
|
170
|
+
*
|
|
171
|
+
* @param command - the composite root
|
|
172
|
+
* @param config - the same `version` and `renderErrors` as core's
|
|
173
|
+
*/
|
|
174
|
+
static run = (command, config) => Stdio.Stdio.use(({ args }) => Effect.flatMap(args, (argv) => CliAudience.runWith(command, config)(argv)));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
//#endregion
|
|
178
|
+
export { CliAudience };
|
package/CliColor.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { TerminalEnv } from "@effected/env";
|
|
3
|
+
import { CliOutput } from "effect/cli";
|
|
3
4
|
|
|
4
5
|
//#region src/CliColor.ts
|
|
5
|
-
const noColor = Config.option(Config.String("NO_COLOR"));
|
|
6
6
|
/**
|
|
7
7
|
* Whether a CLI's output should carry ANSI colour, decided once and shared by
|
|
8
8
|
* everything that renders — help text, error output, and any rendered result.
|
|
9
9
|
*
|
|
10
10
|
* @remarks
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* The decision is `@effected/env`'s `TerminalEnv.colorLevel("stdout")`, which
|
|
12
|
+
* follows Node's `getColorDepth` precedence: `FORCE_COLOR` first (`0` or an
|
|
13
|
+
* unrecognised value forces colour off; an empty value, `1` or `true` force basic colour, `2` 256 colours and
|
|
14
|
+
* `3` truecolor, even without a terminal), then a non-empty `NO_COLOR` or `NODE_DISABLE_COLORS` and
|
|
15
|
+
* `TERM=dumb`, then the TTY gate. `FORCE_COLOR` therefore beats `NO_COLOR`.
|
|
16
|
+
* The environment
|
|
17
|
+
* is read through the ambient `ConfigProvider`, never `process`, so a test
|
|
18
|
+
* swaps it with `Effect.provideService(ConfigProvider.ConfigProvider, ...)`;
|
|
19
|
+
* an ambient `TerminalEnv`, such as `TerminalEnv.layerTest`, answers instead
|
|
20
|
+
* when one is provided.
|
|
20
21
|
*
|
|
21
22
|
* @public
|
|
22
23
|
*/
|
|
@@ -27,14 +28,7 @@ var CliColor = class CliColor {
|
|
|
27
28
|
*
|
|
28
29
|
* @public
|
|
29
30
|
*/
|
|
30
|
-
static enabled = Effect.
|
|
31
|
-
if (!(yield* (yield* Stdio.Stdio).stdoutIsTerminal)) return false;
|
|
32
|
-
const value = yield* noColor.pipe(Effect.orElseSucceed(() => Option.none()));
|
|
33
|
-
return Option.match(value, {
|
|
34
|
-
onNone: () => true,
|
|
35
|
-
onSome: (set) => set === ""
|
|
36
|
-
});
|
|
37
|
-
});
|
|
31
|
+
static enabled = TerminalEnv.colorLevel("stdout").pipe(Effect.map((level) => level !== "none"));
|
|
38
32
|
/**
|
|
39
33
|
* Core's default `CliOutput.Formatter`, coloured by the same decision as
|
|
40
34
|
* {@link CliColor.enabled}, so help text, parse errors and rendered
|
package/CliEnv.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CliInteractive } from "./CliInteractive.js";
|
|
2
|
+
import { ambientLinksLayer } from "./CliLinks.js";
|
|
3
|
+
import { CliTheme } from "./CliTheme.js";
|
|
4
|
+
import { CliPrompt } from "./CliPrompt.js";
|
|
5
|
+
import { ConfigProvider, Layer, Option } from "effect";
|
|
6
|
+
import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
|
|
7
|
+
|
|
8
|
+
//#region src/CliEnv.ts
|
|
9
|
+
/**
|
|
10
|
+
* The environment services a CLI reads, built once and in the right order.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Builds `CurrentRuntimeEnv`, `TerminalEnv`, `Audience`, `CliTheme` and `CliLinks`, and sets `CliInteractive` from them, then
|
|
14
|
+
* installs the two gates for the program: `CliPrompt.gateTerminal`, which replaces `Terminal` with a quiet one when
|
|
15
|
+
* the run is not interactive so no prompt runner ever attaches to stdin, and `CliPrompt.gateWizard`, which drops
|
|
16
|
+
* `--wizard` then. `TerminalEnv` is built from the real terminal first. The layer therefore also outputs
|
|
17
|
+
* `Terminal`, the gated one, and consumers never compose the gates themselves. A
|
|
18
|
+
* `Context.Reference`'s key type is `never`, so the layer's output type does not list `CliInteractive`: it sets
|
|
19
|
+
* the reference rather than providing a service. Every read of the environment goes through `Config` and
|
|
20
|
+
* degrades to "unset" when it fails, so building the layer does not fail on a bad provider; it fails only when
|
|
21
|
+
* `Stdio` or `Terminal` do.
|
|
22
|
+
*
|
|
23
|
+
* `CliLinks` reads `FileSystem` and `Path` from the surrounding context if it has them, and does not require them: a
|
|
24
|
+
* `.vscode/` directory is looked for, and a relative path resolved, only when the platform is provided OUTSIDE this
|
|
25
|
+
* layer, as `CliRuntime.main` does. Without them `auto` is `vscode` on the terminal signal alone.
|
|
26
|
+
*
|
|
27
|
+
* Not interactive, the gated `Terminal`'s `readLine` fails as a quit, its input is already ended and its `display`
|
|
28
|
+
* writes nothing. A program that reads piped data must read `Stdio.stdin`, and one that writes output must use
|
|
29
|
+
* `Console` or `Stdio`, never `Terminal`. It also installs `CliTheme.promptTheme`, so core's prompts follow the
|
|
30
|
+
* theme.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
var CliEnv = class {
|
|
35
|
+
constructor() {}
|
|
36
|
+
/**
|
|
37
|
+
* The environment services for the terminal `Stdio` and `Terminal` describe.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
|
|
41
|
+
*
|
|
42
|
+
* @param options - the audience env var, the stderr check and the theme options
|
|
43
|
+
*/
|
|
44
|
+
static layer = (options = {}) => {
|
|
45
|
+
const base = Layer.mergeAll(CurrentRuntimeEnv.layer, TerminalEnv.layer(options.stderrIsTerminal === void 0 ? void 0 : { stderrIsTerminal: options.stderrIsTerminal }));
|
|
46
|
+
const withAudience = Audience.layer(options.audienceEnvVar === void 0 ? void 0 : { envVar: options.audienceEnvVar }).pipe(Layer.provideMerge(base));
|
|
47
|
+
const withTheme = CliTheme.layer(options.theme).pipe(Layer.provideMerge(withAudience));
|
|
48
|
+
const withLinks = ambientLinksLayer({
|
|
49
|
+
...options.editorLinks === void 0 ? {} : { editorLinks: options.editorLinks },
|
|
50
|
+
...options.editorLinksEnvVar === void 0 ? {} : { envVar: options.editorLinksEnvVar }
|
|
51
|
+
}).pipe(Layer.provideMerge(withTheme));
|
|
52
|
+
const withInteractive = CliInteractive.layer.pipe(Layer.provideMerge(withLinks));
|
|
53
|
+
return Layer.mergeAll(CliPrompt.gateTerminal, CliPrompt.gateWizard, CliTheme.promptTheme).pipe(Layer.provideMerge(withInteractive));
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The environment services a test fixes, needing nothing and reading nothing of the host's: `TerminalEnv` and
|
|
57
|
+
* `Audience` from the answers given, `CliTheme` built from them as {@link CliEnv.layer} builds it, and
|
|
58
|
+
* `CliInteractive` set from them by the same rule (a human, every stream a terminal, and a `TERM` that is not
|
|
59
|
+
* `dumb`).
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* `term` is handed to the theme and interactivity builds alone, through a `ConfigProvider` of their own: the
|
|
63
|
+
* program under the layer keeps its own provider, and a host's `TERM` (a test runner in a dumb terminal) never
|
|
64
|
+
* decides. A screen or a live view also needs `UiStreams` from `@effected/cli/ui`, which `CliUiTest` provides; this
|
|
65
|
+
* layer provides no `Terminal` and installs neither of `CliEnv.layer`'s prompt gates.
|
|
66
|
+
*
|
|
67
|
+
* A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
|
|
68
|
+
*
|
|
69
|
+
* @param options - whether the streams are terminals, the `TERM`, the audience, the width, the colour and the theme
|
|
70
|
+
*/
|
|
71
|
+
static layerTest = (options = {}) => {
|
|
72
|
+
const tty = options.tty ?? false;
|
|
73
|
+
const stream = {
|
|
74
|
+
isTerminal: tty,
|
|
75
|
+
color: options.color ?? "none",
|
|
76
|
+
columns: options.columns === void 0 ? Option.none() : Option.some(options.columns)
|
|
77
|
+
};
|
|
78
|
+
const facts = Layer.mergeAll(TerminalEnv.layerTest({
|
|
79
|
+
stdinIsTerminal: tty,
|
|
80
|
+
stdout: stream,
|
|
81
|
+
stderr: stream
|
|
82
|
+
}), Audience.layerTest(options.audience ?? "human"));
|
|
83
|
+
const term = ConfigProvider.layer(ConfigProvider.fromUnknown(options.term === void 0 ? {} : { TERM: options.term }));
|
|
84
|
+
return Layer.mergeAll(CliTheme.layer(options.theme), CliInteractive.layer).pipe(Layer.provide(term), Layer.provideMerge(facts));
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
export { CliEnv };
|
package/CliExit.js
CHANGED
|
@@ -11,7 +11,7 @@ import { Context, Effect, Layer, MutableRef } from "effect";
|
|
|
11
11
|
* handler must return normally — yet the process must exit non-zero. Writing
|
|
12
12
|
* `process.exitCode` works only because Node's `runMain` skips
|
|
13
13
|
* `process.exit(0)` on success; `process.exit(n)` in a handler skips every
|
|
14
|
-
* finalizer.
|
|
14
|
+
* finalizer. `CliRuntime.main` reads this cell after the program
|
|
15
15
|
* succeeds and turns a non-zero code into a marked failure the runtime's
|
|
16
16
|
* teardown honours, on any runtime, with finalizers intact.
|
|
17
17
|
*
|
package/CliFailure.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { Cancelled } from "./Cancelled.js";
|
|
2
|
+
import { Fmt } from "./Fmt.js";
|
|
3
|
+
import { Doc } from "./Doc.js";
|
|
4
|
+
import { issueEntries, issueTreeChildren } from "./internal/format.js";
|
|
5
|
+
import { splitFrame } from "./internal/splitFrame.js";
|
|
6
|
+
import { NotInteractive } from "./NotInteractive.js";
|
|
7
|
+
import { Status } from "./Status.js";
|
|
8
|
+
import { Cause, Context, SchemaIssue } from "effect";
|
|
9
|
+
|
|
10
|
+
//#region src/CliFailure.ts
|
|
11
|
+
/**
|
|
12
|
+
* The protocol an error class implements to say how a failure is shown: a method under this key that returns the
|
|
13
|
+
* document.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* `CliFailure.toDoc` calls it for a typed failure that has one, so an application error draws itself (a heading, a
|
|
17
|
+
* table of what went wrong) and the default report uses it with no registration. A `Symbol.for` key, so two copies
|
|
18
|
+
* of this package agree on it.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const CliDoc = Symbol.for("@effected/cli/CliDoc");
|
|
23
|
+
/** The deepest an `Error.cause` chain, or a stack, is followed. */
|
|
24
|
+
const MAX_DEPTH = 8;
|
|
25
|
+
const MAX_SPANS = 32;
|
|
26
|
+
const hasCliDoc = (value) => typeof value === "object" && value !== null && typeof value[CliDoc] === "function";
|
|
27
|
+
const describe = (value) => {
|
|
28
|
+
try {
|
|
29
|
+
const text = String(value);
|
|
30
|
+
if (text !== "[object Object]") return text;
|
|
31
|
+
const message = value?.message;
|
|
32
|
+
return typeof message === "string" ? message : text;
|
|
33
|
+
} catch {
|
|
34
|
+
return "[unprintable value]";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const firstLine = (text) => text.split(/\r\n|\r|\n/, 1)[0] ?? "";
|
|
38
|
+
const tagOf = (value) => {
|
|
39
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
40
|
+
const tag = value._tag;
|
|
41
|
+
return typeof tag === "string" ? tag : void 0;
|
|
42
|
+
};
|
|
43
|
+
/** The failure status and the text of a message: one block per line, the status on the first. */
|
|
44
|
+
const failureBlocks = (message) => {
|
|
45
|
+
const [first = "", ...rest] = message.split(/\r\n|\r|\n/);
|
|
46
|
+
return [Doc.paragraph(Doc.status(Status.core, "failure"), " ", first), ...rest.map((line) => Doc.paragraph(line))];
|
|
47
|
+
};
|
|
48
|
+
/** The issue of a schema failure: the value itself, or the `issue` an error carries. */
|
|
49
|
+
const issueOf = (error) => {
|
|
50
|
+
if (SchemaIssue.isIssue(error)) return error;
|
|
51
|
+
if (typeof error === "object" && error !== null) {
|
|
52
|
+
const issue = error.issue;
|
|
53
|
+
if (SchemaIssue.isIssue(issue)) return issue;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const schemaBlocks = (error) => {
|
|
57
|
+
const entries = issueEntries(issueOf(error));
|
|
58
|
+
if (entries.length === 0) return void 0;
|
|
59
|
+
const header = typeof error === "object" && error !== null && !SchemaIssue.isIssue(error) ? firstLine(describe(error.message ?? "")) : "";
|
|
60
|
+
const root = {
|
|
61
|
+
label: [
|
|
62
|
+
Doc.status(Status.core, "failure"),
|
|
63
|
+
" ",
|
|
64
|
+
header === "" ? `invalid value (${Fmt.plural(entries.length, "problem")})` : header
|
|
65
|
+
],
|
|
66
|
+
children: issueTreeChildren(entries)
|
|
67
|
+
};
|
|
68
|
+
return [Doc.tree(root)];
|
|
69
|
+
};
|
|
70
|
+
/** The location of a frame as a path: a `file:` URL decoded, or an absolute POSIX or drive path; else `undefined`. */
|
|
71
|
+
const asPath = (location) => {
|
|
72
|
+
if (location.startsWith("file:")) try {
|
|
73
|
+
const path = decodeURIComponent(new URL(location).pathname);
|
|
74
|
+
return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
return location.startsWith("/") || /^[A-Za-z]:[\\/]/.test(location) ? location : void 0;
|
|
79
|
+
};
|
|
80
|
+
/** A location without its `:line:col`, and the position when there is one. */
|
|
81
|
+
const splitPosition = (location) => {
|
|
82
|
+
const position = /^(.*):(\d+):(\d+)$/.exec(location);
|
|
83
|
+
return position === null ? { where: location } : {
|
|
84
|
+
where: position[1] ?? "",
|
|
85
|
+
line: Number(position[2]),
|
|
86
|
+
col: Number(position[3])
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const parseFrame = (raw) => {
|
|
90
|
+
const { text, fn, location } = splitFrame(raw);
|
|
91
|
+
const { where, line, col } = splitPosition(location);
|
|
92
|
+
const file = asPath(where);
|
|
93
|
+
return {
|
|
94
|
+
raw: text,
|
|
95
|
+
...fn === void 0 ? {} : { fn },
|
|
96
|
+
...file === void 0 ? {} : { file },
|
|
97
|
+
...file === void 0 || line === void 0 || col === void 0 ? {} : {
|
|
98
|
+
line,
|
|
99
|
+
col
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/** The file a frame ran in, as a path, or `undefined` for a frame with none: `node:`, `<anonymous>`, `native`, `eval`. */
|
|
104
|
+
const frameFile = (raw) => asPath(splitPosition(splitFrame(raw).location).where);
|
|
105
|
+
/** A frame under `node_modules`: a dependency's, or an installed program's own. Decided by its file alone. */
|
|
106
|
+
const isDependency = (raw) => /[\\/]node_modules[\\/]/.test(frameFile(raw) ?? "");
|
|
107
|
+
/**
|
|
108
|
+
* A frame that is the runtime's or Effect's, never the program's, wherever the program is installed: one with no file
|
|
109
|
+
* (every `node:` frame, `<anonymous>`, native), or one in Effect's own files. Decided by the file alone, never by the
|
|
110
|
+
* function name: V8 names a program's own thunk by Effect's method alias (`boom [as ~effect/Effect/args]`).
|
|
111
|
+
*/
|
|
112
|
+
const isRuntime = (raw) => {
|
|
113
|
+
const file = frameFile(raw);
|
|
114
|
+
return file === void 0 || /[\\/]node_modules[\\/]effect[\\/]/.test(file) || /[\\/]packages[\\/]effect[\\/]src[\\/]/.test(file);
|
|
115
|
+
};
|
|
116
|
+
/** The frames of a stack that belong to the program, and how many were left out. */
|
|
117
|
+
const cleanStack = (stack, mode) => {
|
|
118
|
+
if (typeof stack !== "string") return {
|
|
119
|
+
frames: [],
|
|
120
|
+
hidden: 0
|
|
121
|
+
};
|
|
122
|
+
const lines = stack.split(/\r\n|\r|\n/).filter((line) => /^\s*at\s/.test(line));
|
|
123
|
+
const app = lines.filter((line) => !isRuntime(line) && !isDependency(line));
|
|
124
|
+
const kept = mode === "all" ? lines : app.length > 0 ? app : lines.filter((line) => !isRuntime(line));
|
|
125
|
+
return {
|
|
126
|
+
frames: kept.map(parseFrame),
|
|
127
|
+
hidden: lines.length - kept.length
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
const frameBlock = (frame, displayPath) => {
|
|
131
|
+
if (frame.file === void 0) return Doc.paragraph(Doc.text("at ", "muted"), frame.raw);
|
|
132
|
+
const where = `${displayPath(frame.file)}${frame.line === void 0 ? "" : `:${frame.line}:${frame.col}`}`;
|
|
133
|
+
const target = {
|
|
134
|
+
file: frame.file,
|
|
135
|
+
...frame.line === void 0 ? {} : { line: frame.line },
|
|
136
|
+
...frame.col === void 0 ? {} : { col: frame.col }
|
|
137
|
+
};
|
|
138
|
+
return Doc.paragraph(Doc.text("at ", "muted"), ...frame.fn === void 0 ? [] : [`${frame.fn} `], Doc.link(target, where));
|
|
139
|
+
};
|
|
140
|
+
const stackBlock = (defect, displayPath, mode) => {
|
|
141
|
+
const { frames, hidden } = cleanStack(defect.stack, mode);
|
|
142
|
+
if (frames.length > 0) {
|
|
143
|
+
const shown = frames.map((frame) => frameBlock(frame, displayPath));
|
|
144
|
+
const note = hidden === 0 ? [] : [Doc.paragraph(Doc.text(`(+${hidden} internal frames hidden)`, "muted"))];
|
|
145
|
+
return Doc.collapsible("stack", [...shown, ...note], { open: true });
|
|
146
|
+
}
|
|
147
|
+
const note = hidden === 0 ? "no stack" : `no user frames (${hidden} internal frames hidden)`;
|
|
148
|
+
return Doc.collapsible("stack", [Doc.paragraph(Doc.text(note, "muted"))], { open: true });
|
|
149
|
+
};
|
|
150
|
+
/** The `Error.cause` chain below a defect as a tree of one-line messages, or none. */
|
|
151
|
+
const causeTree = (defect) => {
|
|
152
|
+
const chain = [];
|
|
153
|
+
const seen = /* @__PURE__ */ new Set([defect]);
|
|
154
|
+
for (let current = defect.cause; current !== void 0 && chain.length < MAX_DEPTH;) {
|
|
155
|
+
if (seen.has(current)) break;
|
|
156
|
+
seen.add(current);
|
|
157
|
+
chain.push(firstLine(describe(current)));
|
|
158
|
+
current = current instanceof Error ? current.cause : void 0;
|
|
159
|
+
}
|
|
160
|
+
if (chain.length === 0) return void 0;
|
|
161
|
+
const nest = (index) => index === chain.length - 1 ? { label: chain[index] } : {
|
|
162
|
+
label: chain[index],
|
|
163
|
+
children: [nest(index + 1)]
|
|
164
|
+
};
|
|
165
|
+
return Doc.tree({
|
|
166
|
+
label: firstLine(describe(defect)),
|
|
167
|
+
children: [nest(0)]
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const dieBlocks = (defect, spans, displayPath, mode) => {
|
|
171
|
+
if (defect instanceof Cancelled || defect instanceof NotInteractive) return [Doc.paragraph(defect.message)];
|
|
172
|
+
const header = failureBlocks(describe(defect));
|
|
173
|
+
if (!(defect instanceof Error)) return [...header, ...spans];
|
|
174
|
+
const chain = causeTree(defect);
|
|
175
|
+
return [
|
|
176
|
+
...header,
|
|
177
|
+
...spans,
|
|
178
|
+
stackBlock(defect, displayPath, mode),
|
|
179
|
+
...chain === void 0 ? [] : [chain]
|
|
180
|
+
];
|
|
181
|
+
};
|
|
182
|
+
const failBlocks = (error, spans, options) => {
|
|
183
|
+
if (hasCliDoc(error)) try {
|
|
184
|
+
return [...error[CliDoc](), ...spans];
|
|
185
|
+
} catch {}
|
|
186
|
+
const tag = tagOf(error);
|
|
187
|
+
const custom = tag === void 0 || options?.render === void 0 || !Object.hasOwn(options.render, tag) ? void 0 : options.render[tag];
|
|
188
|
+
if (custom !== void 0) try {
|
|
189
|
+
return [...custom(error), ...spans];
|
|
190
|
+
} catch {}
|
|
191
|
+
if (error instanceof Cancelled || error instanceof NotInteractive) return [Doc.paragraph(error.message)];
|
|
192
|
+
const schema = schemaBlocks(error);
|
|
193
|
+
if (schema !== void 0) return [...schema, ...spans];
|
|
194
|
+
return [...failureBlocks(describe(error)), ...spans];
|
|
195
|
+
};
|
|
196
|
+
/** `in: outer › inner`, from the span stack the runtime annotates a reason with, or none. */
|
|
197
|
+
const spanBlocks = (reason) => {
|
|
198
|
+
const names = [];
|
|
199
|
+
let frame = Context.getOrUndefined(Cause.reasonAnnotations(reason), Cause.StackTrace);
|
|
200
|
+
while (frame !== void 0 && names.length < MAX_SPANS) {
|
|
201
|
+
names.push(frame.name);
|
|
202
|
+
frame = frame.parent;
|
|
203
|
+
}
|
|
204
|
+
if (names.length === 0) return [];
|
|
205
|
+
return [Doc.paragraph(Doc.text("in: ", "muted"), Doc.path(...names.reverse()))];
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* A failure as a document: what the default report prints, and a building block for a custom one.
|
|
209
|
+
*
|
|
210
|
+
* @remarks
|
|
211
|
+
* One run of blocks per `Cause` reason. A typed failure is, in order of preference: the document of an error that
|
|
212
|
+
* implements {@link CliDoc}; the document `options.render` holds for its `_tag`; for `Cancelled` and
|
|
213
|
+
* `NotInteractive`, their one fixed line; a `Tree` of the rejected values, for a schema error or issue; else a failure
|
|
214
|
+
* status line with its message. A defect is its message followed by a collapsible `stack` of the program's own frames,
|
|
215
|
+
* each a file link (so a terminal can open it in an editor) shown through `displayPath`, with the runtime's frames (every
|
|
216
|
+
* `node:` frame and every frame with no file) and every `node_modules` frame (Effect's and any other dependency's)
|
|
217
|
+
* left out unless `stackFrames` is `all`. A frame is classified by its file alone, never by its function name, so a
|
|
218
|
+
* program's own thunk that V8 names by Effect's method alias is still shown. When that would
|
|
219
|
+
* leave no frame at all, as for a program run from its own install under `node_modules`, only the runtime's and
|
|
220
|
+
* Effect's are left out. Then an
|
|
221
|
+
* `Error.cause` chain as a tree. When cleaning leaves no frame the stack says
|
|
222
|
+
* `no user frames (N internal frames hidden)`, never an empty block; when frames survive and some were left out, the
|
|
223
|
+
* count follows them as `(+N internal frames hidden)`. A reason that ran under spans is followed by
|
|
224
|
+
* `in: outer › inner`. Interrupts are not rendered beside a real failure, and a cause with only interrupts is the
|
|
225
|
+
* one line `interrupted`.
|
|
226
|
+
*
|
|
227
|
+
* All text goes through the document, so a control character in a message or a stack frame never reaches the terminal.
|
|
228
|
+
* Render it with `Render.context` and `Render.plain`, `ansi`, `markdown` or `githubLog`, or `Doc.print` it.
|
|
229
|
+
*
|
|
230
|
+
* @public
|
|
231
|
+
*/
|
|
232
|
+
var CliFailure = class {
|
|
233
|
+
constructor() {}
|
|
234
|
+
/**
|
|
235
|
+
* Build the document of a cause.
|
|
236
|
+
*
|
|
237
|
+
* @param cause - the failure
|
|
238
|
+
* @param options - per-tag documents and a path display function
|
|
239
|
+
*/
|
|
240
|
+
static toDoc = (cause, options) => {
|
|
241
|
+
const reasons = cause.reasons;
|
|
242
|
+
if (reasons.length > 0 && reasons.every(Cause.isInterruptReason)) return [Doc.paragraph("interrupted")];
|
|
243
|
+
const displayPath = options?.displayPath ?? ((absolute) => absolute);
|
|
244
|
+
return reasons.flatMap((reason) => {
|
|
245
|
+
if (Cause.isFailReason(reason)) return failBlocks(reason.error, spanBlocks(reason), options);
|
|
246
|
+
if (Cause.isDieReason(reason)) return dieBlocks(reason.defect, spanBlocks(reason), displayPath, options?.stackFrames ?? "app");
|
|
247
|
+
return [];
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
//#endregion
|
|
253
|
+
export { CliDoc, CliFailure };
|