@effected/cli 0.25.0 → 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 (116) hide show
  1. package/README.md +5 -208
  2. package/bin/effected.js +7 -0
  3. package/commands/root.js +14 -0
  4. package/commands/schema/LiveProgress.js +114 -0
  5. package/commands/schema/build.js +9 -0
  6. package/commands/schema/check.js +12 -0
  7. package/commands/schema/errors.js +121 -0
  8. package/commands/schema/execute.js +141 -0
  9. package/commands/schema/flags.js +24 -0
  10. package/commands/schema/index.js +14 -0
  11. package/commands/schema/validate.js +58 -0
  12. package/index.d.ts +85 -3906
  13. package/index.js +4 -26
  14. package/main.d.ts +51 -0
  15. package/main.js +61 -0
  16. package/package.json +18 -50
  17. package/render/DiffDoc.js +51 -0
  18. package/tsdoc-metadata.json +1 -1
  19. package/version.js +11 -0
  20. package/Cancelled.js +0 -43
  21. package/CliAudience.js +0 -177
  22. package/CliColor.js +0 -59
  23. package/CliEnv.js +0 -101
  24. package/CliExit.js +0 -64
  25. package/CliFailure.js +0 -287
  26. package/CliFormat.js +0 -124
  27. package/CliInteractive.js +0 -94
  28. package/CliLinks.js +0 -153
  29. package/CliLog.js +0 -367
  30. package/CliLogger.js +0 -91
  31. package/CliMessage.js +0 -79
  32. package/CliPrompt.js +0 -110
  33. package/CliRuntime.js +0 -254
  34. package/CliTest.js +0 -206
  35. package/CliTheme.js +0 -142
  36. package/ConfigIssueRenderer.js +0 -63
  37. package/Doc.js +0 -548
  38. package/Fmt.js +0 -335
  39. package/GithubAnnotation.js +0 -39
  40. package/Glyphs.js +0 -82
  41. package/NotInteractive.js +0 -41
  42. package/Render.js +0 -280
  43. package/SchemaIssueRenderer.js +0 -47
  44. package/Status.js +0 -165
  45. package/TestTerminal.js +0 -101
  46. package/Token.js +0 -68
  47. package/internal/ExitRequested.js +0 -21
  48. package/internal/HelpRouting.js +0 -35
  49. package/internal/ansi.js +0 -229
  50. package/internal/autoFormat.js +0 -33
  51. package/internal/canPrompt.js +0 -14
  52. package/internal/counts.js +0 -83
  53. package/internal/diagnostics.js +0 -31
  54. package/internal/displayPath.js +0 -46
  55. package/internal/displayWidth.js +0 -42
  56. package/internal/failureTarget.js +0 -195
  57. package/internal/fallbackAnswer.js +0 -17
  58. package/internal/fileSink.js +0 -61
  59. package/internal/format.js +0 -175
  60. package/internal/inspect.js +0 -40
  61. package/internal/isExitCode.js +0 -11
  62. package/internal/layout.js +0 -249
  63. package/internal/linkScheme.js +0 -29
  64. package/internal/linkTarget.js +0 -49
  65. package/internal/logSafety.js +0 -45
  66. package/internal/renderAnsi.js +0 -51
  67. package/internal/renderDoc.js +0 -322
  68. package/internal/renderGithubLog.js +0 -59
  69. package/internal/renderMarkdown.js +0 -367
  70. package/internal/renderPlain.js +0 -49
  71. package/internal/scanAudience.js +0 -105
  72. package/internal/splitFrame.js +0 -55
  73. package/internal/terminalCapabilities.js +0 -23
  74. package/internal/wizardGate.js +0 -17
  75. package/testing.d.ts +0 -246
  76. package/testing.js +0 -4
  77. package/ui/CliUi.js +0 -507
  78. package/ui/CliUiDetached.js +0 -36
  79. package/ui/CliUiLive.js +0 -436
  80. package/ui/CliUiRouteStream.js +0 -72
  81. package/ui/Confirm.js +0 -244
  82. package/ui/DocView.js +0 -76
  83. package/ui/KeyHelp.js +0 -61
  84. package/ui/KeyTable.js +0 -198
  85. package/ui/MultiSelect.js +0 -259
  86. package/ui/Select.js +0 -229
  87. package/ui/Tabs.js +0 -201
  88. package/ui/TextInput.js +0 -289
  89. package/ui/Toggle.js +0 -31
  90. package/ui/UiKey.js +0 -48
  91. package/ui/UiProvider.js +0 -62
  92. package/ui/UiStreams.js +0 -17
  93. package/ui/UiTheme.js +0 -147
  94. package/ui/Viewport.js +0 -203
  95. package/ui/internal/ErrorBoundary.js +0 -29
  96. package/ui/internal/Holder.js +0 -73
  97. package/ui/internal/ScreenContext.js +0 -51
  98. package/ui/internal/UiProviders.js +0 -38
  99. package/ui/internal/ink.js +0 -121
  100. package/ui/internal/inkChalk.js +0 -47
  101. package/ui/internal/inkConsole.js +0 -150
  102. package/ui/internal/lazyView.js +0 -73
  103. package/ui/internal/lineRouter.js +0 -70
  104. package/ui/internal/lineText.js +0 -18
  105. package/ui/internal/mountPermit.js +0 -15
  106. package/ui/internal/processStreams.js +0 -18
  107. package/ui/internal/renderOptions.js +0 -12
  108. package/ui/testing/CliUiTest.js +0 -792
  109. package/ui/testing/fakeStreams.js +0 -104
  110. package/ui/testing/terminalModel.js +0 -67
  111. package/ui-testing-serializer.d.ts +0 -12
  112. package/ui-testing-serializer.js +0 -32
  113. package/ui-testing.d.ts +0 -558
  114. package/ui-testing.js +0 -3
  115. package/ui.d.ts +0 -1990
  116. package/ui.js +0 -17
package/CliMessage.js DELETED
@@ -1,79 +0,0 @@
1
- import { underGithubActions } from "./internal/autoFormat.js";
2
- import { sanitize } from "./Fmt.js";
3
- import { CliTheme } from "./CliTheme.js";
4
- import { Status } from "./Status.js";
5
- import { Console, Effect } from "effect";
6
- import { Audience } from "@effected/env";
7
- import { CommandNeutralizer } from "@effected/github-commands";
8
-
9
- // src/CliMessage.ts
10
- /**
11
- * One-line status messages: a glyph and some text, themed for a person and plain for an agent.
12
- *
13
- * @remarks
14
- * Each line goes through `Console`, `log` for stdout and `error` for stderr, never through the logger, so no
15
- * log level can silence it. Only the glyph is painted; the text stays plain. An `agent` audience gets the glyph
16
- * and the text and never colour, even when the theme has colour. `success` and `info` go to stdout, `warning`
17
- * and `failure` to stderr.
18
- *
19
- * The text is whatever the caller supplies, so it is sanitised: escape sequences and control characters are removed
20
- * (a line break is kept as one, a tab becomes a space), as in a document. Under GitHub Actions, where
21
- * `CurrentRuntimeEnv` says so, a line the runner would read as a workflow command is neutralized as well. The glyph
22
- * comes from the vocabulary, sanitised too (`Status.glyph`), so a glyph built from data cannot inject an escape either.
23
- *
24
- * @public
25
- */
26
- var CliMessage = class CliMessage {
27
- constructor() {}
28
- /**
29
- * Print a status line from a vocabulary.
30
- *
31
- * @remarks
32
- * The stream defaults to stderr when the status ranks at or above `warning` in `vocab`, and stdout
33
- * otherwise, so a custom status follows its own rank: a `timeout` ranked 85 goes to stderr.
34
- *
35
- * @param vocab - the vocabulary the status belongs to
36
- * @param name - the status
37
- * @param text - the text after the glyph
38
- * @param options - the stream override
39
- */
40
- static status = (vocab, name, text, options) => Effect.gen(function* () {
41
- const theme = yield* CliTheme;
42
- const audience = yield* Audience;
43
- const def = vocab.def(name);
44
- const message = sanitize(text);
45
- const warning = vocab.def("warning").rank;
46
- const stream = options?.stream ?? (def.rank >= warning ? "stderr" : "stdout");
47
- const streamTheme = theme.forStream(stream);
48
- let line;
49
- line = CliTheme.forAudience(streamTheme, audience.kind).status(vocab, name, message);
50
- if (yield* underGithubActions) line = CommandNeutralizer.text(line);
51
- yield* stream === "stderr" ? Console.error(line) : Console.log(line);
52
- });
53
- /**
54
- * A success line, on stdout.
55
- *
56
- * @param text - the text after the glyph
57
- */
58
- static success = (text) => CliMessage.status(Status.core, "success", text);
59
- /**
60
- * An informational line, on stdout.
61
- *
62
- * @param text - the text after the glyph
63
- */
64
- static info = (text) => CliMessage.status(Status.core, "info", text);
65
- /**
66
- * A warning line, on stderr.
67
- *
68
- * @param text - the text after the glyph
69
- */
70
- static warning = (text) => CliMessage.status(Status.core, "warning", text);
71
- /**
72
- * A failure line, on stderr.
73
- *
74
- * @param text - the text after the glyph
75
- */
76
- static failure = (text) => CliMessage.status(Status.core, "failure", text);
77
- };
78
-
79
- export { CliMessage };
package/CliPrompt.js DELETED
@@ -1,110 +0,0 @@
1
- import { Cancelled } from "./Cancelled.js";
2
- import { CliInteractive } from "./CliInteractive.js";
3
- import { WizardDropped } from "./internal/wizardGate.js";
4
- import { answerWithoutPerson } from "./internal/fallbackAnswer.js";
5
- import { Context, Effect, Layer, Queue, Terminal } from "effect";
6
- import { CliConfig, GlobalFlag, Prompt } from "effect/cli";
7
-
8
- // src/CliPrompt.ts
9
- /**
10
- * Prompts that know whether there is a person to ask.
11
- *
12
- * @public
13
- */
14
- var CliPrompt = class {
15
- constructor() {}
16
- /**
17
- * A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that only prompts when the run is
18
- * interactive.
19
- *
20
- * @remarks
21
- * Interactive (`CliInteractive`): the prompt runs and its answer is used. Not interactive: `otherwise` is used
22
- * when given, and the terminal is never read; without it the parameter fails as missing, exactly as it would
23
- * with no fallback, so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with
24
- * `flag` (the name without dashes, as given to `Flag.String`) or `argument` so that error can be built.
25
- *
26
- * Why the prompt runs inside this function rather than being handed back to core: core's
27
- * `withFallbackPrompt` turns a quit, such as Ctrl-C, into the original missing-parameter error, which would
28
- * exit `64` as if the flag had been forgotten. Here a quit is `Cancelled` with reason `interrupt`, exit `130`.
29
- * It is raised as a defect because core's parse step turns every typed failure into a usage error. Core then
30
- * runs the already-answered `Prompt.succeed` it is handed. Because it travels as a defect, a handler's
31
- * `Effect.catchTag("Cancelled", ...)` cannot see it, and only `CliRuntime.main` (or
32
- * `CliRuntime.reportFailures`) renders it as one line with exit `130`; under a bare `runMain` it prints a
33
- * stack.
34
- *
35
- * Core runs `Prompt.run` on the answered prompt it is handed, and an answered prompt never reads the terminal's
36
- * input (core acquires it only when a prompt needs a key), so piped stdin is left untouched without any gate.
37
- * `CliPrompt.gateTerminal`, which `CliEnv.layer` installs, covers the prompts this function does not wrap.
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
- *
46
- * @param prompt - the prompt to show
47
- * @param options - the parameter it stands in for, and the non-interactive default
48
- */
49
- static fallback = (prompt, options) => Effect.gen(function* () {
50
- if (yield* CliInteractive) {
51
- const answer = yield* Prompt.run(prompt).pipe(Effect.catchTag("QuitError", () => Effect.die(new Cancelled({ reason: "interrupt" }))));
52
- return Prompt.succeed(answer);
53
- }
54
- return yield* answerWithoutPerson(options);
55
- });
56
- /**
57
- * Gates core's `Terminal` on `CliInteractive`, so a run that is not interactive never touches the real one.
58
- *
59
- * @remarks
60
- * A prompt that needs a key reads the terminal's input, and the real Node terminal then attaches a readline to
61
- * stdin, which drops piped input and puts a TTY stdin into raw mode. Core's `withFallbackPrompt`, a `Prompt.run`
62
- * in a handler and the command wizard all do so whether or not anyone is there to answer. Not interactive, this
63
- * terminal's input is an already-ended queue, its `readLine` fails as quit and its `display` writes nothing, so
64
- * any such prompt is quit at once and the real terminal is never read. Its `columns`, `rows` and `size` always come
65
- * from the real one, so layout keeps working. Interactive, every call passes through to the real terminal.
66
- *
67
- * The decision is made on every call, not when the layer is built, so a scope that narrows `CliInteractive`
68
- * later, such as an audience flag under `CliAudience.runWith`, gates it too.
69
- *
70
- * It requires the real `Terminal`. `CliEnv.layer` installs it, after `TerminalEnv` is built from the real
71
- * terminal, so consumers do not compose it.
72
- */
73
- static gateTerminal = Layer.effect(Terminal.Terminal, Effect.gen(function* () {
74
- const real = yield* Terminal.Terminal;
75
- const endedInput = Effect.map(Queue.unbounded(), (queue) => {
76
- Queue.endUnsafe(queue);
77
- return queue;
78
- });
79
- return Terminal.make({
80
- columns: real.columns,
81
- rows: real.rows,
82
- size: real.size,
83
- readInput: Effect.flatMap(CliInteractive, (interactive) => interactive ? real.readInput : endedInput),
84
- readLine: Effect.flatMap(CliInteractive, (interactive) => interactive ? real.readLine : Effect.fail(new Terminal.QuitError({}))),
85
- display: (text) => Effect.flatMap(CliInteractive, (interactive) => interactive ? real.display(text) : Effect.void)
86
- });
87
- }));
88
- /**
89
- * Drops core's `--wizard` built-in flag when the run is not interactive.
90
- *
91
- * @remarks
92
- * The wizard prompts, so offering it without a terminal only leads to a dead end. With the flag gone, core
93
- * treats `--wizard` as an unknown flag, a usage error that exits `64`, and lists it nowhere in `--help`.
94
- * The layer reads `CliInteractive` and the ambient `CliConfig` when it is built, so provide those first, for
95
- * example `CliPrompt.gateWizard.pipe(Layer.provide(CliInteractive.layer))`. It filters the ambient `CliConfig`,
96
- * so a consumer's own `builtIns` survive, and returns it untouched when interactive. An audience flag read
97
- * later is covered by `CliAudience.runWith`, which drops the wizard too. When this layer removes the flag it records
98
- * that it did, so `runWith` puts it back only when a flag turns interactivity on where this took it away: a
99
- * consumer who never had `Wizard` in their `builtIns` keeps it out.
100
- */
101
- static gateWizard = Layer.effectContext(Effect.gen(function* () {
102
- const ambient = yield* CliConfig.CliConfig;
103
- const unchanged = Context.make(CliConfig.CliConfig, ambient);
104
- if ((yield* CliInteractive) || !ambient.builtIns.includes(GlobalFlag.Wizard)) return unchanged;
105
- const dropped = CliConfig.make({ builtIns: ambient.builtIns.filter((flag) => flag !== GlobalFlag.Wizard) });
106
- return Context.make(CliConfig.CliConfig, dropped).pipe(Context.add(WizardDropped, dropped));
107
- }));
108
- };
109
-
110
- export { CliPrompt };
package/CliRuntime.js DELETED
@@ -1,254 +0,0 @@
1
- import { Cancelled } from "./Cancelled.js";
2
- import { sanitize } from "./Fmt.js";
3
- import { failureText, tagOf } from "./internal/inspect.js";
4
- import { NotInteractive } from "./NotInteractive.js";
5
- import { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, readSpans, refreshFailureTarget } from "./internal/failureTarget.js";
6
- import { CliColor } from "./CliColor.js";
7
- import { CliEnv } from "./CliEnv.js";
8
- import { isExitCode } from "./internal/isExitCode.js";
9
- import { CliExit } from "./CliExit.js";
10
- import { TrustedLine } from "./internal/logSafety.js";
11
- import { CliLogger, makeCliLogger } from "./CliLogger.js";
12
- import { CliLog, envBuildLogLayer, platformLogLayer } from "./CliLog.js";
13
- import { ExitRequested } from "./internal/ExitRequested.js";
14
- import { routeHelpOnUsageError } from "./internal/HelpRouting.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/engine";
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"
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 DELETED
@@ -1,206 +0,0 @@
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/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 };