@effected/cli-kit 0.0.0-stage → 0.26.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 +59 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +64 -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 +206 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +63 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +335 -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 +215 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +101 -0
  29. package/Token.js +68 -0
  30. package/index.d.ts +3951 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +35 -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 +175 -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 +90 -5
  61. package/testing.d.ts +246 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +507 -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 +147 -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 +1990 -0
  103. package/ui.js +17 -0
package/CliRuntime.js ADDED
@@ -0,0 +1,254 @@
1
+ import { Cancelled } from "./Cancelled.js";
2
+ import { CliColor } from "./CliColor.js";
3
+ import { CliEnv } from "./CliEnv.js";
4
+ import { CliExit } from "./CliExit.js";
5
+ import { CliLog, envBuildLogLayer, platformLogLayer } from "./CliLog.js";
6
+ import { CliLogger, makeCliLogger } from "./CliLogger.js";
7
+ import { sanitize } from "./Fmt.js";
8
+ import { ExitRequested } from "./internal/ExitRequested.js";
9
+ import { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, readSpans, refreshFailureTarget } from "./internal/failureTarget.js";
10
+ import { routeHelpOnUsageError } from "./internal/HelpRouting.js";
11
+ import { failureText, tagOf } from "./internal/inspect.js";
12
+ import { isExitCode } from "./internal/isExitCode.js";
13
+ import { TrustedLine } from "./internal/logSafety.js";
14
+ import { NotInteractive } from "./NotInteractive.js";
15
+ import { Cause, Effect, Layer, Logger, MutableRef, Option, Runtime } from "effect";
16
+ import { CliError, CliOutput } from "effect/cli";
17
+ import { CommandNeutralizer } from "@effected/github-commands";
18
+ import { withDistribution, withDistributionVersion } from "@effected/kit";
19
+
20
+ // src/CliRuntime.ts
21
+ const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
22
+ /** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
23
+ const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
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
+ }));
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]")));
43
+ const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
44
+ /**
45
+ * The error's own exit code when it carries one, otherwise the fallback.
46
+ *
47
+ * @remarks
48
+ * `Runtime.getErrorExitCode` cannot serve alone here: it answers `1` both for
49
+ * an error marked `1` and for an unmarked one, so an `exitCode` option would
50
+ * silently override a deliberate `1`. Testing for the marker keeps "the error
51
+ * chose its code" distinct from "nothing chose".
52
+ */
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
+ };
64
+ /**
65
+ * Report a CLI program's failures through the program's own logger.
66
+ *
67
+ * @remarks
68
+ * ## What it prevents
69
+ *
70
+ * A platform `runMain` reports an unhandled failure using Effect's **default**
71
+ * logger. That logger sits **outside** the layers the program was provided —
72
+ * `makeRunMain` composes its reporting `tapCause` around the already-provided
73
+ * effect — so a program that carefully installs {@link CliLogger} still prints
74
+ * its failures in the structured format that logger exists to replace, and
75
+ * prints them on **stdout**, the one stream errors must not use.
76
+ *
77
+ * Nothing about the call site suggests this. The program looks correct, the
78
+ * logger is installed, every success path is right, and only a failure reveals
79
+ * it.
80
+ *
81
+ * ## Why a combinator, and not a `runMain`
82
+ *
83
+ * The fix has to happen **inside** the effect, before any `runMain` sees it. So
84
+ * this is a combinator you apply to your program, and you still call your own
85
+ * platform's runner. Wrapping `runMain` itself would drag a platform choice into a library that
86
+ * has no business making one, and would make this package unusable from Bun or
87
+ * Deno for no gain.
88
+ *
89
+ * ## What it does with the failure
90
+ *
91
+ * Renders it through the ambient logger, then **re-fails with a marked error**
92
+ * so the runtime still sees a failure — the exit code is the runtime's to set,
93
+ * and swallowing the failure would make a broken run exit `0`.
94
+ *
95
+ * The marks are core's own, and they are what make this work without a platform
96
+ * import:
97
+ *
98
+ * - `Runtime.errorExitCode` — `defaultTeardown` takes the squashed error's
99
+ * value as the process exit code. An error that already carries one **keeps
100
+ * it**: that was a deliberate choice by whatever raised it, and flattening
101
+ * every failure to a single code would discard it.
102
+ * - `Runtime.errorReported` — set to `false`, which suppresses the runtime's own
103
+ * duplicate report. **The polarity is inverted relative to the name**: the
104
+ * marker means "should this be reported", so the intuitive
105
+ * `errorReported: true` — "I have reported it, stay quiet" — produces exactly
106
+ * the double report it was meant to prevent.
107
+ *
108
+ * An **interrupt is left alone**: it is not a failure to report, and the
109
+ * default teardown already maps an interrupt-only cause to `130`.
110
+ *
111
+ * A `CliError.ShowHelp` is never rendered: `Command.runWith` already printed
112
+ * the help text (and any parse errors) before re-failing with it, so
113
+ * rendering it again would print nothing but a stray "Help requested" line.
114
+ * A `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`,
115
+ * BSD `EX_USAGE`); a bare `--help` or root invocation — `errors` empty —
116
+ * keeps exit `0`. A `CliError.UserError` whose `Runtime.errorReported` mark
117
+ * is `false` is likewise skipped: that is how `Command.runWith` leaves one it
118
+ * already rendered through its `CliOutput` formatter. It exits with its own
119
+ * `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
120
+ * under runWith's `renderErrors: false` the mark stays set, and it renders
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
125
+ * error renders even when it already carries the reported mark — a gate
126
+ * failure marked with `CliRuntime.reported` still prints its line. The
127
+ * private `ExitRequested` sentinel `CliRuntime.main`
128
+ * raises is likewise never rendered; it only carries the exit code a
129
+ * successful program recorded through `CliExit`.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * import { CliRuntime } from "@effected/cli-kit"
134
+ * import { NodeRuntime } from "@effect/platform-node"
135
+ * import { Effect } from "effect"
136
+ *
137
+ * NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)))
138
+ * ```
139
+ *
140
+ * @public
141
+ */
142
+ var CliRuntime = class CliRuntime {
143
+ constructor() {}
144
+ /**
145
+ * What `reportFailures` and `main` render a failure as when no `render` option is given, for a consumer's own
146
+ * `render` to hand a failure back to.
147
+ *
148
+ * @remarks
149
+ * The plain lines of `CliFailure.toDoc(details.cause)`: a failure status line, a `Tree` for a schema failure, a
150
+ * defect's message with its cleaned `stack`, and the one fixed line each for `Cancelled` and `NotInteractive`.
151
+ * It has no terminal to ask, so it is the plain rendering for an agent, with absolute paths; the report `main` writes
152
+ * with no `render` option is the same document in the renderer the audience gets (painted for a person), and that
153
+ * report is `details.defaultLines`: return those to hand a failure back with the run's colour, links and path
154
+ * display. With `status: false` the leading status (the glyph, or `[FAIL]` in plain text) is left off, so a prefix
155
+ * such as the program's name reads cleanly; for that AND the run's settings, use `details.lines({ status: false })`.
156
+ * A custom `render` that only cares about its own errors delegates the rest:
157
+ *
158
+ * ```ts
159
+ * const render = (error: unknown, details: FailureDetails) =>
160
+ * error instanceof MyError ? myLines(error) : CliRuntime.defaultRender(error, details)
161
+ * ```
162
+ *
163
+ * @param error - the squashed failure
164
+ * @param details - what `render` is told about the failure; accepted so a delegating `render` passes both
165
+ * arguments through unchanged (only `cause` and `isDefect` are read)
166
+ * @param options - `status: false` leaves off the leading status glyph or `[FAIL]` tag
167
+ */
168
+ static defaultRender = (error, details, options) => plainFailureLines(details.cause.reasons.length > 0 ? details.cause : Cause.fail(error), options?.status !== false);
169
+ /**
170
+ * Catch a program's failure, render it through the ambient logger, and re-fail with the exit code and the
171
+ * no-double-report mark.
172
+ *
173
+ * @param options - how to render the failure and which exit codes to use
174
+ */
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) => {
191
+ try {
192
+ return linesOf(cause, target, status, spans ?? target.spans);
193
+ } catch {
194
+ try {
195
+ return plainFailureLines(cause, status, spans ?? target.spans);
196
+ } catch {
197
+ return lastResort(error);
198
+ }
199
+ }
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
+ };
219
+ static main(program, options) {
220
+ const env = options.env === void 0 ? void 0 : CliEnv.layer(options.env);
221
+ const envLog = options.env?.log;
222
+ const logger = options.logger ?? (env === void 0 || envLog === void 0 ? CliLogger.layer() : CliLog.layer(envLog).pipe(Layer.provide(env), Layer.provide(Layer.provideMerge(options.platform.pipe(Layer.provide(platformLogLayer(envLog, options.env?.audienceEnvVar))), envBuildLogLayer(envLog, options.env?.audienceEnvVar))), Layer.catchCause(() => CliLogger.layer(envLog.logger))));
223
+ const inside = env === void 0 ? Layer.empty : Layer.mergeAll(CliColor.formatterLayer(options.env?.formatter), Layer.effectDiscard(Effect.gen(function* () {
224
+ const { spans, invalid } = yield* readSpans(options.env?.spans, options.env?.spansEnvVar);
225
+ if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([makeCliLogger(envLog?.logger)])));
226
+ yield* refreshFailureTarget(void 0, {
227
+ stackFrames: options.env?.stackFrames,
228
+ spans,
229
+ appModule: options.env?.appModule
230
+ });
231
+ }))).pipe(Layer.provideMerge(env));
232
+ const carried = options.distribution === void 0 ? program : withCarrier(program, options.distribution);
233
+ const run = Effect.gen(function* () {
234
+ yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(carried) : carried;
235
+ const exit = yield* CliExit;
236
+ const code = MutableRef.get(exit.code);
237
+ if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
238
+ if (code !== 0) return yield* Effect.fail(new ExitRequested(code));
239
+ }).pipe(Effect.provide(CliExit.layer), Effect.provide(inside), Effect.provide(options.platform), CliRuntime.reportFailures(options), Effect.provide(logger));
240
+ return Effect.suspend(() => Effect.provideService(run, FailureTargetCell, MutableRef.make(void 0)));
241
+ }
242
+ static reported(error, exitCode = 1) {
243
+ const marked = error instanceof Error ? error : new Error(String(error));
244
+ for (const [key, value] of [[Runtime.errorReported, false], [Runtime.errorExitCode, exitCode]]) Object.defineProperty(marked, key, {
245
+ value,
246
+ writable: true,
247
+ configurable: true,
248
+ enumerable: false
249
+ });
250
+ return marked;
251
+ }
252
+ };
253
+
254
+ export { CliRuntime };
package/CliTest.js ADDED
@@ -0,0 +1,206 @@
1
+ import { Fmt } from "./Fmt.js";
2
+ import { Duration, Effect, Fiber, FileSystem, Option, Path, Stream, SubscriptionRef } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
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
+ });
55
+ const text = (stream) => Stream.mkString(Stream.decodeText(stream));
56
+ /**
57
+ * Spawn a built CLI bin hermetically and read its exit code and streams as data.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * import * as NodeServices from "@effect/platform-node/NodeServices"
62
+ * import { assert, it } from "@effect/vitest"
63
+ * import { CliTest } from "@effected/cli-kit/testing"
64
+ * import { Effect } from "effect"
65
+ *
66
+ * it.effect("prints its version", () =>
67
+ * Effect.gen(function* () {
68
+ * const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" })
69
+ * const result = yield* CliTest.run("dist/bin.js", ["--version"], { sandbox, execPath: process.execPath })
70
+ * assert.strictEqual(result.exitCode, 0)
71
+ * }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
72
+ * )
73
+ * ```
74
+ *
75
+ * @public
76
+ */
77
+ var CliTest = class {
78
+ constructor() {}
79
+ /**
80
+ * A scoped temp directory with a fresh `HOME` and XDG tree, `NO_COLOR=1`,
81
+ * and the `PATH` you pass — the host environment is never inherited.
82
+ *
83
+ * @remarks
84
+ * Each call mints a fresh temp directory; bind the result to a `const`
85
+ * within one test rather than calling this more than once per assertion.
86
+ */
87
+ static sandbox = (options) => Effect.gen(function* () {
88
+ const fs = yield* FileSystem.FileSystem;
89
+ const path = yield* Path.Path;
90
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "effected-cli-test-" });
91
+ const home = path.join(root, "home");
92
+ const xdg = {
93
+ XDG_CONFIG_HOME: path.join(home, ".config"),
94
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
95
+ XDG_STATE_HOME: path.join(home, ".local", "state"),
96
+ XDG_CACHE_HOME: path.join(home, ".cache")
97
+ };
98
+ for (const dir of Object.values(xdg)) yield* fs.makeDirectory(dir, { recursive: true });
99
+ return {
100
+ root,
101
+ home,
102
+ env: {
103
+ HOME: home,
104
+ ...xdg,
105
+ PATH: options.path,
106
+ NO_COLOR: "1"
107
+ }
108
+ };
109
+ });
110
+ /**
111
+ * Run `execPath bin ...args`; a non-zero exit is returned, never failed.
112
+ *
113
+ * @remarks
114
+ * `stdin` is never left as an inherited open pipe: when omitted or `""`
115
+ * the child gets an already-ended empty input (`Stream.empty`), so a
116
+ * stdin-reading bin exits instead of hanging on `effect/process`'s
117
+ * default `"pipe"` stdio, which stays open until something writes to and
118
+ * ends it.
119
+ */
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
+ };
202
+ });
203
+ };
204
+ };
205
+
206
+ export { CliTest };
package/CliTheme.js ADDED
@@ -0,0 +1,142 @@
1
+ import { Glyphs } from "./Glyphs.js";
2
+ import { openSequence, paintStyle } from "./internal/ansi.js";
3
+ import { Token } from "./Token.js";
4
+ import { Config, Context, Effect, Layer, Option } from "effect";
5
+ import { TerminalEnv } from "@effected/env";
6
+ import { Prompt } from "effect/cli";
7
+
8
+ // src/CliTheme.ts
9
+ /**
10
+ * A stream theme at `color`, over a style resolution and a glyph set: the one way a {@link StreamTheme} is built.
11
+ *
12
+ * @internal
13
+ */
14
+ const streamThemeAt = (resolve, glyphs, color) => {
15
+ const paint = (token, text) => paintStyle(resolve(token), color, text);
16
+ return {
17
+ paint,
18
+ style: resolve,
19
+ sgr: (token) => openSequence(resolve(token), color),
20
+ glyphs,
21
+ color,
22
+ status: (vocab, name, text) => {
23
+ const glyph = paint(vocab.def(name).token, vocab.glyph(name, glyphs));
24
+ return text === void 0 || text === "" ? glyph : `${glyph} ${text}`;
25
+ }
26
+ };
27
+ };
28
+ /** The audience rule, shared by `CliTheme.forAudience` and the class's own docs. */
29
+ const forAudience = (theme, audience) => audience === "agent" && theme.color !== "none" ? streamThemeAt(theme.style, theme.glyphs, "none") : theme;
30
+ const make = (colors, glyphs, overrides) => {
31
+ const resolve = (token) => Token.resolve(token, overrides);
32
+ const stdout = streamThemeAt(resolve, glyphs, colors.stdout);
33
+ const stderr = streamThemeAt(resolve, glyphs, colors.stderr);
34
+ return {
35
+ ...stdout,
36
+ forStream: (stream) => stream === "stdout" ? stdout : stderr
37
+ };
38
+ };
39
+ /** The prompt glyphs that differ under ASCII; `Prompt.makeTheme` already holds the Unicode ones. */
40
+ const ASCII_PROMPT_GLYPHS = {
41
+ prefix: "?",
42
+ arrowUp: "^",
43
+ arrowDown: "v",
44
+ checkboxOn: "[x]",
45
+ checkboxOff: "[ ]",
46
+ tick: "+",
47
+ pointerSmall: ">",
48
+ pointer: ">"
49
+ };
50
+ /**
51
+ * The presentation of a CLI: colour tokens, glyphs and statuses, decided once from the terminal.
52
+ *
53
+ * @remarks
54
+ * A `Context.Service`, not a `Reference`. A colour level is a fact about the terminal, not a preference with a
55
+ * safe default, so there is nothing sensible for an unprovided theme to read; requiring it puts `CliTheme` in
56
+ * `R` and a program that forgot to wire it fails to compile rather than printing plain text to a colour
57
+ * terminal. {@link CliTheme.layer} reads `TerminalEnv`.
58
+ *
59
+ * @public
60
+ */
61
+ var CliTheme = class CliTheme extends Context.Service()("@effected/cli-kit/CliTheme") {
62
+ /**
63
+ * The theme for the terminal `TerminalEnv` describes.
64
+ *
65
+ * @remarks
66
+ * Bind the layer to a constant and provide it once. With `glyphs: "auto"` the glyph set is ASCII only when
67
+ * `TERM=dumb`, read through `Config`.
68
+ *
69
+ * @param options - token overrides and the glyph set
70
+ */
71
+ static layer = (options) => Layer.effect(CliTheme, Effect.gen(function* () {
72
+ const terminal = yield* TerminalEnv;
73
+ const choice = options?.glyphs ?? "auto";
74
+ const term = choice === "auto" ? Option.getOrUndefined(yield* Config.option(Config.String("TERM")).pipe(Effect.orElseSucceed(() => Option.none()))) : void 0;
75
+ const glyphs = Glyphs.select({
76
+ ascii: choice === "ascii" ? true : choice === "unicode" ? false : "auto",
77
+ ...term === void 0 ? {} : { term }
78
+ });
79
+ return make({
80
+ stdout: terminal.stdout.color,
81
+ stderr: terminal.stderr.color
82
+ }, glyphs, options?.tokens);
83
+ }));
84
+ /**
85
+ * The theme an audience sees of `theme`: for an agent, the same theme at colour `none` (`paint` the identity, `sgr`
86
+ * empty, `status` unpainted), whatever the terminal could do, because an agent never gets an escape of any kind; for
87
+ * anyone else, or when the audience is not known, `theme` itself.
88
+ *
89
+ * @remarks
90
+ * The one rule the kit applies wherever it paints for an audience: `Render.context` takes its colour and `paint`
91
+ * from it, `CliMessage` and `CliLog.status` paint their glyphs through it, and `./ui` gives it to the trees it
92
+ * mounts, so `useTheme`, `Styled` and the widgets' colour-`none` text markers all agree. A program that paints its
93
+ * own lines applies the same rule with it rather than re-implementing it:
94
+ *
95
+ * ```ts
96
+ * const line = Effect.gen(function* () {
97
+ * const theme = CliTheme.forAudience((yield* CliTheme).forStream("stdout"), (yield* Audience).kind)
98
+ * return theme.status(Status.core, "success", Fmt.sanitize(name))
99
+ * })
100
+ * ```
101
+ *
102
+ * Pure: it reads nothing, so the audience is the caller's to pass, `undefined` when it is not known.
103
+ *
104
+ * @param theme - a stream's theme, such as `CliTheme.forStream("stdout")`
105
+ * @param audience - who the output is for, or `undefined` when that is not known
106
+ */
107
+ static forAudience = forAudience;
108
+ /**
109
+ * A fixed theme that needs nothing; `none` colour and Unicode glyphs unless told otherwise.
110
+ *
111
+ * @param options - the colour level and glyph set
112
+ */
113
+ static layerTest = (options) => Layer.succeed(CliTheme, make({
114
+ stdout: options?.color ?? "none",
115
+ stderr: options?.stderrColor ?? options?.color ?? "none"
116
+ }, options?.glyphs === "ascii" ? Glyphs.ascii : Glyphs.unicode, void 0));
117
+ /**
118
+ * Sets core's `Prompt.Theme` from the tokens, so built-in prompts match the rest of the output.
119
+ *
120
+ * @remarks
121
+ * The colour fields are raw SGR openers and are empty strings when colour is `none`, and `colors` is the theme's
122
+ * own decision (`false` at `none`), so core's prompts and its command wizard write no colour or style escape, not
123
+ * even a reset, and core never re-reads the host for `NO_COLOR` or a TTY. Cursor and redraw controls are still
124
+ * written, whatever the theme says. Under ASCII glyphs the prompt symbols fall back to ASCII too.
125
+ */
126
+ static promptTheme = Layer.effect(Prompt.Theme, Effect.gen(function* () {
127
+ const theme = yield* CliTheme;
128
+ const open = (token) => theme.sgr(token);
129
+ return Prompt.makeTheme({
130
+ ...theme.glyphs.kind === "ascii" ? ASCII_PROMPT_GLYPHS : {},
131
+ ellipsis: theme.glyphs.ellipsis,
132
+ colors: theme.color !== "none",
133
+ primaryColor: open("accent"),
134
+ mutedColor: open("muted"),
135
+ successColor: open("success"),
136
+ errorColor: open("error"),
137
+ submittedColor: open("emphasis")
138
+ });
139
+ }));
140
+ };
141
+
142
+ export { CliTheme, streamThemeAt };
@@ -0,0 +1,63 @@
1
+ import { formatIssueWithHints } from "./internal/format.js";
2
+
3
+ // src/ConfigIssueRenderer.ts
4
+ /**
5
+ * Turns a `@effected/config-file` `ConfigValidationError` into one line per rejected value.
6
+ *
7
+ * @remarks
8
+ * `ConfigValidationError` carries the structured `issue` tree rather than a
9
+ * string, so a caller holds a tree it has to turn into sentences. This is that
10
+ * step, the same treatment {@link SchemaIssueRenderer} gives a bare issue.
11
+ *
12
+ * `ConfigValidationError.message` names the file but not the value, and the
13
+ * value is the diagnostic: printing only the message tells a user their config
14
+ * is invalid without saying which value is wrong or how it is shaped.
15
+ *
16
+ * `@effected/config-file` is an optional peer, and this module only
17
+ * `import type`s it, so the import is erased at build time. A consumer without
18
+ * the package installed can import this module without the resolver being asked
19
+ * for it.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { ConfigIssueRenderer } from "@effected/cli-kit"
24
+ * import { Effect } from "effect"
25
+ *
26
+ * const load = configFile.load.pipe(
27
+ * Effect.catchTag("ConfigValidationError", (error) =>
28
+ * Effect.gen(function* () {
29
+ * yield* Effect.logError(String(error))
30
+ * for (const line of ConfigIssueRenderer.render(error)) yield* Effect.logError(` ${line}`)
31
+ * }),
32
+ * ),
33
+ * )
34
+ * ```
35
+ *
36
+ * @public
37
+ */
38
+ var ConfigIssueRenderer = class {
39
+ constructor() {}
40
+ /**
41
+ * One line per rejected value.
42
+ *
43
+ * @remarks
44
+ * Takes the **error**, not its `issue`, because that is what a `catchTag`
45
+ * hands you and because `issue` is typed `Schema.Defect`, which every call
46
+ * site would otherwise have to reach into. Inside
47
+ * `Effect.catchTag("ConfigValidationError", …)` the error is already this
48
+ * type.
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
+ *
56
+ * It cannot throw on a malformed value: the issue tree is validated by
57
+ * a guard before it is read, so a renderer on an error path never becomes the
58
+ * reason a program dies.
59
+ */
60
+ static render = (error) => formatIssueWithHints(error?.issue);
61
+ };
62
+
63
+ export { ConfigIssueRenderer };