@effected/cli 0.23.0 → 0.24.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/CliEnv.js +15 -3
- package/CliFormat.js +71 -16
- package/CliInteractive.js +28 -4
- package/CliPrompt.js +7 -0
- package/CliRuntime.js +10 -1
- package/CliTest.js +134 -22
- package/ConfigIssueRenderer.js +8 -2
- package/Fmt.js +94 -22
- package/index.d.ts +183 -30
- package/internal/HelpRouting.js +21 -75
- package/internal/format.js +74 -3
- package/internal/logSafety.js +1 -1
- package/internal/terminalCapabilities.js +23 -0
- package/package.json +8 -8
- package/testing.d.ts +68 -5
- package/ui/CliUi.js +4 -1
- package/ui.d.ts +4 -1
- package/internal/causeMessage.js +0 -57
package/CliEnv.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ambientLinksLayer } from "./CliLinks.js";
|
|
|
3
3
|
import { CliTheme } from "./CliTheme.js";
|
|
4
4
|
import { displayPathLayer } from "./internal/displayPath.js";
|
|
5
5
|
import { CliPrompt } from "./CliPrompt.js";
|
|
6
|
+
import { capabilitiesLayer } from "./internal/terminalCapabilities.js";
|
|
6
7
|
import { ConfigProvider, Layer, Option } from "effect";
|
|
7
8
|
import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
|
|
8
9
|
|
|
@@ -30,6 +31,15 @@ import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
|
|
|
30
31
|
* `Console` or `Stdio`, never `Terminal`. It also installs `CliTheme.promptTheme`, so core's prompts follow the
|
|
31
32
|
* theme.
|
|
32
33
|
*
|
|
34
|
+
* It sets core's `TerminalCapabilities` from `TerminalEnv` (through `TerminalEnv.capabilities` in `@effected/env`), so
|
|
35
|
+
* the colour of core's own help, version and error output follows the kit's colour rule, and `canAnimate` is stdout
|
|
36
|
+
* being a terminal that is not `dumb`. `canPrompt` alone is held at `true` whatever the terminal is: core skips a
|
|
37
|
+
* fallback prompt entirely when it is `false`, which would lose `CliPrompt.fallback`'s and `CliUi.fallback`'s
|
|
38
|
+
* `otherwise` in every piped run, and `CliInteractive` with `CliPrompt.gateTerminal` already owns that decision (the
|
|
39
|
+
* gate quits any other prompt at once when the run is not interactive). A `TerminalCapabilities` with
|
|
40
|
+
* `canPrompt: false` provided inside the program bypasses the kit's fallbacks: the parameter then fails as missing even
|
|
41
|
+
* when an `otherwise` is given.
|
|
42
|
+
*
|
|
33
43
|
* @public
|
|
34
44
|
*/
|
|
35
45
|
var CliEnv = class {
|
|
@@ -50,14 +60,15 @@ var CliEnv = class {
|
|
|
50
60
|
...options.editorLinks === void 0 ? {} : { editorLinks: options.editorLinks },
|
|
51
61
|
...options.editorLinksEnvVar === void 0 ? {} : { envVar: options.editorLinksEnvVar }
|
|
52
62
|
}).pipe(Layer.provideMerge(withTheme));
|
|
53
|
-
const
|
|
63
|
+
const withCapabilities = capabilitiesLayer.pipe(Layer.provideMerge(withLinks));
|
|
64
|
+
const withInteractive = CliInteractive.layer.pipe(Layer.provideMerge(withCapabilities));
|
|
54
65
|
return Layer.mergeAll(CliPrompt.gateTerminal, CliPrompt.gateWizard, CliTheme.promptTheme, displayPathLayer(options.displayPath)).pipe(Layer.provideMerge(withInteractive));
|
|
55
66
|
};
|
|
56
67
|
/**
|
|
57
68
|
* The environment services a test fixes, needing nothing and reading nothing of the host's: `TerminalEnv` and
|
|
58
69
|
* `Audience` from the answers given, `CliTheme` built from them as {@link CliEnv.layer} builds it, and
|
|
59
70
|
* `CliInteractive` set from them by the same rule (a human, every stream a terminal, and a `TERM` that is not
|
|
60
|
-
* `dumb`).
|
|
71
|
+
* `dumb`), and core's `TerminalCapabilities` from them as `CliEnv.layer` sets it, `canPrompt` held at `true`.
|
|
61
72
|
*
|
|
62
73
|
* @remarks
|
|
63
74
|
* `term` is handed to the theme and interactivity builds alone, through a `ConfigProvider` of their own: the
|
|
@@ -82,7 +93,8 @@ var CliEnv = class {
|
|
|
82
93
|
stderr: stream
|
|
83
94
|
}), Audience.layerTest(options.audience ?? "human"));
|
|
84
95
|
const term = ConfigProvider.layer(ConfigProvider.fromUnknown(options.term === void 0 ? {} : { TERM: options.term }));
|
|
85
|
-
|
|
96
|
+
const interactive = CliInteractive.layer.pipe(Layer.provideMerge(capabilitiesLayer));
|
|
97
|
+
return Layer.mergeAll(CliTheme.layer(options.theme), interactive).pipe(Layer.provide(term), Layer.provideMerge(facts));
|
|
86
98
|
};
|
|
87
99
|
};
|
|
88
100
|
|
package/CliFormat.js
CHANGED
|
@@ -5,13 +5,21 @@ import { Console, Effect } from "effect";
|
|
|
5
5
|
import { Flag } from "effect/cli";
|
|
6
6
|
|
|
7
7
|
// src/CliFormat.ts
|
|
8
|
+
const BUILT_IN = /* @__PURE__ */ new Set([
|
|
9
|
+
"auto",
|
|
10
|
+
"plain",
|
|
11
|
+
"ansi",
|
|
12
|
+
"markdown",
|
|
13
|
+
"githubLog",
|
|
14
|
+
"json"
|
|
15
|
+
]);
|
|
8
16
|
/**
|
|
9
17
|
* A `--format` flag and the one print that honours it, so a command builds its report once as a `Doc` and its data
|
|
10
18
|
* once as a value, and never writes per-format output by hand.
|
|
11
19
|
*
|
|
12
20
|
* @remarks
|
|
13
|
-
* The flag is core's `Flag.
|
|
14
|
-
* format to `Doc.print` and writes `json` itself.
|
|
21
|
+
* The flag is core's `Flag.ChoiceWithValue` with the kit's names, default and description; the print hands every
|
|
22
|
+
* document format to `Doc.print` and writes `json` itself.
|
|
15
23
|
*
|
|
16
24
|
* ```ts
|
|
17
25
|
* const format = CliFormat.flag(["auto", "markdown", "json"])
|
|
@@ -22,30 +30,54 @@ import { Flag } from "effect/cli";
|
|
|
22
30
|
*
|
|
23
31
|
* @public
|
|
24
32
|
*/
|
|
25
|
-
var CliFormat = class {
|
|
33
|
+
var CliFormat = class CliFormat {
|
|
26
34
|
constructor() {}
|
|
27
35
|
/**
|
|
28
|
-
* A `--format` flag offering `formats
|
|
36
|
+
* A `--format` flag offering `formats`, and the caller's own `aliases` and `custom` formats.
|
|
29
37
|
*
|
|
30
38
|
* @remarks
|
|
31
39
|
* Its default is `options.default`, else `auto` when it is offered, else the first format. Any other value is a
|
|
32
|
-
* usage error from core's parser, naming the accepted ones.
|
|
40
|
+
* usage error from core's parser, naming the accepted ones. The value is the built-in format a name stands for
|
|
41
|
+
* (`human` under `{ human: "auto" }` is `auto`), or a custom name as itself, so the value of a flag with no custom
|
|
42
|
+
* formats goes straight to {@link CliFormat.print}.
|
|
33
43
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
44
|
+
* Two offered names that clash (an alias or custom name reusing a built-in name, or any name offered twice) and a
|
|
45
|
+
* default that is not offered are programming mistakes: the call throws. A CLI that still accepts a `--json` of its
|
|
46
|
+
* own declares and resolves that flag itself; {@link CliFormat.printJson} prints the JSON the same way.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* // okfit's contract: --format human|json, and a graph command that also draws mermaid and dot
|
|
50
|
+
* const format = CliFormat.flag(["json"], { aliases: { human: "auto" }, default: "human" })
|
|
51
|
+
* const graph = CliFormat.flag(["json"], { custom: ["mermaid", "dot"], default: "mermaid" })
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @param formats - the built-in formats the command offers under their own names, at least one
|
|
55
|
+
* @param options - the default, the flag's name and description, aliases and custom formats
|
|
36
56
|
*/
|
|
37
57
|
static flag = (formats, options) => {
|
|
38
|
-
const
|
|
39
|
-
|
|
58
|
+
const name = options?.name ?? "format";
|
|
59
|
+
const choices = [
|
|
60
|
+
...formats.map((format) => [format, format]),
|
|
61
|
+
...Object.entries(options?.aliases ?? {}).map(([alias, target]) => [alias, target]),
|
|
62
|
+
...(options?.custom ?? []).map((custom) => [custom, custom])
|
|
63
|
+
];
|
|
64
|
+
const offered = choices.map(([spelling]) => spelling);
|
|
65
|
+
for (const [index, spelling] of offered.entries()) {
|
|
66
|
+
if (offered.indexOf(spelling) !== index) throw new Error(`CliFormat.flag: the format name "${spelling}" is offered twice`);
|
|
67
|
+
if (index >= formats.length && BUILT_IN.has(spelling)) throw new Error(`CliFormat.flag: "${spelling}" is a built-in format, not an alias or a custom one`);
|
|
68
|
+
}
|
|
69
|
+
const fallback = options?.default ?? (offered.includes("auto") ? "auto" : formats[0]);
|
|
70
|
+
const fallbackChoice = choices.find(([spelling]) => spelling === fallback);
|
|
71
|
+
if (fallbackChoice === void 0) throw new Error(`CliFormat.flag: the default "${fallback}" is not one of ${offered.join(", ")}`);
|
|
72
|
+
const flag = Flag.ChoiceWithValue(name, choices).pipe(Flag.withDescription(options?.description ?? `Output format: ${offered.join(", ")} (default ${fallback})`));
|
|
73
|
+
return Flag.withDefault(flag, fallbackChoice[1]);
|
|
40
74
|
};
|
|
41
75
|
/**
|
|
42
76
|
* Print a command's output in `format`.
|
|
43
77
|
*
|
|
44
78
|
* @remarks
|
|
45
|
-
* `json` writes `output.json()` as
|
|
46
|
-
*
|
|
47
|
-
* so the runner cannot read it as a command. A value JSON cannot hold (a cycle, a bigint) is a defect. Every other
|
|
48
|
-
* format prints `output.doc()` with `Doc.print`, which renders it as `auto` or the named renderer would.
|
|
79
|
+
* `json` writes `output.json()` exactly as {@link CliFormat.printJson} writes a value, which it delegates to. Every
|
|
80
|
+
* other format prints `output.doc()` with `Doc.print`, which renders it as `auto` or the named renderer would.
|
|
49
81
|
*
|
|
50
82
|
* @param format - the chosen format, usually the value of {@link CliFormat.flag}
|
|
51
83
|
* @param output - the document and the data
|
|
@@ -59,10 +91,33 @@ var CliFormat = class {
|
|
|
59
91
|
});
|
|
60
92
|
const data = output.json;
|
|
61
93
|
if (data === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("CliFormat.print: format json needs output.json"));
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
return yield* CliFormat.printJson(data(), options);
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Print `value` as JSON, the way {@link CliFormat.print} prints the `json` format, for a CLI that keeps a JSON flag
|
|
98
|
+
* of its own.
|
|
99
|
+
*
|
|
100
|
+
* @remarks
|
|
101
|
+
* One `Console.log` (or `Console.error` for stderr), indented by `indent`: one document, never one line per item.
|
|
102
|
+
* Under GitHub Actions a `##[` inside it is written as the JSON escape `#\u0023[`, which decodes to the same
|
|
103
|
+
* text, so the runner cannot read it as a command. A value JSON cannot hold (a cycle, a bigint, `undefined`) is a
|
|
104
|
+
* defect. It needs no theme, terminal or audience: whether this is GitHub Actions is read from `CurrentRuntimeEnv`
|
|
105
|
+
* when the environment has it, and is "no" otherwise.
|
|
106
|
+
*
|
|
107
|
+
* ```ts
|
|
108
|
+
* const status = Command.make("status", { json: Flag.Boolean("json").pipe(Flag.withDefault(false)) }, ({ json }) =>
|
|
109
|
+
* json ? CliFormat.printJson(results) : Doc.print(report(results)),
|
|
110
|
+
* )
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* @param value - the data to print
|
|
114
|
+
* @param options - the stream and the JSON indent
|
|
115
|
+
*/
|
|
116
|
+
static printJson = (value, options) => Effect.gen(function* () {
|
|
117
|
+
const text = yield* Effect.sync(() => JSON.stringify(value, null, options?.indent ?? 2));
|
|
118
|
+
if (text === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("CliFormat.printJson: the value is not JSON"));
|
|
64
119
|
const safe = (yield* underGithubActions) ? neutralizeJson(text) : text;
|
|
65
|
-
yield* stream === "stdout" ? Console.log(safe) : Console.error(safe);
|
|
120
|
+
yield* (options?.stream ?? "stdout") === "stdout" ? Console.log(safe) : Console.error(safe);
|
|
66
121
|
});
|
|
67
122
|
};
|
|
68
123
|
|
package/CliInteractive.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canPrompt } from "./internal/canPrompt.js";
|
|
2
2
|
import { Context, Effect, Layer } from "effect";
|
|
3
3
|
import { Audience, TerminalEnv } from "@effected/env";
|
|
4
|
+
import { TerminalCapabilities } from "effect/cli";
|
|
4
5
|
|
|
5
6
|
// src/CliInteractive.ts
|
|
6
7
|
/**
|
|
@@ -21,6 +22,19 @@ import { Audience, TerminalEnv } from "@effected/env";
|
|
|
21
22
|
* {@link CliInteractive.layerTest} are typed `Layer<never>`: they set the
|
|
22
23
|
* reference rather than provide a service.
|
|
23
24
|
*
|
|
25
|
+
* Both layers also hold core's `TerminalCapabilities.canPrompt` at `true`,
|
|
26
|
+
* keeping its `canColor` and `canAnimate` as they find them. Core skips a
|
|
27
|
+
* `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` fallback entirely
|
|
28
|
+
* when `canPrompt` is `false`, and decides that from the process's own
|
|
29
|
+
* terminal, so a kit fallback (`CliPrompt.fallback`, `CliUi.fallback`) would
|
|
30
|
+
* never be called in a piped run and its `otherwise` would be lost. With the
|
|
31
|
+
* gate held open, this reference makes the prompt decision instead: the kit's
|
|
32
|
+
* fallbacks answer without a person when it is `false`, and
|
|
33
|
+
* `CliPrompt.gateTerminal` quits any other prompt at once. A
|
|
34
|
+
* `TerminalCapabilities` with `canPrompt: false` provided inside the program,
|
|
35
|
+
* under these layers, bypasses the kit's fallbacks again: the parameter fails
|
|
36
|
+
* as missing even when an `otherwise` is given.
|
|
37
|
+
*
|
|
24
38
|
* @public
|
|
25
39
|
*/
|
|
26
40
|
var CliInteractive = class CliInteractive extends Context.Reference("@effected/cli/CliInteractive", { defaultValue: () => false }) {
|
|
@@ -34,19 +48,21 @@ var CliInteractive = class CliInteractive extends Context.Reference("@effected/c
|
|
|
34
48
|
* as `@effected/env` reads the environment, so it adds no requirement; a test fixes it with
|
|
35
49
|
* `Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ TERM: "dumb" }))`.
|
|
36
50
|
*
|
|
51
|
+
* It also holds core's `TerminalCapabilities.canPrompt` at `true`; see {@link CliInteractive}.
|
|
52
|
+
*
|
|
37
53
|
* Bind the layer to a constant and provide it once; `Audience` and `TerminalEnv` come from `@effected/env`.
|
|
38
54
|
*/
|
|
39
|
-
static layer = Layer.
|
|
55
|
+
static layer = Layer.effectContext(Effect.gen(function* () {
|
|
40
56
|
const audience = yield* Audience;
|
|
41
57
|
const terminal = yield* TerminalEnv;
|
|
42
|
-
return audience.kind === "human" && (yield* canPrompt(terminal));
|
|
58
|
+
return yield* decided(audience.kind === "human" && (yield* canPrompt(terminal)));
|
|
43
59
|
}));
|
|
44
60
|
/**
|
|
45
|
-
* A fixed answer that needs nothing.
|
|
61
|
+
* A fixed answer that needs nothing. It holds core's `canPrompt` open too, as {@link CliInteractive.layer} does.
|
|
46
62
|
*
|
|
47
63
|
* @param value - whether the run is interactive
|
|
48
64
|
*/
|
|
49
|
-
static layerTest = (value) => Layer.
|
|
65
|
+
static layerTest = (value) => Layer.effectContext(decided(value));
|
|
50
66
|
/**
|
|
51
67
|
* Run `self` with interactivity switched off when `condition` holds.
|
|
52
68
|
*
|
|
@@ -66,5 +82,13 @@ var CliInteractive = class CliInteractive extends Context.Reference("@effected/c
|
|
|
66
82
|
return yield* Effect.provideService(self, CliInteractive, current && !condition);
|
|
67
83
|
});
|
|
68
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* The context the two layers set: the decision, and core's capabilities as they are found with `canPrompt` held at
|
|
87
|
+
* `true`, so core always hands a missing parameter to its fallback and the kit's decision governs.
|
|
88
|
+
*/
|
|
89
|
+
const decided = (interactive) => Effect.map(TerminalCapabilities.TerminalCapabilities, (capabilities) => Context.make(CliInteractive, interactive).pipe(Context.add(TerminalCapabilities.TerminalCapabilities, {
|
|
90
|
+
...capabilities,
|
|
91
|
+
canPrompt: true
|
|
92
|
+
})));
|
|
69
93
|
|
|
70
94
|
export { CliInteractive };
|
package/CliPrompt.js
CHANGED
|
@@ -36,6 +36,13 @@ var CliPrompt = class {
|
|
|
36
36
|
* input (core acquires it only when a prompt needs a key), so piped stdin is left untouched without any gate.
|
|
37
37
|
* `CliPrompt.gateTerminal`, which `CliEnv.layer` installs, covers the prompts this function does not wrap.
|
|
38
38
|
*
|
|
39
|
+
* Core calls a fallback only when its `TerminalCapabilities.canPrompt` is `true`, and its own default decides that
|
|
40
|
+
* from the process's terminal, so in a piped run it would fail the parameter as missing without calling this
|
|
41
|
+
* function and `otherwise` would be lost. `CliInteractive.layer` and `CliInteractive.layerTest` (and so
|
|
42
|
+
* `CliEnv.layer`) hold `canPrompt` at `true` for that reason: the decision is `CliInteractive`'s, made here. A
|
|
43
|
+
* `TerminalCapabilities` with `canPrompt: false` provided inside the program bypasses this fallback again, and a
|
|
44
|
+
* program that sets `CliInteractive` with neither layer keeps core's own gate.
|
|
45
|
+
*
|
|
39
46
|
* @param prompt - the prompt to show
|
|
40
47
|
* @param options - the parameter it stands in for, and the non-interactive default
|
|
41
48
|
*/
|
package/CliRuntime.js
CHANGED
|
@@ -22,6 +22,11 @@ const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
|
|
|
22
22
|
/** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
|
|
23
23
|
const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
|
|
24
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
|
+
/**
|
|
25
30
|
* The program with `CurrentDistribution` set (engine's own `withDistribution`), and `--version` decorated with the
|
|
26
31
|
* carrier's suffix (`withDistributionVersion`), wrapping whatever `formatVersion` is in effect rather than replacing a
|
|
27
32
|
* coloured or custom one.
|
|
@@ -113,7 +118,10 @@ const checkExitCodes = (exitCodes) => {
|
|
|
113
118
|
* already rendered through its `CliOutput` formatter. It exits with its own
|
|
114
119
|
* `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
|
|
115
120
|
* under runWith's `renderErrors: false` the mark stays set, and it renders
|
|
116
|
-
* here like any other failure.
|
|
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
|
|
117
125
|
* error renders even when it already carries the reported mark — a gate
|
|
118
126
|
* failure marked with `CliRuntime.reported` still prints its line. The
|
|
119
127
|
* private `ExitRequested` sentinel `CliRuntime.main`
|
|
@@ -175,6 +183,7 @@ var CliRuntime = class CliRuntime {
|
|
|
175
183
|
return Effect.fail(CliRuntime.reported(error, code));
|
|
176
184
|
}
|
|
177
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)));
|
|
178
187
|
const render = options.render;
|
|
179
188
|
return Effect.gen(function* () {
|
|
180
189
|
const target = yield* currentTarget.pipe(Effect.catchCause(() => Effect.succeed(fallbackTarget)));
|
package/CliTest.js
CHANGED
|
@@ -1,7 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Fmt } from "./Fmt.js";
|
|
2
|
+
import { Duration, Effect, Fiber, FileSystem, Option, Path, Stream, SubscriptionRef } from "effect";
|
|
2
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
3
4
|
|
|
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
|
+
});
|
|
5
55
|
const text = (stream) => Stream.mkString(Stream.decodeText(stream));
|
|
6
56
|
/**
|
|
7
57
|
* Spawn a built CLI bin hermetically and read its exit code and streams as data.
|
|
@@ -67,28 +117,90 @@ var CliTest = class {
|
|
|
67
117
|
* default `"pipe"` stdio, which stays open until something writes to and
|
|
68
118
|
* ends it.
|
|
69
119
|
*/
|
|
70
|
-
static run = (bin, args, options) =>
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
};
|
|
79
202
|
});
|
|
80
|
-
|
|
81
|
-
const [stdout, stderr, exitCode] = yield* Effect.all([
|
|
82
|
-
text(handle.stdout),
|
|
83
|
-
text(handle.stderr),
|
|
84
|
-
handle.exitCode
|
|
85
|
-
], { concurrency: "unbounded" });
|
|
86
|
-
return {
|
|
87
|
-
exitCode: Number(exitCode),
|
|
88
|
-
stdout,
|
|
89
|
-
stderr
|
|
90
|
-
};
|
|
91
|
-
}));
|
|
203
|
+
};
|
|
92
204
|
};
|
|
93
205
|
|
|
94
206
|
export { CliTest };
|
package/ConfigIssueRenderer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatIssueWithHints } from "./internal/format.js";
|
|
2
2
|
|
|
3
3
|
// src/ConfigIssueRenderer.ts
|
|
4
4
|
/**
|
|
@@ -47,11 +47,17 @@ var ConfigIssueRenderer = class {
|
|
|
47
47
|
* `Effect.catchTag("ConfigValidationError", …)` the error is already this
|
|
48
48
|
* type.
|
|
49
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
|
+
*
|
|
50
56
|
* It cannot throw on a malformed value: the issue tree is validated by
|
|
51
57
|
* a guard before it is read, so a renderer on an error path never becomes the
|
|
52
58
|
* reason a program dies.
|
|
53
59
|
*/
|
|
54
|
-
static render = (error) =>
|
|
60
|
+
static render = (error) => formatIssueWithHints(error?.issue);
|
|
55
61
|
};
|
|
56
62
|
|
|
57
63
|
export { ConfigIssueRenderer };
|
package/Fmt.js
CHANGED
|
@@ -1,8 +1,40 @@
|
|
|
1
|
-
import { causeMessage } from "./internal/causeMessage.js";
|
|
2
1
|
import { displayWidth, graphemes, isEscapeOnly, stripAnsi } from "./internal/displayWidth.js";
|
|
3
|
-
import { Duration, Option
|
|
2
|
+
import { Duration, Option } from "effect";
|
|
3
|
+
import { ErrorText } from "@effected/engine";
|
|
4
4
|
|
|
5
5
|
// src/Fmt.ts
|
|
6
|
+
/**
|
|
7
|
+
* The Levenshtein distance between `a` and `b` when it is at most `limit`, and `limit + 1` otherwise.
|
|
8
|
+
*
|
|
9
|
+
* Only the diagonal band of the matrix within `limit` of it is computed (a cell outside it is past `limit` already),
|
|
10
|
+
* over two rows reused in turn, and a row whose every cell is past `limit` ends the walk, since no later row can come
|
|
11
|
+
* back under it: the cost is the length of `a` times `2 * limit + 1`.
|
|
12
|
+
*/
|
|
13
|
+
const boundedDistance = (a, b, limit) => {
|
|
14
|
+
const over = limit + 1;
|
|
15
|
+
if (Math.abs(a.length - b.length) > limit) return over;
|
|
16
|
+
const n = b.length;
|
|
17
|
+
const at = (row, j) => row[j] ?? over;
|
|
18
|
+
let previous = Array.from({ length: n + 1 }, (_, j) => j <= limit ? j : over);
|
|
19
|
+
let current = new Array(n + 1).fill(over);
|
|
20
|
+
for (let i = 1; i <= a.length; i++) {
|
|
21
|
+
const lo = Math.max(1, i - limit);
|
|
22
|
+
const hi = Math.min(n, i + limit);
|
|
23
|
+
const edge = lo === 1 && i <= limit ? i : over;
|
|
24
|
+
current[lo - 1] = edge;
|
|
25
|
+
let rowMin = edge;
|
|
26
|
+
for (let j = lo; j <= hi; j++) {
|
|
27
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
28
|
+
const cell = Math.min(at(previous, j) + 1, at(current, j - 1) + 1, at(previous, j - 1) + cost, over);
|
|
29
|
+
current[j] = cell;
|
|
30
|
+
if (cell < rowMin) rowMin = cell;
|
|
31
|
+
}
|
|
32
|
+
if (hi < n) current[hi + 1] = over;
|
|
33
|
+
if (rowMin > limit) return over;
|
|
34
|
+
[previous, current] = [current, previous];
|
|
35
|
+
}
|
|
36
|
+
return Math.min(at(previous, n), over);
|
|
37
|
+
};
|
|
6
38
|
const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
7
39
|
/**
|
|
8
40
|
* Text made safe to lay out and print: complete ANSI and OSC sequences removed, every remaining control character
|
|
@@ -218,27 +250,15 @@ var Fmt = class {
|
|
|
218
250
|
* function), or `"[unprintable value]"` when that throws too. A getter that throws is a value with no message.
|
|
219
251
|
*
|
|
220
252
|
* The text is not sanitised: pass it through {@link Fmt.sanitize} before printing data you do not control. For
|
|
221
|
-
* the messages along an error's `cause` chain, use {@link Fmt.causeMessage}
|
|
253
|
+
* the messages along an error's `cause` chain, use {@link Fmt.causeMessage}; for the two combined, use
|
|
254
|
+
* {@link Fmt.errorText}.
|
|
255
|
+
*
|
|
256
|
+
* It is `@effected/engine`'s `ErrorText.describe`, the same function, so a front end that cannot depend on this
|
|
257
|
+
* package (an MCP server) describes a value with the same words.
|
|
222
258
|
*
|
|
223
259
|
* @param value - the value
|
|
224
260
|
*/
|
|
225
|
-
static describe =
|
|
226
|
-
if (Predicate.isString(value)) return value;
|
|
227
|
-
if (value === void 0 || value === null) return String(value);
|
|
228
|
-
if (typeof value !== "object" && typeof value !== "function") return String(value);
|
|
229
|
-
try {
|
|
230
|
-
if (Predicate.hasProperty(value, "message") && Predicate.isString(value.message)) return value.message;
|
|
231
|
-
} catch {}
|
|
232
|
-
try {
|
|
233
|
-
const json = JSON.stringify(value);
|
|
234
|
-
if (json !== void 0) return json;
|
|
235
|
-
} catch {}
|
|
236
|
-
try {
|
|
237
|
-
return String(value);
|
|
238
|
-
} catch {
|
|
239
|
-
return "[unprintable value]";
|
|
240
|
-
}
|
|
241
|
-
};
|
|
261
|
+
static describe = ErrorText.describe;
|
|
242
262
|
/**
|
|
243
263
|
* Every distinct non-empty message along a value's `cause` chain, nearest first and joined with `: `, or
|
|
244
264
|
* `undefined` when nothing along it has one.
|
|
@@ -253,11 +273,63 @@ var Fmt = class {
|
|
|
253
273
|
*
|
|
254
274
|
* Never throws: a `message` getter that throws leaves that node without text, and a `cause` getter that throws ends
|
|
255
275
|
* the walk with the text found so far, `undefined` when there is none, so a caller's own fallback
|
|
256
|
-
* (`?? Fmt.describe(value)
|
|
276
|
+
* (`?? Fmt.describe(value)`, which {@link Fmt.errorText} spells once) applies.
|
|
277
|
+
*
|
|
278
|
+
* It is `@effected/engine`'s `ErrorText.causeMessage`, the same function.
|
|
279
|
+
*
|
|
280
|
+
* @param value - the error, or whatever was thrown
|
|
281
|
+
*/
|
|
282
|
+
static causeMessage = ErrorText.causeMessage;
|
|
283
|
+
/**
|
|
284
|
+
* The text of an unknown failure: {@link Fmt.causeMessage} when the chain carries a message, otherwise
|
|
285
|
+
* {@link Fmt.describe}.
|
|
286
|
+
*
|
|
287
|
+
* @remarks
|
|
288
|
+
* Exactly `Fmt.causeMessage(value) ?? Fmt.describe(value)`, the fallback an I/O site turning a caught value into
|
|
289
|
+
* text would otherwise spell out by hand at every call. An error whose message is empty and whose chain carries none
|
|
290
|
+
* is `""`, as `describe` reads it. Never throws, and not sanitised: pass it through {@link Fmt.sanitize} before
|
|
291
|
+
* printing data you do not control. It is `@effected/engine`'s `ErrorText.of`, the same function.
|
|
257
292
|
*
|
|
258
293
|
* @param value - the error, or whatever was thrown
|
|
259
294
|
*/
|
|
260
|
-
static
|
|
295
|
+
static errorText = ErrorText.of;
|
|
296
|
+
/**
|
|
297
|
+
* The candidates nearest to `input`, for a "did you mean" hint: those at the smallest edit distance found, up to
|
|
298
|
+
* `maxDistance` edits (two by default), in the order given, each once. No candidate that close is `[]`.
|
|
299
|
+
*
|
|
300
|
+
* @remarks
|
|
301
|
+
* The distance is Levenshtein's (one insertion, deletion or substitution per edit), over UTF-16 code units and
|
|
302
|
+
* case-sensitive, with the same two-edit threshold and the same ties-all-kept rule `effect/cli` uses for an unknown
|
|
303
|
+
* subcommand or flag, so a program's own suggestions agree with the parser's. A candidate equal to `input` is at
|
|
304
|
+
* distance zero and is the only one returned.
|
|
305
|
+
*
|
|
306
|
+
* The cost is linear in the candidates' total length: a candidate whose length differs from `input`'s by more than
|
|
307
|
+
* `maxDistance` is skipped unmeasured, and only the band of the edit matrix within `maxDistance` of its diagonal is
|
|
308
|
+
* measured, so a long typed value cannot make it quadratic. A `maxDistance` that is not a non-negative integer is a programming mistake and
|
|
309
|
+
* throws a `RangeError`.
|
|
310
|
+
*
|
|
311
|
+
* ```ts
|
|
312
|
+
* Fmt.suggest("stauts", ["status", "stack", "build"]) // => ["status"]
|
|
313
|
+
* ```
|
|
314
|
+
*
|
|
315
|
+
* @param input - what was typed: an unknown key, a profile name, a phase name
|
|
316
|
+
* @param candidates - the names it could have meant
|
|
317
|
+
* @param options - the most edits a suggestion may be away
|
|
318
|
+
*/
|
|
319
|
+
static suggest = (input, candidates, options) => {
|
|
320
|
+
const max = options?.maxDistance ?? 2;
|
|
321
|
+
if (!Number.isInteger(max) || max < 0) throw new RangeError(`Fmt.suggest: maxDistance must be a non-negative integer, received ${max}`);
|
|
322
|
+
let best = max + 1;
|
|
323
|
+
let found = [];
|
|
324
|
+
for (const candidate of new Set(candidates)) {
|
|
325
|
+
const distance = boundedDistance(input, candidate, Math.min(max, best));
|
|
326
|
+
if (distance < best) {
|
|
327
|
+
best = distance;
|
|
328
|
+
found = [candidate];
|
|
329
|
+
} else if (distance === best) found.push(candidate);
|
|
330
|
+
}
|
|
331
|
+
return best <= max ? found : [];
|
|
332
|
+
};
|
|
261
333
|
};
|
|
262
334
|
|
|
263
335
|
export { Fmt, sanitize };
|