@effected/cli 0.22.2 → 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
@@ -1,7 +1,9 @@
1
1
  import { CliInteractive } from "./CliInteractive.js";
2
2
  import { ambientLinksLayer } from "./CliLinks.js";
3
3
  import { CliTheme } from "./CliTheme.js";
4
+ import { displayPathLayer } from "./internal/displayPath.js";
4
5
  import { CliPrompt } from "./CliPrompt.js";
6
+ import { capabilitiesLayer } from "./internal/terminalCapabilities.js";
5
7
  import { ConfigProvider, Layer, Option } from "effect";
6
8
  import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
7
9
 
@@ -29,6 +31,15 @@ import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
29
31
  * `Console` or `Stdio`, never `Terminal`. It also installs `CliTheme.promptTheme`, so core's prompts follow the
30
32
  * theme.
31
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
+ *
32
43
  * @public
33
44
  */
34
45
  var CliEnv = class {
@@ -49,14 +60,15 @@ var CliEnv = class {
49
60
  ...options.editorLinks === void 0 ? {} : { editorLinks: options.editorLinks },
50
61
  ...options.editorLinksEnvVar === void 0 ? {} : { envVar: options.editorLinksEnvVar }
51
62
  }).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));
63
+ const withCapabilities = capabilitiesLayer.pipe(Layer.provideMerge(withLinks));
64
+ const withInteractive = CliInteractive.layer.pipe(Layer.provideMerge(withCapabilities));
65
+ return Layer.mergeAll(CliPrompt.gateTerminal, CliPrompt.gateWizard, CliTheme.promptTheme, displayPathLayer(options.displayPath)).pipe(Layer.provideMerge(withInteractive));
54
66
  };
55
67
  /**
56
68
  * The environment services a test fixes, needing nothing and reading nothing of the host's: `TerminalEnv` and
57
69
  * `Audience` from the answers given, `CliTheme` built from them as {@link CliEnv.layer} builds it, and
58
70
  * `CliInteractive` set from them by the same rule (a human, every stream a terminal, and a `TERM` that is not
59
- * `dumb`).
71
+ * `dumb`), and core's `TerminalCapabilities` from them as `CliEnv.layer` sets it, `canPrompt` held at `true`.
60
72
  *
61
73
  * @remarks
62
74
  * `term` is handed to the theme and interactivity builds alone, through a `ConfigProvider` of their own: the
@@ -81,7 +93,8 @@ var CliEnv = class {
81
93
  stderr: stream
82
94
  }), Audience.layerTest(options.audience ?? "human"));
83
95
  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));
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));
85
98
  };
86
99
  };
87
100
 
package/CliFailure.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Cancelled } from "./Cancelled.js";
2
2
  import { Fmt } from "./Fmt.js";
3
+ import { identity } from "./internal/displayPath.js";
3
4
  import { Doc } from "./Doc.js";
4
5
  import { issueEntries, issueTreeChildren } from "./internal/format.js";
6
+ import { failureText, readProperty, tagOf } from "./internal/inspect.js";
5
7
  import { splitFrame } from "./internal/splitFrame.js";
6
8
  import { NotInteractive } from "./NotInteractive.js";
7
9
  import { Status } from "./Status.js";
@@ -24,22 +26,7 @@ const CliDoc = Symbol.for("@effected/cli/CliDoc");
24
26
  const MAX_DEPTH = 8;
25
27
  const MAX_SPANS = 32;
26
28
  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
29
  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
30
  /** The failure status and the text of a message: one block per line, the status on the first. */
44
31
  const failureBlocks = (message) => {
45
32
  const [first = "", ...rest] = message.split(/\r\n|\r|\n/);
@@ -48,15 +35,13 @@ const failureBlocks = (message) => {
48
35
  /** The issue of a schema failure: the value itself, or the `issue` an error carries. */
49
36
  const issueOf = (error) => {
50
37
  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
- }
38
+ const issue = readProperty(error, "issue");
39
+ return SchemaIssue.isIssue(issue) ? issue : void 0;
55
40
  };
56
41
  const schemaBlocks = (error) => {
57
42
  const entries = issueEntries(issueOf(error));
58
43
  if (entries.length === 0) return void 0;
59
- const header = typeof error === "object" && error !== null && !SchemaIssue.isIssue(error) ? firstLine(describe(error.message ?? "")) : "";
44
+ const header = typeof error === "object" && error !== null && !SchemaIssue.isIssue(error) ? firstLine(failureText(readProperty(error, "message") ?? "")) : "";
60
45
  const root = {
61
46
  label: [
62
47
  Doc.status(Status.core, "failure"),
@@ -138,7 +123,7 @@ const frameBlock = (frame, displayPath) => {
138
123
  return Doc.paragraph(Doc.text("at ", "muted"), ...frame.fn === void 0 ? [] : [`${frame.fn} `], Doc.link(target, where));
139
124
  };
140
125
  const stackBlock = (defect, displayPath, mode) => {
141
- const { frames, hidden } = cleanStack(defect.stack, mode);
126
+ const { frames, hidden } = cleanStack(readProperty(defect, "stack"), mode);
142
127
  if (frames.length > 0) {
143
128
  const shown = frames.map((frame) => frameBlock(frame, displayPath));
144
129
  const note = hidden === 0 ? [] : [Doc.paragraph(Doc.text(`(+${hidden} internal frames hidden)`, "muted"))];
@@ -151,11 +136,11 @@ const stackBlock = (defect, displayPath, mode) => {
151
136
  const causeTree = (defect) => {
152
137
  const chain = [];
153
138
  const seen = /* @__PURE__ */ new Set([defect]);
154
- for (let current = defect.cause; current !== void 0 && chain.length < MAX_DEPTH;) {
139
+ for (let current = readProperty(defect, "cause"); current !== void 0 && chain.length < MAX_DEPTH;) {
155
140
  if (seen.has(current)) break;
156
141
  seen.add(current);
157
- chain.push(firstLine(describe(current)));
158
- current = current instanceof Error ? current.cause : void 0;
142
+ chain.push(firstLine(failureText(current)));
143
+ current = current instanceof Error ? readProperty(current, "cause") : void 0;
159
144
  }
160
145
  if (chain.length === 0) return void 0;
161
146
  const nest = (index) => index === chain.length - 1 ? { label: chain[index] } : {
@@ -163,13 +148,13 @@ const causeTree = (defect) => {
163
148
  children: [nest(index + 1)]
164
149
  };
165
150
  return Doc.tree({
166
- label: firstLine(describe(defect)),
151
+ label: firstLine(failureText(defect)),
167
152
  children: [nest(0)]
168
153
  });
169
154
  };
170
155
  const dieBlocks = (defect, spans, displayPath, mode) => {
171
156
  if (defect instanceof Cancelled || defect instanceof NotInteractive) return [Doc.paragraph(defect.message)];
172
- const header = failureBlocks(describe(defect));
157
+ const header = failureBlocks(failureText(defect));
173
158
  if (!(defect instanceof Error)) return [...header, ...spans];
174
159
  const chain = causeTree(defect);
175
160
  return [
@@ -191,7 +176,7 @@ const failBlocks = (error, spans, options) => {
191
176
  if (error instanceof Cancelled || error instanceof NotInteractive) return [Doc.paragraph(error.message)];
192
177
  const schema = schemaBlocks(error);
193
178
  if (schema !== void 0) return [...schema, ...spans];
194
- return [...failureBlocks(describe(error)), ...spans];
179
+ return [...failureBlocks(failureText(error)), ...spans];
195
180
  };
196
181
  /** A file of the kit's own packages or of Effect, as installed: a span defined there is not the program's. */
197
182
  const isKitFile = (file) => /[\\/]node_modules[\\/](?:@effected[\\/]|effect[\\/])/.test(file);
@@ -272,7 +257,8 @@ const spanBlocks = (reason, mode, appDir) => {
272
257
  * one line `interrupted`.
273
258
  *
274
259
  * All text goes through the document, so a control character in a message or a stack frame never reaches the terminal.
275
- * Render it with `Render.context` and `Render.plain`, `ansi`, `markdown` or `githubLog`, or `Doc.print` it.
260
+ * Building it never throws: an error whose `_tag`, `message`, `stack`, `cause` or `issue` getter throws is drawn without
261
+ * that part (an unreadable `stack` as `no stack`, an unreadable `cause` ending the chain there). Render it with `Render.context` and `Render.plain`, `ansi`, `markdown` or `githubLog`, or `Doc.print` it.
276
262
  *
277
263
  * @public
278
264
  */
@@ -287,7 +273,7 @@ var CliFailure = class {
287
273
  static toDoc = (cause, options) => {
288
274
  const reasons = cause.reasons;
289
275
  if (reasons.length > 0 && reasons.every(Cause.isInterruptReason)) return [Doc.paragraph("interrupted")];
290
- const displayPath = options?.displayPath ?? ((absolute) => absolute);
276
+ const displayPath = options?.displayPath ?? identity;
291
277
  return reasons.flatMap((reason) => {
292
278
  const spans = options?.spans ?? "app";
293
279
  const appDir = packageDirOf(options?.appModule);
package/CliFormat.js ADDED
@@ -0,0 +1,124 @@
1
+ import { underGithubActions } from "./internal/autoFormat.js";
2
+ import { Doc } from "./Doc.js";
3
+ import { neutralizeJson } from "./internal/logSafety.js";
4
+ import { Console, Effect } from "effect";
5
+ import { Flag } from "effect/cli";
6
+
7
+ // src/CliFormat.ts
8
+ const BUILT_IN = /* @__PURE__ */ new Set([
9
+ "auto",
10
+ "plain",
11
+ "ansi",
12
+ "markdown",
13
+ "githubLog",
14
+ "json"
15
+ ]);
16
+ /**
17
+ * A `--format` flag and the one print that honours it, so a command builds its report once as a `Doc` and its data
18
+ * once as a value, and never writes per-format output by hand.
19
+ *
20
+ * @remarks
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.
23
+ *
24
+ * ```ts
25
+ * const format = CliFormat.flag(["auto", "markdown", "json"])
26
+ * const doctor = Command.make("doctor", { format }, ({ format }) =>
27
+ * CliFormat.print(format, { doc: () => report(results), json: () => results }),
28
+ * )
29
+ * ```
30
+ *
31
+ * @public
32
+ */
33
+ var CliFormat = class CliFormat {
34
+ constructor() {}
35
+ /**
36
+ * A `--format` flag offering `formats`, and the caller's own `aliases` and `custom` formats.
37
+ *
38
+ * @remarks
39
+ * Its default is `options.default`, else `auto` when it is offered, else the first format. Any other value is a
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}.
43
+ *
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
56
+ */
57
+ static flag = (formats, options) => {
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]);
74
+ };
75
+ /**
76
+ * Print a command's output in `format`.
77
+ *
78
+ * @remarks
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.
81
+ *
82
+ * @param format - the chosen format, usually the value of {@link CliFormat.flag}
83
+ * @param output - the document and the data
84
+ * @param options - the stream and the JSON indent
85
+ */
86
+ static print = (format, output, options) => Effect.gen(function* () {
87
+ const stream = options?.stream ?? "stdout";
88
+ if (format !== "json") return yield* Doc.print(output.doc(), {
89
+ stream,
90
+ format
91
+ });
92
+ const data = output.json;
93
+ if (data === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("CliFormat.print: format json needs output.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"));
119
+ const safe = (yield* underGithubActions) ? neutralizeJson(text) : text;
120
+ yield* (options?.stream ?? "stdout") === "stdout" ? Console.log(safe) : Console.error(safe);
121
+ });
122
+ };
123
+
124
+ export { CliFormat };
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/CliLog.js CHANGED
@@ -19,18 +19,20 @@ const indentOf = (indent) => {
19
19
  if (typeof indent === "number") return Number.isFinite(indent) && indent > 0 ? " ".repeat(Math.min(MAX_INDENT, Math.floor(indent))) : "";
20
20
  return sanitize(indent).replace(/\r\n|\r|\n/g, "");
21
21
  };
22
- /** The accepted spellings of a level, lower-cased, to the level they mean. */
23
- const LEVELS = {
24
- all: "All",
25
- trace: "Trace",
26
- debug: "Debug",
27
- info: "Info",
28
- warn: "Warn",
29
- warning: "Warn",
30
- error: "Error",
31
- fatal: "Fatal",
32
- none: "None"
33
- };
22
+ /** The accepted spellings of a level, lower-cased, to the level they mean. A `Map`, so no inherited key matches. */
23
+ const LEVELS = /* @__PURE__ */ new Map([
24
+ ["all", "All"],
25
+ ["trace", "Trace"],
26
+ ["debug", "Debug"],
27
+ ["info", "Info"],
28
+ ["warn", "Warn"],
29
+ ["warning", "Warn"],
30
+ ["error", "Error"],
31
+ ["fatal", "Fatal"],
32
+ ["none", "None"]
33
+ ]);
34
+ /** A level's spelling, in any case, to the level it means. */
35
+ const parseLevel = (text) => Option.fromUndefinedOr(LEVELS.get(text.toLowerCase()));
34
36
  const LEVEL_STYLES = {
35
37
  FATAL: {
36
38
  fg: "red",
@@ -57,14 +59,14 @@ const readLevel = (explicit, envVar) => Effect.gen(function* () {
57
59
  level: "None",
58
60
  invalid: void 0
59
61
  };
60
- const level = LEVELS[raw.value.toLowerCase()];
61
- if (level !== void 0) return {
62
- level,
62
+ const level = parseLevel(raw.value);
63
+ if (Option.isSome(level)) return {
64
+ level: level.value,
63
65
  invalid: void 0
64
66
  };
65
67
  return {
66
68
  level: "None",
67
- invalid: `${envVar}=${raw.value} is not a log level (${Object.keys(LEVELS).join("|")}); ignoring it`
69
+ invalid: `${envVar}=${raw.value} is not a log level (${[...LEVELS.keys()].join("|")}); ignoring it`
68
70
  };
69
71
  });
70
72
  /**
@@ -149,6 +151,26 @@ var CliLog = class CliLog {
149
151
  * cannot lower it below the level the layer installed, because Effect has already dropped those records.
150
152
  */
151
153
  static Level = Level;
154
+ /**
155
+ * Read a log level the way `CliLog.layer` reads its environment variable: `Option.some` of the level, or
156
+ * `Option.none()` when the text is not one.
157
+ *
158
+ * @remarks
159
+ * Case-insensitive, and not trimmed. The spellings are `all`, `trace`, `debug`, `info`, `warn` (or `warning`),
160
+ * `error`, `fatal` and `none`. For a host that reads a level from its own flag or config and wants the same answer
161
+ * the layer would give.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * CliLog.parseLevel("WARNING") // Option.some("Warn")
166
+ * CliLog.parseLevel("loud") // Option.none()
167
+ * ```
168
+ *
169
+ * @param text - the level's spelling
170
+ */
171
+ static parseLevel(text) {
172
+ return parseLevel(text);
173
+ }
152
174
  static layer(options = {}) {
153
175
  const file = "file" in options ? options.file : void 0;
154
176
  const format = options.format ?? "auto";
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
@@ -1,5 +1,6 @@
1
1
  import { Cancelled } from "./Cancelled.js";
2
2
  import { sanitize } from "./Fmt.js";
3
+ import { failureText, tagOf } from "./internal/inspect.js";
3
4
  import { NotInteractive } from "./NotInteractive.js";
4
5
  import { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, readSpans, refreshFailureTarget } from "./internal/failureTarget.js";
5
6
  import { CliColor } from "./CliColor.js";
@@ -11,24 +12,34 @@ import { CliLogger, makeCliLogger } from "./CliLogger.js";
11
12
  import { CliLog, envBuildLogLayer, platformLogLayer } from "./CliLog.js";
12
13
  import { ExitRequested } from "./internal/ExitRequested.js";
13
14
  import { routeHelpOnUsageError } from "./internal/HelpRouting.js";
14
- import { Cause, Effect, Layer, Logger, MutableRef, Runtime } from "effect";
15
- import { CliError } from "effect/cli";
15
+ import { Cause, Effect, Layer, Logger, MutableRef, Option, Runtime } from "effect";
16
+ import { CliError, CliOutput } from "effect/cli";
16
17
  import { CommandNeutralizer } from "@effected/github-commands";
18
+ import { withDistribution, withDistributionVersion } from "@effected/engine";
17
19
 
18
20
  // src/CliRuntime.ts
19
21
  const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
20
22
  /** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
21
23
  const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
22
- /** The last line of defence of a failure report: the error's text, sanitised and neutralized, whatever else broke. */
23
- const lastResort = (error) => {
24
- let text;
25
- try {
26
- text = String(error);
27
- } catch {
28
- text = "[unprintable failure]";
29
- }
30
- return CommandNeutralizer.lines(sanitize(text));
24
+ /**
25
+ * A handler failure carrying a `CliError.userMessage` that `Command.runWith` already printed: it renders any such
26
+ * failure and sets the mark to `false`, keeping the error's own tag.
27
+ */
28
+ const isRenderedUserMessage = (u) => typeof u === "object" && u !== null && CliError.getUserMessage(u) !== void 0 && Runtime.getErrorReported(u) === false;
29
+ /**
30
+ * The program with `CurrentDistribution` set (engine's own `withDistribution`), and `--version` decorated with the
31
+ * carrier's suffix (`withDistributionVersion`), wrapping whatever `formatVersion` is in effect rather than replacing a
32
+ * coloured or custom one.
33
+ */
34
+ const withCarrier = (program, distribution) => {
35
+ const provided = program.pipe(withDistribution(distribution));
36
+ return distribution === null ? provided : Effect.updateService(provided, CliOutput.Formatter, (formatter) => ({
37
+ ...formatter,
38
+ formatVersion: withDistributionVersion(Option.some(distribution))(formatter.formatVersion)
39
+ }));
31
40
  };
41
+ /** The last line of defence of a failure report: the error's text, sanitised and neutralized, whatever else broke. */
42
+ const lastResort = (error) => CommandNeutralizer.lines(sanitize(failureText(error, "[unprintable failure]")));
32
43
  const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
33
44
  /**
34
45
  * The error's own exit code when it carries one, otherwise the fallback.
@@ -39,7 +50,17 @@ const toLines = (rendered) => typeof rendered === "string" ? [rendered] : render
39
50
  * silently override a deliberate `1`. Testing for the marker keeps "the error
40
51
  * chose its code" distinct from "nothing chose".
41
52
  */
42
- const chooseExitCode = (error, fallback) => typeof error === "object" && error !== null && Runtime.errorExitCode in error ? Runtime.getErrorExitCode(error) : fallback ?? 1;
53
+ const chooseExitCode = (error, fallback, byTag) => {
54
+ if (typeof error === "object" && error !== null && Runtime.errorExitCode in error) return Runtime.getErrorExitCode(error);
55
+ if (byTag === void 0) return fallback ?? 1;
56
+ const tag = tagOf(error);
57
+ return tag !== void 0 && Object.hasOwn(byTag, tag) ? byTag[tag] : fallback ?? 1;
58
+ };
59
+ /** Every `exitCodes` entry an integer in `1..255`, or a `RangeError` naming the first that is not. */
60
+ const checkExitCodes = (exitCodes) => {
61
+ if (exitCodes === void 0) return;
62
+ for (const [tag, code] of Object.entries(exitCodes)) if (!isExitCode(code) || code === 0) throw new RangeError(`CliRuntime: exitCodes.${tag} must be an integer 1..255, received ${String(code)}`);
63
+ };
43
64
  /**
44
65
  * Report a CLI program's failures through the program's own logger.
45
66
  *
@@ -97,7 +118,10 @@ const chooseExitCode = (error, fallback) => typeof error === "object" && error !
97
118
  * already rendered through its `CliOutput` formatter. It exits with its own
98
119
  * `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
99
120
  * under runWith's `renderErrors: false` the mark stays set, and it renders
100
- * 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
101
125
  * error renders even when it already carries the reported mark — a gate
102
126
  * failure marked with `CliRuntime.reported` still prints its line. The
103
127
  * private `ExitRequested` sentinel `CliRuntime.main`
@@ -148,43 +172,50 @@ var CliRuntime = class CliRuntime {
148
172
  *
149
173
  * @param options - how to render the failure and which exit codes to use
150
174
  */
151
- static reportFailures = (options = {}) => (effect) => effect.pipe(Effect.catchCause((cause) => {
152
- if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
153
- const error = Cause.squash(cause);
154
- if (error instanceof ExitRequested) return Effect.fail(error);
155
- if (isShowHelp(error)) {
156
- const code = error.errors.length > 0 ? options.usageExitCode ?? 64 : 0;
157
- return Effect.fail(CliRuntime.reported(error, code));
158
- }
159
- if (isRenderedUserError(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.usageExitCode ?? 64)));
160
- const render = options.render;
161
- return Effect.gen(function* () {
162
- const target = yield* currentTarget.pipe(Effect.catchCause(() => Effect.succeed(fallbackTarget)));
163
- const reportLines = (status, spans) => {
164
- try {
165
- return linesOf(cause, target, status, spans ?? target.spans);
166
- } catch {
175
+ static reportFailures = (options = {}) => {
176
+ checkExitCodes(options.exitCodes);
177
+ return (effect) => effect.pipe(Effect.catchCause((cause) => {
178
+ if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
179
+ const error = Cause.squash(cause);
180
+ if (error instanceof ExitRequested) return Effect.fail(error);
181
+ if (isShowHelp(error)) {
182
+ const code = error.errors.length > 0 ? options.usageExitCode ?? 64 : 0;
183
+ return Effect.fail(CliRuntime.reported(error, code));
184
+ }
185
+ if (isRenderedUserError(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.usageExitCode ?? 64)));
186
+ if (isRenderedUserMessage(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode, options.exitCodes)));
187
+ const render = options.render;
188
+ return Effect.gen(function* () {
189
+ const target = yield* currentTarget.pipe(Effect.catchCause(() => Effect.succeed(fallbackTarget)));
190
+ const reportLines = (status, spans) => {
167
191
  try {
168
- return plainFailureLines(cause, status, spans ?? target.spans);
192
+ return linesOf(cause, target, status, spans ?? target.spans);
169
193
  } catch {
170
- return lastResort(error);
194
+ try {
195
+ return plainFailureLines(cause, status, spans ?? target.spans);
196
+ } catch {
197
+ return lastResort(error);
198
+ }
171
199
  }
172
- }
173
- };
174
- const defaultLines = reportLines(true);
175
- const details = {
176
- cause,
177
- isDefect: !Cause.hasFails(cause),
178
- isCancelled: error instanceof Cancelled,
179
- isNotInteractive: error instanceof NotInteractive,
180
- defaultLines,
181
- lines: (options) => options?.status === false || options?.spans !== void 0 ? reportLines(options?.status !== false, options?.spans) : defaultLines
182
- };
183
- const lines = render === void 0 ? defaultLines : yield* guardConsumerLines(toLines(render(error, details)));
184
- for (const line of lines) yield* Effect.logError(line).pipe(Effect.provideService(TrustedLine, true));
185
- return yield* Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode)));
186
- });
187
- }));
200
+ };
201
+ const defaultLines = reportLines(true);
202
+ const isDefect = !Cause.hasFails(cause);
203
+ const exitCode = chooseExitCode(error, options.exitCode, isDefect ? void 0 : options.exitCodes);
204
+ const details = {
205
+ cause,
206
+ isDefect,
207
+ exitCode,
208
+ isCancelled: error instanceof Cancelled,
209
+ isNotInteractive: error instanceof NotInteractive,
210
+ defaultLines,
211
+ lines: (options) => options?.status === false || options?.spans !== void 0 ? reportLines(options?.status !== false, options?.spans) : defaultLines
212
+ };
213
+ const lines = render === void 0 ? defaultLines : yield* guardConsumerLines(toLines(render(error, details)));
214
+ for (const line of lines) yield* Effect.logError(line).pipe(Effect.provideService(TrustedLine, true));
215
+ return yield* Effect.fail(CliRuntime.reported(error, exitCode));
216
+ });
217
+ }));
218
+ };
188
219
  static main(program, options) {
189
220
  const env = options.env === void 0 ? void 0 : CliEnv.layer(options.env);
190
221
  const envLog = options.env?.log;
@@ -193,14 +224,14 @@ var CliRuntime = class CliRuntime {
193
224
  const { spans, invalid } = yield* readSpans(options.env?.spans, options.env?.spansEnvVar);
194
225
  if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([makeCliLogger(envLog?.logger)])));
195
226
  yield* refreshFailureTarget(void 0, {
196
- displayPath: options.env?.displayPath,
197
227
  stackFrames: options.env?.stackFrames,
198
228
  spans,
199
229
  appModule: options.env?.appModule
200
230
  });
201
231
  }))).pipe(Layer.provideMerge(env));
232
+ const carried = options.distribution === void 0 ? program : withCarrier(program, options.distribution);
202
233
  const run = Effect.gen(function* () {
203
- yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(program) : program;
234
+ yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(carried) : carried;
204
235
  const exit = yield* CliExit;
205
236
  const code = MutableRef.get(exit.code);
206
237
  if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));