@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 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 withInteractive = CliInteractive.layer.pipe(Layer.provideMerge(withLinks));
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
- return Layer.mergeAll(CliTheme.layer(options.theme), CliInteractive.layer).pipe(Layer.provide(term), Layer.provideMerge(facts));
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.Literals` with the kit's names, default and description; the print hands every document
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
- * @param formats - the formats the command offers, at least one
35
- * @param options - the default, the flag's name and its description
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 fallback = options?.default ?? (formats.includes("auto") ? "auto" : formats[0]);
39
- return Flag.Literals(options?.name ?? "format", formats).pipe(Flag.withDefault(fallback), Flag.withDescription(options?.description ?? `Output format: ${formats.join(", ")} (default ${fallback})`));
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 JSON, one `Console.log` (or `Console.error` for stderr), indented by `indent`.
46
- * Under GitHub Actions a `##[` inside it is written as the JSON escape `#\u0023[`, which decodes to the same text,
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
- const text = yield* Effect.sync(() => JSON.stringify(data(), null, options?.indent ?? 2));
63
- if (text === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("CliFormat.print: output.json() is not JSON"));
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.effect(CliInteractive, Effect.gen(function* () {
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.succeed(CliInteractive, value);
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. Every other
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 { Effect, FileSystem, Path, Stream } from "effect";
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) => Effect.scoped(Effect.gen(function* () {
71
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
72
- const command = ChildProcess.make(options.execPath, [bin, ...args], {
73
- cwd: options.cwd ?? options.sandbox.root,
74
- env: {
75
- ...options.sandbox.env,
76
- ...options.env
77
- },
78
- stdin: options.stdin === void 0 || options.stdin === "" ? Stream.empty : Stream.make(new TextEncoder().encode(options.stdin))
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
- const handle = yield* spawner.spawn(command);
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 };
@@ -1,4 +1,4 @@
1
- import { formatIssue } from "./internal/format.js";
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) => formatIssue(error?.issue);
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, Predicate } from "effect";
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 = (value) => {
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)`) applies.
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 causeMessage = (value) => causeMessage(value);
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 };