@effected/cli-kit 0.0.0-stage → 0.27.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.
Files changed (103) hide show
  1. package/Cancelled.js +43 -0
  2. package/CliAudience.js +177 -0
  3. package/CliColor.js +66 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +117 -0
  6. package/CliFailure.js +287 -0
  7. package/CliFormat.js +124 -0
  8. package/CliInteractive.js +94 -0
  9. package/CliLinks.js +153 -0
  10. package/CliLog.js +367 -0
  11. package/CliLogger.js +91 -0
  12. package/CliMessage.js +79 -0
  13. package/CliPrompt.js +110 -0
  14. package/CliRuntime.js +254 -0
  15. package/CliTest.js +240 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +62 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +295 -0
  20. package/GithubAnnotation.js +39 -0
  21. package/Glyphs.js +82 -0
  22. package/LICENSE +21 -0
  23. package/NotInteractive.js +41 -0
  24. package/README.md +213 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +107 -0
  29. package/Token.js +70 -0
  30. package/index.d.ts +4035 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +63 -0
  34. package/internal/ansi.js +229 -0
  35. package/internal/autoFormat.js +33 -0
  36. package/internal/canPrompt.js +14 -0
  37. package/internal/counts.js +83 -0
  38. package/internal/diagnostics.js +31 -0
  39. package/internal/displayPath.js +46 -0
  40. package/internal/displayWidth.js +42 -0
  41. package/internal/failureTarget.js +195 -0
  42. package/internal/fallbackAnswer.js +17 -0
  43. package/internal/fileSink.js +61 -0
  44. package/internal/format.js +173 -0
  45. package/internal/inspect.js +40 -0
  46. package/internal/isExitCode.js +11 -0
  47. package/internal/layout.js +249 -0
  48. package/internal/linkScheme.js +29 -0
  49. package/internal/linkTarget.js +49 -0
  50. package/internal/logSafety.js +45 -0
  51. package/internal/renderAnsi.js +51 -0
  52. package/internal/renderDoc.js +322 -0
  53. package/internal/renderGithubLog.js +59 -0
  54. package/internal/renderMarkdown.js +367 -0
  55. package/internal/renderPlain.js +49 -0
  56. package/internal/scanAudience.js +105 -0
  57. package/internal/splitFrame.js +55 -0
  58. package/internal/terminalCapabilities.js +23 -0
  59. package/internal/wizardGate.js +17 -0
  60. package/package.json +87 -5
  61. package/testing.d.ts +283 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +509 -0
  65. package/ui/CliUiDetached.js +36 -0
  66. package/ui/CliUiLive.js +436 -0
  67. package/ui/CliUiRouteStream.js +72 -0
  68. package/ui/Confirm.js +244 -0
  69. package/ui/DocView.js +76 -0
  70. package/ui/KeyHelp.js +61 -0
  71. package/ui/KeyTable.js +198 -0
  72. package/ui/MultiSelect.js +259 -0
  73. package/ui/Select.js +229 -0
  74. package/ui/Tabs.js +201 -0
  75. package/ui/TextInput.js +289 -0
  76. package/ui/Toggle.js +31 -0
  77. package/ui/UiKey.js +48 -0
  78. package/ui/UiProvider.js +62 -0
  79. package/ui/UiStreams.js +17 -0
  80. package/ui/UiTheme.js +166 -0
  81. package/ui/Viewport.js +203 -0
  82. package/ui/internal/ErrorBoundary.js +29 -0
  83. package/ui/internal/Holder.js +73 -0
  84. package/ui/internal/ScreenContext.js +51 -0
  85. package/ui/internal/UiProviders.js +38 -0
  86. package/ui/internal/ink.js +121 -0
  87. package/ui/internal/inkChalk.js +47 -0
  88. package/ui/internal/inkConsole.js +150 -0
  89. package/ui/internal/lazyView.js +73 -0
  90. package/ui/internal/lineRouter.js +70 -0
  91. package/ui/internal/lineText.js +18 -0
  92. package/ui/internal/mountPermit.js +15 -0
  93. package/ui/internal/processStreams.js +18 -0
  94. package/ui/internal/renderOptions.js +12 -0
  95. package/ui/testing/CliUiTest.js +792 -0
  96. package/ui/testing/fakeStreams.js +104 -0
  97. package/ui/testing/terminalModel.js +67 -0
  98. package/ui-testing-serializer.d.ts +12 -0
  99. package/ui-testing-serializer.js +32 -0
  100. package/ui-testing.d.ts +558 -0
  101. package/ui-testing.js +3 -0
  102. package/ui.d.ts +2013 -0
  103. package/ui.js +17 -0
package/Cancelled.js ADDED
@@ -0,0 +1,43 @@
1
+ import { Runtime, Schema } from "effect";
2
+
3
+ // src/Cancelled.ts
4
+ /**
5
+ * A person backed out of an interactive prompt: they pressed escape, or the
6
+ * prompt was interrupted.
7
+ *
8
+ * @remarks
9
+ * Exits `130`, the conventional status for a run ended by the user, through
10
+ * core's own `Runtime.errorExitCode` marker, so `CliRuntime.reportFailures`
11
+ * keeps it. Its default rendering is one line, `cancelled; nothing written`,
12
+ * because nothing has been written by the time a prompt is cancelled and a
13
+ * stack trace would only alarm. A consumer `render` still overrides the line, and can hand off to it: the line is
14
+ * the error's `message`, so `error.message` and `String(error)` carry it.
15
+ *
16
+ * @public
17
+ */
18
+ var Cancelled = class extends Schema.TaggedError()("Cancelled", { reason: Schema.Literals(["escape", "interrupt"]) }) {
19
+ /**
20
+ * The one line, `cancelled; nothing written`.
21
+ *
22
+ * @remarks
23
+ * A prototype getter, not a field, so it is not part of the encoded form, equality or a JSON dump. Assigning to
24
+ * it is ignored: a library that rewrites `error.message` (to prefix a context, say) must not make this error throw,
25
+ * which a getter-only property does in strict mode. The line is fixed.
26
+ */
27
+ get message() {
28
+ return "cancelled; nothing written";
29
+ }
30
+ set message(_value) {}
31
+ /**
32
+ * The process exit code: `130`.
33
+ *
34
+ * @remarks
35
+ * A prototype getter rather than an own field, so a JSON or logger dump of the error does not carry the
36
+ * runtime marker.
37
+ */
38
+ get [Runtime.errorExitCode]() {
39
+ return 130;
40
+ }
41
+ };
42
+
43
+ export { Cancelled };
package/CliAudience.js ADDED
@@ -0,0 +1,177 @@
1
+ import { CliInteractive } from "./CliInteractive.js";
2
+ import { canPrompt } from "./internal/canPrompt.js";
3
+ import { refreshFailureTarget } from "./internal/failureTarget.js";
4
+ import { scanAudience, tallyAudience } from "./internal/scanAudience.js";
5
+ import { WizardDropped } from "./internal/wizardGate.js";
6
+ import { Effect, Option, Stdio } from "effect";
7
+ import { Audience, TerminalEnv } from "@effected/env";
8
+ import { CliConfig, CliError, Command, Flag, GlobalFlag } from "effect/cli";
9
+
10
+ // src/CliAudience.ts
11
+ const KINDS = [
12
+ "human",
13
+ "agent",
14
+ "ci"
15
+ ];
16
+ /**
17
+ * Whether the run may prompt once a flag has named the audience.
18
+ *
19
+ * Only a `human` audience prompts, and only with a terminal on both standard input and standard output and a `TERM`
20
+ * that is not `dumb` (`canPrompt`, the decision `CliInteractive.layer` makes). With the
21
+ * terminal facts in the environment (`TerminalEnv`) that is decided from them, not from the ambient value, so a
22
+ * flag can WIDEN: `--human` under a detected agent on real terminals prompts, and in a pipe it still cannot. With no
23
+ * `TerminalEnv` there is nothing to decide from and the flag only narrows.
24
+ */
25
+ const interactiveWhenFlagged = (kind, current) => Effect.flatMap(Effect.serviceOption(TerminalEnv), (terminal) => {
26
+ if (kind !== "human") return Effect.succeed(false);
27
+ return Option.isSome(terminal) ? canPrompt(terminal.value) : Effect.succeed(current);
28
+ });
29
+ const CONFLICT = "Give at most one of --audience, --human, --agent, --ci (once).";
30
+ /** Resolve the four flags into the audience to provide, failing when more than one occurrence was given. */
31
+ const resolve = (input) => {
32
+ const { given: named, conflict } = tallyAudience(input);
33
+ if (conflict) return Effect.fail(new CliError.UserError({
34
+ cause: /* @__PURE__ */ new Error(CONFLICT),
35
+ userMessage: CONFLICT
36
+ }));
37
+ const [kind] = named;
38
+ return kind === void 0 ? Audience : Effect.succeed({
39
+ kind,
40
+ source: "flag"
41
+ });
42
+ };
43
+ /**
44
+ * The audience flags of a CLI: `--audience <human|agent|ci>` and the shorthands `--human`, `--agent`, `--ci`,
45
+ * resolved into `@effected/env`'s `Audience`.
46
+ *
47
+ * @remarks
48
+ * The one wiring: share the flags on the root command and hand the root to {@link CliAudience.run} (or
49
+ * {@link CliAudience.runWith}), which resolves the flags from argv before core parses and applies
50
+ * {@link CliAudience.provide} itself:
51
+ *
52
+ * ```ts
53
+ * const root = Command.make("tool").pipe(
54
+ * Command.withSharedFlags(CliAudience.flags()),
55
+ * Command.withSubcommands([verify]),
56
+ * )
57
+ * NodeRuntime.runMain(
58
+ * CliRuntime.main(CliAudience.run(root, { version }), { platform: NodeServices.layer, env: {} }),
59
+ * )
60
+ * ```
61
+ *
62
+ * Core lists a shared flag in the help of every subcommand, not only at the root: that is upstream (Effect-TS/effect
63
+ * issue 8642), and `flags({ hidden: true })` is the way to keep them out of help altogether.
64
+ *
65
+ * A root that forgot `Command.withSharedFlags(CliAudience.flags())` does not compile. Giving more than one
66
+ * occurrence across the four flags is a usage error even when they agree; a boolean set to false (`--no-agent`,
67
+ * `--agent=false`) counts as not given. A bad `--audience` value is core's own parse error. Both exit `64` under
68
+ * `CliRuntime.main`. A conflicting audience together with `--help` exits `0` and prints help, because core handles
69
+ * its action flags before the resolver runs.
70
+ *
71
+ * A flag decides `CliInteractive` from the audience it names and the terminal facts: `--human` is interactive when
72
+ * `TerminalEnv` says there is a terminal on stdin and on stdout, even where the environment detected an agent, so a
73
+ * person running the tool inside an agent can ask for the human experience; in a pipe it still cannot prompt. A
74
+ * non-human flag (`--agent`, `--ci`, `--audience agent|ci`) turns it off and drops `--wizard`, and switches
75
+ * diagnostics to NDJSON, for the whole run including the parse step where a fallback prompt fires. Without
76
+ * `TerminalEnv` in the environment a flag only narrows. `--wizard` follows the decision: a run a flag makes
77
+ * interactive gets it back where the environment's gate had dropped it, and only there: a consumer's own `builtIns`
78
+ * without it stay without it.
79
+ * {@link CliAudience.provide} on its own, the path for a bare `Command.run`, acts only on the subcommand handler,
80
+ * because core parses the root flags into a local context before any of it is visible.
81
+ *
82
+ * @public
83
+ */
84
+ var CliAudience = class CliAudience {
85
+ constructor() {}
86
+ /**
87
+ * The four flags, for `Command.withSharedFlags` on the root command.
88
+ *
89
+ * @remarks
90
+ * Each is repeatable, so every occurrence is counted; a boolean given as `false` (`--agent=false`,
91
+ * `--no-agent`) is not an occurrence. Core lists shared flags in every command's help; pass
92
+ * `hidden` to remove them from all of them.
93
+ */
94
+ static flags = (options) => {
95
+ const hide = options?.hidden === true;
96
+ const maybeHide = (flag) => hide ? Flag.withHidden(flag) : flag;
97
+ return {
98
+ audience: maybeHide(Flag.Literals("audience", KINDS).pipe(Flag.atLeast(0), Flag.withDescription("Who the output is for"))),
99
+ human: maybeHide(Flag.Boolean("human").pipe(Flag.atLeast(0), Flag.withDescription("Shorthand for --audience human"))),
100
+ agent: maybeHide(Flag.Boolean("agent").pipe(Flag.atLeast(0), Flag.withDescription("Shorthand for --audience agent"))),
101
+ ci: maybeHide(Flag.Boolean("ci").pipe(Flag.atLeast(0), Flag.withDescription("Shorthand for --audience ci")))
102
+ };
103
+ };
104
+ /**
105
+ * Resolve the flags before every subcommand handler and re-provide `Audience`.
106
+ *
107
+ * @remarks
108
+ * `CliAudience.run` and `runWith` apply this themselves, so a program run through them never needs it. Use it
109
+ * directly only with a bare `Command.run`, which leaves a fallback prompt blind to the flags (see the class
110
+ * remarks).
111
+ *
112
+ * A failure report is written outside the run, where the flag is not in force, so only `runWith` and `run` carry
113
+ * the flag's audience to it; with this on its own the report follows the environment's audience.
114
+ *
115
+ * Pipe it onto the composite root, after `withSubcommands`, since a parent's handler does not run when a
116
+ * subcommand is selected. With exactly one flag the audience is `{ kind, source: "flag" }`; with none the
117
+ * ambient `Audience` is read and provided back unchanged, so `Audience` stays in the requirement a handler
118
+ * reading it already has, and is added to a program whose handlers do not read it.
119
+ */
120
+ static provide = (command) => Command.provideEffect(command, Audience, (input) => resolve(input)).pipe(Command.provideEffect(CliInteractive, (input) => Effect.gen(function* () {
121
+ const current = yield* CliInteractive;
122
+ const [flagged] = tallyAudience(input).given;
123
+ return flagged === void 0 ? current : yield* interactiveWhenFlagged(flagged, current);
124
+ })));
125
+ /**
126
+ * Run a command the way `Command.runWith` does, with the audience flag resolved BEFORE core parses.
127
+ *
128
+ * @remarks
129
+ * It scans `argv` for the four audience flags first, then runs core around a provided `Audience` (when exactly
130
+ * one is given: `{ kind, source: "flag" }`) and a `CliInteractive` decided from it: `--human` is interactive when
131
+ * `TerminalEnv` reports a terminal on stdin and stdout and `TERM` is not `dumb` (it can turn prompting on under a detected agent), a
132
+ * non-human flag or a conflict makes it false. A fallback prompt fires while core parses, earlier than
133
+ * anything `CliAudience.provide` can reach, so `--agent init` on a terminal would otherwise still prompt. No
134
+ * flag leaves the ambient values untouched. A conflict still gets core's own usage error, exit `64`, from
135
+ * `CliAudience.provide`'s resolver.
136
+ *
137
+ * @param command - the composite root, with the flags shared and `CliAudience.provide` piped on
138
+ * @param config - the same `version` and `renderErrors` as core's
139
+ */
140
+ static runWith = (command, config) => {
141
+ const core = Command.runWith(CliAudience.provide(command), config);
142
+ return (argv) => {
143
+ const run = core(argv);
144
+ const { given, conflict } = scanAudience(argv);
145
+ const [kind] = given;
146
+ if (kind === void 0) return run;
147
+ const withAudience = conflict ? run : Effect.provideService(run, Audience, {
148
+ kind,
149
+ source: "flag"
150
+ });
151
+ return Effect.gen(function* () {
152
+ const current = yield* CliInteractive;
153
+ const ambient = yield* CliConfig.CliConfig;
154
+ if (!conflict) yield* refreshFailureTarget({
155
+ kind,
156
+ source: "flag"
157
+ });
158
+ const interactive = !conflict && (yield* interactiveWhenFlagged(kind, current));
159
+ const decided = Effect.provideService(withAudience, CliInteractive, interactive);
160
+ const hasWizard = ambient.builtIns.includes(GlobalFlag.Wizard);
161
+ if (!(interactive && !hasWizard && (yield* WizardDropped) === ambient) && !(!interactive && hasWizard)) return yield* decided;
162
+ const builtIns = interactive ? [...ambient.builtIns, GlobalFlag.Wizard] : ambient.builtIns.filter((flag) => flag !== GlobalFlag.Wizard);
163
+ return yield* Effect.provideService(decided, CliConfig.CliConfig, CliConfig.make({ builtIns }));
164
+ });
165
+ };
166
+ };
167
+ /**
168
+ * `Command.run` with the audience flag resolved before parsing: reads `Stdio.args` and calls
169
+ * {@link CliAudience.runWith}.
170
+ *
171
+ * @param command - the composite root
172
+ * @param config - the same `version` and `renderErrors` as core's
173
+ */
174
+ static run = (command, config) => Stdio.Stdio.use(({ args }) => Effect.flatMap(args, (argv) => CliAudience.runWith(command, config)(argv)));
175
+ };
176
+
177
+ export { CliAudience };
package/CliColor.js ADDED
@@ -0,0 +1,66 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { TerminalEnv } from "@effected/env";
3
+ import { CliOutput } from "effect/cli";
4
+
5
+ // src/CliColor.ts
6
+ /**
7
+ * Whether a CLI's output should carry ANSI colour, decided once and shared by
8
+ * everything that renders — help text, error output, and any rendered result.
9
+ *
10
+ * @remarks
11
+ * The decision is `@effected/env`'s `TerminalEnv.colorLevel("stdout")`, which
12
+ * follows Node's `getColorDepth` precedence: `FORCE_COLOR` first (`0` or an
13
+ * unrecognised value forces colour off; an empty value, `1` or `true` force basic colour, `2` 256 colours and
14
+ * `3` truecolor, even without a terminal), then a non-empty `NO_COLOR` or `NODE_DISABLE_COLORS` and
15
+ * `TERM=dumb`, then the TTY gate. `FORCE_COLOR` therefore beats `NO_COLOR`.
16
+ * The environment
17
+ * is read through the ambient `ConfigProvider`, never `process`, so a test
18
+ * swaps it with `Effect.provideService(ConfigProvider.ConfigProvider, ...)`;
19
+ * an ambient `TerminalEnv`, such as `TerminalEnv.layerTest`, answers instead
20
+ * when one is provided.
21
+ *
22
+ * @public
23
+ */
24
+ var CliColor = class CliColor {
25
+ constructor() {}
26
+ /**
27
+ * The decision, for passing to a pure renderer as a plain boolean.
28
+ *
29
+ * @public
30
+ */
31
+ static enabled = TerminalEnv.colorLevel("stdout").pipe(Effect.map((level) => level !== "none"));
32
+ /**
33
+ * Core's default `CliOutput.Formatter`, coloured by the same decision as
34
+ * {@link CliColor.enabled}, so help text, parse errors and rendered
35
+ * output never disagree on whether colour is on.
36
+ *
37
+ * @remarks
38
+ * `overrides` replaces individual methods of the default formatter — for
39
+ * example `formatVersion`, to append a "via `<carrier>`" line without
40
+ * losing the other defaults. Each call mints a fresh layer; bind the
41
+ * result to a `const` or the decision is re-read every time it is
42
+ * provided.
43
+ *
44
+ * `helpFlags` sets how subcommand help renders the flags a parent declared (`inheritedFlags`) and the
45
+ * program-wide built-ins (`globalFlags`): `"full"` (core's default), `"summary"` or `"none"`. It is passed to
46
+ * core's `defaultFormatter` beside the colour decision, so choosing a mode costs no colour.
47
+ *
48
+ * The `never` in its output does not mean it installs nothing.
49
+ * `CliOutput.Formatter` is a `Context.Reference`, whose key type is
50
+ * `never`, so this layer sets the formatter reference rather than
51
+ * providing a service. Every command it is provided to renders with the
52
+ * formatter it sets, and without it they fall back to core's default
53
+ * formatter.
54
+ *
55
+ * @public
56
+ */
57
+ static formatterLayer = (overrides = {}, helpFlags) => Layer.unwrap(Effect.map(CliColor.enabled, (colors) => CliOutput.layer({
58
+ ...CliOutput.defaultFormatter({
59
+ colors,
60
+ ...helpFlags
61
+ }),
62
+ ...overrides
63
+ })));
64
+ };
65
+
66
+ export { CliColor };
package/CliEnv.js ADDED
@@ -0,0 +1,101 @@
1
+ import { CliInteractive } from "./CliInteractive.js";
2
+ import { ambientLinksLayer } from "./CliLinks.js";
3
+ import { CliPrompt } from "./CliPrompt.js";
4
+ import { CliTheme } from "./CliTheme.js";
5
+ import { displayPathLayer } from "./internal/displayPath.js";
6
+ import { capabilitiesLayer } from "./internal/terminalCapabilities.js";
7
+ import { ConfigProvider, Layer, Option } from "effect";
8
+ import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
9
+
10
+ // src/CliEnv.ts
11
+ /**
12
+ * The environment services a CLI reads, built once and in the right order.
13
+ *
14
+ * @remarks
15
+ * Builds `CurrentRuntimeEnv`, `TerminalEnv`, `Audience`, `CliTheme` and `CliLinks`, and sets `CliInteractive` from them, then
16
+ * installs the two gates for the program: `CliPrompt.gateTerminal`, which replaces `Terminal` with a quiet one when
17
+ * the run is not interactive so no prompt runner ever attaches to stdin, and `CliPrompt.gateWizard`, which drops
18
+ * `--wizard` then. `TerminalEnv` is built from the real terminal first. The layer therefore also outputs
19
+ * `Terminal`, the gated one, and consumers never compose the gates themselves. A
20
+ * `Context.Reference`'s key type is `never`, so the layer's output type does not list `CliInteractive`: it sets
21
+ * the reference rather than providing a service. Every read of the environment goes through `Config` and
22
+ * degrades to "unset" when it fails, so building the layer does not fail on a bad provider; it fails only when
23
+ * `Stdio` or `Terminal` do.
24
+ *
25
+ * `CliLinks` reads `FileSystem` and `Path` from the surrounding context if it has them, and does not require them: a
26
+ * `.vscode/` directory is looked for, and a relative path resolved, only when the platform is provided OUTSIDE this
27
+ * layer, as `CliRuntime.main` does. Without them `auto` is `vscode` on the terminal signal alone.
28
+ *
29
+ * Not interactive, the gated `Terminal`'s `readLine` fails as a quit, its input is already ended and its `display`
30
+ * writes nothing. A program that reads piped data must read `Stdio.stdin`, and one that writes output must use
31
+ * `Console` or `Stdio`, never `Terminal`. It also installs `CliTheme.promptTheme`, so core's prompts follow the
32
+ * theme.
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
+ *
43
+ * @public
44
+ */
45
+ var CliEnv = class {
46
+ constructor() {}
47
+ /**
48
+ * The environment services for the terminal `Stdio` and `Terminal` describe.
49
+ *
50
+ * @remarks
51
+ * A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
52
+ *
53
+ * @param options - the audience env var, the stderr check and the theme options
54
+ */
55
+ static layer = (options = {}) => {
56
+ const base = Layer.mergeAll(CurrentRuntimeEnv.layer, TerminalEnv.layer(options.stderrIsTerminal === void 0 ? void 0 : { stderrIsTerminal: options.stderrIsTerminal }));
57
+ const withAudience = Audience.layer(options.audienceEnvVar === void 0 ? void 0 : { envVar: options.audienceEnvVar }).pipe(Layer.provideMerge(base));
58
+ const withTheme = CliTheme.layer(options.theme).pipe(Layer.provideMerge(withAudience));
59
+ const withLinks = ambientLinksLayer({
60
+ ...options.editorLinks === void 0 ? {} : { editorLinks: options.editorLinks },
61
+ ...options.editorLinksEnvVar === void 0 ? {} : { envVar: options.editorLinksEnvVar }
62
+ }).pipe(Layer.provideMerge(withTheme));
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));
66
+ };
67
+ /**
68
+ * The environment services a test fixes, needing nothing and reading nothing of the host's: `TerminalEnv` and
69
+ * `Audience` from the answers given, `CliTheme` built from them as {@link CliEnv.layer} builds it, and
70
+ * `CliInteractive` set from them by the same rule (a human, every stream a terminal, and a `TERM` that is not
71
+ * `dumb`), and core's `TerminalCapabilities` from them as `CliEnv.layer` sets it, `canPrompt` held at `true`.
72
+ *
73
+ * @remarks
74
+ * `term` is handed to the theme and interactivity builds alone, through a `ConfigProvider` of their own: the
75
+ * program under the layer keeps its own provider, and a host's `TERM` (a test runner in a dumb terminal) never
76
+ * decides. A screen or a live view also needs `UiStreams` from `@effected/cli-kit/ui`, which `CliUiTest` provides; this
77
+ * layer provides no `Terminal` and installs neither of `CliEnv.layer`'s prompt gates.
78
+ *
79
+ * A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
80
+ *
81
+ * @param options - whether the streams are terminals, the `TERM`, the audience, the width, the colour and the theme
82
+ */
83
+ static layerTest = (options = {}) => {
84
+ const tty = options.tty ?? false;
85
+ const stream = {
86
+ isTerminal: tty,
87
+ color: options.color ?? "none",
88
+ columns: options.columns === void 0 ? Option.none() : Option.some(options.columns)
89
+ };
90
+ const facts = Layer.mergeAll(TerminalEnv.layerTest({
91
+ stdinIsTerminal: tty,
92
+ stdout: stream,
93
+ stderr: stream
94
+ }), Audience.layerTest(options.audience ?? "human"));
95
+ const term = ConfigProvider.layer(ConfigProvider.fromUnknown(options.term === void 0 ? {} : { TERM: options.term }));
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));
98
+ };
99
+ };
100
+
101
+ export { CliEnv };
package/CliExit.js ADDED
@@ -0,0 +1,117 @@
1
+ import { Fmt } from "./Fmt.js";
2
+ import { isExitCode } from "./internal/isExitCode.js";
3
+ import { Cause, Console, Context, Effect, Layer, MutableRef, Predicate } from "effect";
4
+
5
+ // src/CliExit.ts
6
+ /**
7
+ * The exit code a successful run wants, for commands whose findings are a
8
+ * result rather than a failure (a linter that found problems, say).
9
+ *
10
+ * @remarks
11
+ * Findings are not a failure — a JSON `tapError` must not fire, and the
12
+ * handler must return normally — yet the process must exit non-zero. Writing
13
+ * `process.exitCode` works only because Node's `runMain` skips
14
+ * `process.exit(0)` on success; `process.exit(n)` in a handler skips every
15
+ * finalizer. `CliRuntime.main` reads this cell after the program
16
+ * succeeds and turns a non-zero code into a marked failure the runtime's
17
+ * teardown honours, on any runtime, with finalizers intact.
18
+ *
19
+ * A `Service`, not a `Reference`: forgetting `CliRuntime.main` is a type error,
20
+ * never a silently ignored global.
21
+ *
22
+ * @public
23
+ */
24
+ var CliExit = class CliExit extends Context.Service()("@effected/cli-kit/CliExit") {
25
+ /**
26
+ * A fresh cell at `0`; `CliRuntime.main` provides it, and tests provide it
27
+ * directly.
28
+ *
29
+ * @remarks
30
+ * Every provide mints a new cell. Layers memoize by reference across
31
+ * `Effect.provide` calls, so without `Layer.fresh` a second provide of this
32
+ * layer anywhere in the program — a nested `CliRuntime.main`, a test
33
+ * helper — would silently share the first run's cell and inherit its code.
34
+ *
35
+ * A program run under `CliRuntime.main` must NOT provide `CliExit.layer`
36
+ * itself: `main` already provides one, and a second provide mints a second,
37
+ * unrelated cell that `main` never reads back, so `CliExit.set` calls made
38
+ * against it are silently discarded and the run exits `0`.
39
+ */
40
+ static layer = Layer.fresh(Layer.sync(this, () => ({ code: MutableRef.make(0) })));
41
+ /**
42
+ * Record an exit code; the highest code set during the run wins.
43
+ *
44
+ * @remarks
45
+ * Highest-wins, not last-wins, so a later "clean" step cannot quietly
46
+ * downgrade an earlier finding's code.
47
+ *
48
+ * The code must be an integer in `0..255` — the range a POSIX exit status
49
+ * can carry. Anything else dies as a defect naming the value: `256` would
50
+ * wrap to exit `0` and silently pass a run with findings, and a fraction
51
+ * such as `1.5` makes `process.exit` throw `ERR_OUT_OF_RANGE` after the
52
+ * program has finished.
53
+ *
54
+ * The code only applies to a run that **succeeds**. A program failure beats
55
+ * findings: when the program fails, `CliRuntime.main` never reads this
56
+ * cell, and the failure's own exit code (or the `exitCode` fallback) wins.
57
+ */
58
+ static set = (code) => Effect.gen(function* () {
59
+ if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliExit.set: exit code must be an integer 0..255, received ${code}`));
60
+ const exit = yield* CliExit;
61
+ if (code > MutableRef.get(exit.code)) MutableRef.set(exit.code, code);
62
+ });
63
+ /**
64
+ * Write one line to stderr, record an exit code, and succeed: the shape of a
65
+ * hook-style command that must say why it exits non-zero without failing.
66
+ *
67
+ * @remarks
68
+ * This does **not** stop the program. It succeeds, so the caller must still
69
+ * return (or otherwise end its own flow) after calling it; finalizers and log
70
+ * sinks run as they would on any successful run, and `CliRuntime.main` turns
71
+ * the recorded code into the exit.
72
+ *
73
+ * The code is validated first, exactly as {@link CliExit.set} validates it, and
74
+ * an invalid code dies as the same defect before anything is written. The
75
+ * line goes through `Console.error`, never stdout, and is written as given:
76
+ * sanitize anything you do not control with `Fmt.sanitize` first. The
77
+ * highest code recorded during the run wins.
78
+ */
79
+ static report = (code, line) => Effect.gen(function* () {
80
+ yield* CliExit.set(code);
81
+ yield* Console.error(line);
82
+ });
83
+ /**
84
+ * Report a failure cause as one stderr line, record the exit code the caller
85
+ * chooses for it, and succeed.
86
+ *
87
+ * @remarks
88
+ * The cause is squashed and the line is `<code> <tag>: <message>`, or
89
+ * `<code> <message>` when the error has no string `_tag`. The message comes
90
+ * from `Fmt.errorText` and is sanitized with `Fmt.sanitize` and its line breaks
91
+ * become a space, so a hostile error can neither throw, write an escape nor
92
+ * forge a second line. Reading `_tag` is guarded
93
+ * too: a getter that throws means no tag.
94
+ *
95
+ * An interrupt-only cause is not a failure to report: nothing is written or
96
+ * recorded and the effect re-interrupts. `codeOf` receives the squashed error
97
+ * and must return an integer in `0..255`; anything else dies as
98
+ * {@link CliExit.set} does, which is wiring.
99
+ *
100
+ * Like {@link CliExit.report} it does not stop the program: the caller still
101
+ * has to return.
102
+ */
103
+ static reportCause = (cause, codeOf) => Cause.hasInterruptsOnly(cause) ? Effect.interrupt : Effect.suspend(() => {
104
+ const error = Cause.squash(cause);
105
+ const code = codeOf(error);
106
+ let tag;
107
+ try {
108
+ const read = Predicate.hasProperty(error, "_tag") ? error._tag : void 0;
109
+ tag = Predicate.isString(read) ? read : void 0;
110
+ } catch {}
111
+ const oneLine = (text) => Fmt.sanitize(text).replace(/\r?\n|\r/gu, " ");
112
+ const message = oneLine(Fmt.errorText(error));
113
+ return CliExit.report(code, tag === void 0 ? `${code} ${message}` : `${code} ${oneLine(tag)}: ${message}`);
114
+ });
115
+ };
116
+
117
+ export { CliExit };