@effected/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CliColor.js +66 -0
- package/CliExit.js +65 -0
- package/CliLogger.js +12 -4
- package/CliRuntime.js +66 -2
- package/CliTest.js +79 -0
- package/README.md +95 -8
- package/index.d.ts +265 -9
- package/index.js +3 -1
- package/internal/ExitRequested.js +22 -0
- package/internal/HelpRouting.js +90 -0
- package/internal/isExitCode.js +12 -0
- package/package.json +6 -1
- package/testing.d.ts +94 -0
- package/testing.js +3 -0
package/CliColor.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Config, Effect, Layer, Option, Stdio } from "effect";
|
|
2
|
+
import { CliOutput } from "effect/unstable/cli";
|
|
3
|
+
|
|
4
|
+
//#region src/CliColor.ts
|
|
5
|
+
const noColor = Config.option(Config.String("NO_COLOR"));
|
|
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
|
+
* Follows the no-color.org rule: colour is off when stdout is not a
|
|
12
|
+
* terminal, or when `NO_COLOR` is set to any **non-empty** value — an empty
|
|
13
|
+
* `NO_COLOR=""` does not disable colour. `FORCE_COLOR` is ignored, matching
|
|
14
|
+
* core's own formatter. The environment is read through the ambient
|
|
15
|
+
* `ConfigProvider`, never `process`, so a test swaps it with
|
|
16
|
+
* `Effect.provideService(ConfigProvider.ConfigProvider, ...)`. The kit's
|
|
17
|
+
* default providers (`fromEnv`, `fromUnknown`) already treat an empty
|
|
18
|
+
* `NO_COLOR` as unset, so the explicit `set === ""` check exists for a
|
|
19
|
+
* provider constructed with `{ preserveEmptyStrings: true }`.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
var CliColor = class CliColor {
|
|
24
|
+
constructor() {}
|
|
25
|
+
/**
|
|
26
|
+
* The decision, for passing to a pure renderer as a plain boolean.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
static enabled = Effect.gen(function* () {
|
|
31
|
+
if (!(yield* (yield* Stdio.Stdio).stdoutIsTerminal)) return false;
|
|
32
|
+
const value = yield* noColor.pipe(Effect.orElseSucceed(() => Option.none()));
|
|
33
|
+
return Option.match(value, {
|
|
34
|
+
onNone: () => true,
|
|
35
|
+
onSome: (set) => set === ""
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Core's default `CliOutput.Formatter`, coloured by the same decision as
|
|
40
|
+
* {@link CliColor.enabled}, so help text, parse errors and rendered
|
|
41
|
+
* output never disagree on whether colour is on.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* `overrides` replaces individual methods of the default formatter — for
|
|
45
|
+
* example `formatVersion`, to append a "via `<carrier>`" line without
|
|
46
|
+
* losing the other defaults. Each call mints a fresh layer; bind the
|
|
47
|
+
* result to a `const` or the decision is re-read every time it is
|
|
48
|
+
* provided.
|
|
49
|
+
*
|
|
50
|
+
* The `never` in its output does not mean it installs nothing.
|
|
51
|
+
* `CliOutput.Formatter` is a `Context.Reference`, whose key type is
|
|
52
|
+
* `never`, so this layer sets the formatter reference rather than
|
|
53
|
+
* providing a service. Every command it is provided to renders with the
|
|
54
|
+
* formatter it sets, and without it they fall back to core's default
|
|
55
|
+
* formatter.
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
static formatterLayer = (overrides = {}) => Layer.unwrap(Effect.map(CliColor.enabled, (colors) => CliOutput.layer({
|
|
60
|
+
...CliOutput.defaultFormatter({ colors }),
|
|
61
|
+
...overrides
|
|
62
|
+
})));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
export { CliColor };
|
package/CliExit.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isExitCode } from "./internal/isExitCode.js";
|
|
2
|
+
import { Context, Effect, Layer, MutableRef } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/CliExit.ts
|
|
5
|
+
/**
|
|
6
|
+
* The exit code a successful run wants, for commands whose findings are a
|
|
7
|
+
* result rather than a failure (a linter that found problems, say).
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Findings are not a failure — a JSON `tapError` must not fire, and the
|
|
11
|
+
* handler must return normally — yet the process must exit non-zero. Writing
|
|
12
|
+
* `process.exitCode` works only because Node's `runMain` skips
|
|
13
|
+
* `process.exit(0)` on success; `process.exit(n)` in a handler skips every
|
|
14
|
+
* finalizer. {@link CliRuntime.main} reads this cell after the program
|
|
15
|
+
* succeeds and turns a non-zero code into a marked failure the runtime's
|
|
16
|
+
* teardown honours, on any runtime, with finalizers intact.
|
|
17
|
+
*
|
|
18
|
+
* A `Service`, not a `Reference`: forgetting `CliRuntime.main` is a type error,
|
|
19
|
+
* never a silently ignored global.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
var CliExit = class CliExit extends Context.Service()("@effected/cli/CliExit") {
|
|
24
|
+
/**
|
|
25
|
+
* A fresh cell at `0`; `CliRuntime.main` provides it, and tests provide it
|
|
26
|
+
* directly.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Every provide mints a new cell. Layers memoize by reference across
|
|
30
|
+
* `Effect.provide` calls, so without `Layer.fresh` a second provide of this
|
|
31
|
+
* layer anywhere in the program — a nested `CliRuntime.main`, a test
|
|
32
|
+
* helper — would silently share the first run's cell and inherit its code.
|
|
33
|
+
*
|
|
34
|
+
* A program run under `CliRuntime.main` must NOT provide `CliExit.layer`
|
|
35
|
+
* itself: `main` already provides one, and a second provide mints a second,
|
|
36
|
+
* unrelated cell that `main` never reads back, so `CliExit.set` calls made
|
|
37
|
+
* against it are silently discarded and the run exits `0`.
|
|
38
|
+
*/
|
|
39
|
+
static layer = Layer.fresh(Layer.sync(this, () => ({ code: MutableRef.make(0) })));
|
|
40
|
+
/**
|
|
41
|
+
* Record an exit code; the highest code set during the run wins.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Highest-wins, not last-wins, so a later "clean" step cannot quietly
|
|
45
|
+
* downgrade an earlier finding's code.
|
|
46
|
+
*
|
|
47
|
+
* The code must be an integer in `0..255` — the range a POSIX exit status
|
|
48
|
+
* can carry. Anything else dies as a defect naming the value: `256` would
|
|
49
|
+
* wrap to exit `0` and silently pass a run with findings, and a fraction
|
|
50
|
+
* such as `1.5` makes `process.exit` throw `ERR_OUT_OF_RANGE` after the
|
|
51
|
+
* program has finished.
|
|
52
|
+
*
|
|
53
|
+
* The code only applies to a run that **succeeds**. A program failure beats
|
|
54
|
+
* findings: when the program fails, `CliRuntime.main` never reads this
|
|
55
|
+
* cell, and the failure's own exit code (or the `exitCode` fallback) wins.
|
|
56
|
+
*/
|
|
57
|
+
static set = (code) => Effect.gen(function* () {
|
|
58
|
+
if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliExit.set: exit code must be an integer 0..255, received ${code}`));
|
|
59
|
+
const exit = yield* CliExit;
|
|
60
|
+
if (code > MutableRef.get(exit.code)) MutableRef.set(exit.code, code);
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { CliExit };
|
package/CliLogger.js
CHANGED
|
@@ -37,16 +37,24 @@ const defaultRender = (message) => Array.isArray(message) ? message.map(String).
|
|
|
37
37
|
* @example
|
|
38
38
|
* ```ts
|
|
39
39
|
* import { CliLogger } from "@effected/cli"
|
|
40
|
-
* import { Effect } from "effect"
|
|
40
|
+
* import { Console, Effect } from "effect"
|
|
41
41
|
*
|
|
42
42
|
* const program = Effect.gen(function* () {
|
|
43
|
-
* yield* Effect.log("synced 3 repos")
|
|
44
|
-
* yield*
|
|
43
|
+
* yield* Effect.log("synced 3 repos") // stderr, no timestamp — a diagnostic
|
|
44
|
+
* yield* Console.log("3 repos synced") // stdout — the program's actual output
|
|
45
|
+
* yield* Effect.logError("one failed") // stderr
|
|
45
46
|
* })
|
|
46
47
|
*
|
|
47
48
|
* program.pipe(Effect.provide(CliLogger.layer()))
|
|
48
49
|
* ```
|
|
49
50
|
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* `stderrFrom` defaults to `"All"`: a CLI's stdout is its product, so every
|
|
53
|
+
* log level is a diagnostic unless a consumer narrows the threshold. Write
|
|
54
|
+
* program output with `Console.log`, never `Effect.log`. Pass
|
|
55
|
+
* `stderrFrom: "Error"` for a tool whose output *is* its log lines. This is
|
|
56
|
+
* a breaking change on the 0.x line (#716).
|
|
57
|
+
*
|
|
50
58
|
* @public
|
|
51
59
|
*/
|
|
52
60
|
var CliLogger = class CliLogger {
|
|
@@ -60,7 +68,7 @@ var CliLogger = class CliLogger {
|
|
|
60
68
|
*/
|
|
61
69
|
static make = (options = {}) => {
|
|
62
70
|
const render = options.render ?? defaultRender;
|
|
63
|
-
const stderrFrom = options.stderrFrom ?? "
|
|
71
|
+
const stderrFrom = options.stderrFrom ?? "All";
|
|
64
72
|
return Logger.make(({ fiber, logLevel, message }) => {
|
|
65
73
|
const console = fiber.getRef(Console.Console);
|
|
66
74
|
(fiber.getRef(References.LogToStderr) || LogLevel.isGreaterThanOrEqualTo(logLevel, stderrFrom) ? console.error : console.log)(render(message));
|
package/CliRuntime.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isExitCode } from "./internal/isExitCode.js";
|
|
2
|
+
import { CliExit } from "./CliExit.js";
|
|
3
|
+
import { CliLogger } from "./CliLogger.js";
|
|
4
|
+
import { ExitRequested } from "./internal/ExitRequested.js";
|
|
5
|
+
import { routeHelpOnUsageError } from "./internal/HelpRouting.js";
|
|
6
|
+
import { Cause, Effect, MutableRef, Runtime } from "effect";
|
|
7
|
+
import { CliError } from "effect/unstable/cli";
|
|
2
8
|
|
|
3
9
|
//#region src/CliRuntime.ts
|
|
10
|
+
const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
|
|
11
|
+
/** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
|
|
12
|
+
const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
|
|
4
13
|
const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
|
|
5
14
|
/**
|
|
6
15
|
* The error's own exit code when it carries one, otherwise the fallback.
|
|
@@ -70,6 +79,23 @@ const chooseExitCode = (error, fallback) => typeof error === "object" && error !
|
|
|
70
79
|
* An **interrupt is left alone**: it is not a failure to report, and the
|
|
71
80
|
* default teardown already maps an interrupt-only cause to `130`.
|
|
72
81
|
*
|
|
82
|
+
* A `CliError.ShowHelp` is never rendered: `Command.runWith` already printed
|
|
83
|
+
* the help text (and any parse errors) before re-failing with it, so
|
|
84
|
+
* rendering it again would print nothing but a stray "Help requested" line.
|
|
85
|
+
* A `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`,
|
|
86
|
+
* BSD `EX_USAGE`); a bare `--help` or root invocation — `errors` empty —
|
|
87
|
+
* keeps exit `0`. A `CliError.UserError` whose `Runtime.errorReported` mark
|
|
88
|
+
* is `false` is likewise skipped: that is how `Command.runWith` leaves one it
|
|
89
|
+
* already rendered through its `CliOutput` formatter. It exits with its own
|
|
90
|
+
* `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
|
|
91
|
+
* under runWith's `renderErrors: false` the mark stays set, and it renders
|
|
92
|
+
* here like any other failure. Every other
|
|
93
|
+
* error renders even when it already carries the reported mark — a gate
|
|
94
|
+
* failure marked with `CliRuntime.reported` still prints its line. The
|
|
95
|
+
* private `ExitRequested` sentinel `CliRuntime.main`
|
|
96
|
+
* raises is likewise never rendered; it only carries the exit code a
|
|
97
|
+
* successful program recorded through `CliExit`.
|
|
98
|
+
*
|
|
73
99
|
* @public
|
|
74
100
|
*/
|
|
75
101
|
var CliRuntime = class CliRuntime {
|
|
@@ -81,12 +107,50 @@ var CliRuntime = class CliRuntime {
|
|
|
81
107
|
static reportFailures = (options = {}) => (effect) => effect.pipe(Effect.catchCause((cause) => {
|
|
82
108
|
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
|
|
83
109
|
const error = Cause.squash(cause);
|
|
110
|
+
if (error instanceof ExitRequested) return Effect.fail(error);
|
|
111
|
+
if (isShowHelp(error)) {
|
|
112
|
+
const code = error.errors.length > 0 ? options.usageExitCode ?? 64 : 0;
|
|
113
|
+
return Effect.fail(CliRuntime.reported(error, code));
|
|
114
|
+
}
|
|
115
|
+
if (isRenderedUserError(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.usageExitCode ?? 64)));
|
|
84
116
|
const render = options.render ?? ((value) => String(value));
|
|
117
|
+
const details = {
|
|
118
|
+
cause,
|
|
119
|
+
isDefect: !Cause.hasFails(cause)
|
|
120
|
+
};
|
|
85
121
|
return Effect.gen(function* () {
|
|
86
|
-
for (const line of toLines(render(error))) yield* Effect.logError(line);
|
|
122
|
+
for (const line of toLines(render(error, details))) yield* Effect.logError(line);
|
|
87
123
|
return yield* Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode)));
|
|
88
124
|
});
|
|
89
125
|
}));
|
|
126
|
+
/**
|
|
127
|
+
* Assemble a CLI program in the one order that reports every failure well.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* - `CliExit` is provided fresh, and a non-zero code after success becomes a
|
|
131
|
+
* marked failure the teardown honours.
|
|
132
|
+
* - The platform layer is provided **inside** failure reporting, so a
|
|
133
|
+
* layer-build failure (`HOME` unset, say) renders as one line with the
|
|
134
|
+
* fallback code rather than escaping to the runtime's stack trace.
|
|
135
|
+
* - The logger is provided **outermost**, so it is present whichever branch
|
|
136
|
+
* fails.
|
|
137
|
+
*
|
|
138
|
+
* You still call your platform's runner:
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* NodeRuntime.runMain(
|
|
143
|
+
* CliRuntime.main(Command.run(root, { version }), { platform: NodeServices.layer, exitCode: 3 }),
|
|
144
|
+
* )
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
static main = (program, options) => Effect.gen(function* () {
|
|
148
|
+
yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(program) : program;
|
|
149
|
+
const exit = yield* CliExit;
|
|
150
|
+
const code = MutableRef.get(exit.code);
|
|
151
|
+
if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
|
|
152
|
+
if (code !== 0) return yield* Effect.fail(new ExitRequested(code));
|
|
153
|
+
}).pipe(Effect.provide(CliExit.layer), Effect.provide(options.platform), CliRuntime.reportFailures(options), Effect.provide(options.logger ?? CliLogger.layer()));
|
|
90
154
|
static reported(error, exitCode = 1) {
|
|
91
155
|
const marked = error instanceof Error ? error : new Error(String(error));
|
|
92
156
|
return Object.assign(marked, {
|
package/CliTest.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Stream } from "effect";
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
3
|
+
|
|
4
|
+
//#region src/CliTest.ts
|
|
5
|
+
const text = (stream) => Stream.mkString(Stream.decodeText(stream));
|
|
6
|
+
/**
|
|
7
|
+
* Spawn a built CLI bin hermetically and read its exit code and streams as data.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
var CliTest = class {
|
|
12
|
+
constructor() {}
|
|
13
|
+
/**
|
|
14
|
+
* A scoped temp directory with a fresh `HOME` and XDG tree, `NO_COLOR=1`,
|
|
15
|
+
* and the `PATH` you pass — the host environment is never inherited.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* Each call mints a fresh temp directory; bind the result to a `const`
|
|
19
|
+
* within one test rather than calling this more than once per assertion.
|
|
20
|
+
*/
|
|
21
|
+
static sandbox = (options) => Effect.gen(function* () {
|
|
22
|
+
const fs = yield* FileSystem.FileSystem;
|
|
23
|
+
const path = yield* Path.Path;
|
|
24
|
+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "effected-cli-test-" });
|
|
25
|
+
const home = path.join(root, "home");
|
|
26
|
+
const xdg = {
|
|
27
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
28
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
29
|
+
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
30
|
+
XDG_CACHE_HOME: path.join(home, ".cache")
|
|
31
|
+
};
|
|
32
|
+
for (const dir of Object.values(xdg)) yield* fs.makeDirectory(dir, { recursive: true });
|
|
33
|
+
return {
|
|
34
|
+
root,
|
|
35
|
+
home,
|
|
36
|
+
env: {
|
|
37
|
+
HOME: home,
|
|
38
|
+
...xdg,
|
|
39
|
+
PATH: options.path,
|
|
40
|
+
NO_COLOR: "1"
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Run `execPath bin ...args`; a non-zero exit is returned, never failed.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* `stdin` is never left as an inherited open pipe: when omitted or `""`
|
|
49
|
+
* the child gets an already-ended empty input (`Stream.empty`), so a
|
|
50
|
+
* stdin-reading bin exits instead of hanging on `effect/unstable/process`'s
|
|
51
|
+
* default `"pipe"` stdio, which stays open until something writes to and
|
|
52
|
+
* ends it.
|
|
53
|
+
*/
|
|
54
|
+
static run = (bin, args, options) => Effect.scoped(Effect.gen(function* () {
|
|
55
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
56
|
+
const command = ChildProcess.make(options.execPath, [bin, ...args], {
|
|
57
|
+
cwd: options.cwd ?? options.sandbox.root,
|
|
58
|
+
env: {
|
|
59
|
+
...options.sandbox.env,
|
|
60
|
+
...options.env
|
|
61
|
+
},
|
|
62
|
+
stdin: options.stdin === void 0 || options.stdin === "" ? Stream.empty : Stream.make(new TextEncoder().encode(options.stdin))
|
|
63
|
+
});
|
|
64
|
+
const handle = yield* spawner.spawn(command);
|
|
65
|
+
const [stdout, stderr, exitCode] = yield* Effect.all([
|
|
66
|
+
text(handle.stdout),
|
|
67
|
+
text(handle.stderr),
|
|
68
|
+
handle.exitCode
|
|
69
|
+
], { concurrency: "unbounded" });
|
|
70
|
+
return {
|
|
71
|
+
exitCode: Number(exitCode),
|
|
72
|
+
stdout,
|
|
73
|
+
stderr
|
|
74
|
+
};
|
|
75
|
+
}));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { CliTest };
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
The boundary layer of a command-line program built on `effect/unstable/cli`: how output reaches a human, how a failure is reported, and how a schema issue becomes a sentence someone can act on. `CliLogger` renders log records as plain lines and routes diagnostics to stderr, reading the `Console` off the fiber so it needs no platform package and the stream split is actually testable. `CliRuntime.reportFailures` catches inside your program so a failure prints through *your* logger instead of Effect's default one on stdout, then re-fails with the exit code and the no-double-report mark. `SchemaIssueRenderer` and `ConfigIssueRenderer` turn issue trees into `unknown key at groups.g.rulesetz`.
|
|
8
|
+
The boundary layer of a command-line program built on `effect/unstable/cli`: how output reaches a human, how a failure is reported, and how a schema issue becomes a sentence someone can act on. `CliLogger` renders log records as plain lines and routes diagnostics to stderr, reading the `Console` off the fiber so it needs no platform package and the stream split is actually testable. `CliRuntime.reportFailures` catches inside your program so a failure prints through *your* logger instead of Effect's default one on stdout, then re-fails with the exit code and the no-double-report mark; `CliRuntime.main` assembles a whole program — platform layer, a fresh `CliExit`, failure reporting, and the logger — in the one order that reports every failure well. `CliExit` lets a findings command (a linter that found problems, say) succeed with a non-zero exit code, with finalizers intact on any runtime. `CliColor` decides once, per the no-color.org rule, whether output carries ANSI colour, and hands core's own `CliOutput.Formatter` the same decision. `SchemaIssueRenderer` and `ConfigIssueRenderer` turn issue trees into `unknown key at groups.g.rulesetz`. The `./testing` subpath's `CliTest` spawns a built bin hermetically for a test that wants a real subprocess.
|
|
9
9
|
|
|
10
10
|
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
11
|
> development against a single pinned Effect v4 prerelease. Packages graduate to
|
|
@@ -48,12 +48,13 @@ All `@effected/*` packages are ESM-only: the exports maps publish only `import`
|
|
|
48
48
|
```ts
|
|
49
49
|
import { CliLogger, CliRuntime } from "@effected/cli";
|
|
50
50
|
import { NodeRuntime } from "@effect/platform-node";
|
|
51
|
-
import { Effect, Layer } from "effect";
|
|
51
|
+
import { Console, Effect, Layer } from "effect";
|
|
52
52
|
|
|
53
53
|
declare const AppLive: Layer.Layer<never>;
|
|
54
54
|
|
|
55
55
|
const program = Effect.gen(function* () {
|
|
56
|
-
yield* Effect.
|
|
56
|
+
yield* Effect.logInfo("building 3 packages"); // a diagnostic, not the product
|
|
57
|
+
yield* Console.log("build.json contents"); // the program's actual output
|
|
57
58
|
yield* Effect.logError("nothing to build");
|
|
58
59
|
});
|
|
59
60
|
|
|
@@ -62,9 +63,10 @@ const program = Effect.gen(function* () {
|
|
|
62
63
|
const MainLive = Layer.mergeAll(AppLive, CliLogger.layer());
|
|
63
64
|
|
|
64
65
|
NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)));
|
|
65
|
-
// stdout:
|
|
66
|
+
// stdout: build.json contents
|
|
67
|
+
// stderr: building 3 packages
|
|
66
68
|
// stderr: nothing to build
|
|
67
|
-
// No timestamp, no level, no fiber id — and
|
|
69
|
+
// No timestamp, no level, no fiber id — and stdout carries only what Console.log wrote.
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
Rendering a bad config into something actionable:
|
|
@@ -96,12 +98,63 @@ ConfigValidationError: Config validation failed at "/home/me/.config/app/config.
|
|
|
96
98
|
Missing key at variables.keep.resolved
|
|
97
99
|
```
|
|
98
100
|
|
|
101
|
+
Print-then-`reported` is for a program run **without** `CliRuntime.main` or `reportFailures`. Under either, do not print the failure yourself: `reportFailures` renders every error except a `ShowHelp`, a `CliError.UserError` whose reported mark is `false` (one `Command.runWith` already printed, or one you marked with `reported`) and the `CliExit` sentinel, so it would print twice. Fail with the error and put the multi-line rendering in the `render` option instead — see "Rendering a multi-line failure" in the [advanced guide](https://effected.spencerbeg.gs/cli/advanced#rendering-a-multi-line-failure).
|
|
102
|
+
|
|
103
|
+
## Putting it together
|
|
104
|
+
|
|
105
|
+
A findings command — one whose non-zero exit reports a result rather than a
|
|
106
|
+
crash — wires `CliRuntime.main`, `CliExit.set` and `CliColor.formatterLayer`
|
|
107
|
+
around an ordinary `effect/unstable/cli` command:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { CliColor, CliExit, CliRuntime } from "@effected/cli";
|
|
111
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
112
|
+
import { Console, Effect, Layer } from "effect";
|
|
113
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
114
|
+
|
|
115
|
+
const findProblems = (strict: boolean): ReadonlyArray<string> =>
|
|
116
|
+
strict ? ["missing changeset", "unpinned dependency"] : ["missing changeset"];
|
|
117
|
+
|
|
118
|
+
const check = Command.make("check", { strict: Flag.Boolean("strict").pipe(Flag.withDefault(false)) }, (config) =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const problems = findProblems(config.strict);
|
|
121
|
+
for (const problem of problems) yield* Console.log(problem);
|
|
122
|
+
|
|
123
|
+
// Findings, not a crash: the handler still SUCCEEDS. CliExit.set records
|
|
124
|
+
// the code CliRuntime.main turns into a real exit once the program ends.
|
|
125
|
+
if (problems.length > 0) yield* CliExit.set(1);
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Satisfies Command.Environment (Command.run needs it) AND feeds Stdio to
|
|
130
|
+
// CliColor.formatterLayer, so help text, parse errors and rendered output
|
|
131
|
+
// never disagree about whether colour is on.
|
|
132
|
+
const Platform = CliColor.formatterLayer().pipe(Layer.provideMerge(NodeServices.layer));
|
|
133
|
+
|
|
134
|
+
// CliRuntime.main provides a fresh CliExit, the platform layer inside failure
|
|
135
|
+
// reporting, and the logger outermost — do NOT provide CliExit.layer here
|
|
136
|
+
// yourself, or CliExit.set writes to a second, unread cell and `check`
|
|
137
|
+
// silently exits 0.
|
|
138
|
+
NodeRuntime.runMain(CliRuntime.main(Command.run(check, { version: "1.0.0" }), { platform: Platform }));
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
$ node check.js
|
|
143
|
+
missing changeset
|
|
144
|
+
$ echo $?
|
|
145
|
+
1
|
|
146
|
+
```
|
|
147
|
+
|
|
99
148
|
## Features
|
|
100
149
|
|
|
101
|
-
- `CliLogger.layer(options?)` — replaces the default logger with plain lines, routing
|
|
150
|
+
- `CliLogger.layer(options?)` — replaces the default logger with plain lines, routing every level to stderr by default. The threshold is the `stderrFrom` option (pass `"Error"` to restore the old split), compared ordinally, so a level added upstream lands on the right stream without a change here.
|
|
102
151
|
- `CliLogger.make(options?)` — the `Logger` itself, for composing into a logger set you already have.
|
|
103
|
-
- `CliRuntime.reportFailures(options?)` — reports through your logger, then re-fails with an exit code and the mark that stops the runtime reporting it a second time.
|
|
104
|
-
- `CliRuntime.
|
|
152
|
+
- `CliRuntime.reportFailures(options?)` — reports through your logger, then re-fails with an exit code and the mark that stops the runtime reporting it a second time. `render(error, details)` receives the squashed error and a `FailureDetails` (`{ cause, isDefect }`), so a defect can render differently from a typed failure. Never renders a `CliError.ShowHelp` (already printed by `Command.runWith`) — a `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`).
|
|
153
|
+
- `CliRuntime.main(program, { platform, logger?, ... })` — assembles a whole program in the one order that reports every failure well: a fresh `CliExit`, the platform layer inside failure reporting, and the logger outermost. `helpOnUsageError: "stderr"` moves the help printed with a parse error onto stderr beside the error, so a caller piping stdout into `jq` gets nothing on a usage error; `--help` and a bare group invocation still print on stdout.
|
|
154
|
+
- `CliRuntime.reported(error, exitCode?)` — marks an error you reported yourself, so the runtime stays quiet about it. A typed `Error` comes back as its own type (the marks are added in place) when it passes `instanceof Error` at runtime; any other value — including one that only satisfies `Error`'s shape structurally — is wrapped in a plain `Error`. A `CliError.UserError` marked with `reported` is treated as already printed and is not rendered — use a different error type if the program has not printed it. It keeps the code you pass: `reported(userError, 3)` exits `3`, not `usageExitCode`.
|
|
155
|
+
- `CliExit.set(code)` — records a findings exit code from a successful program; the highest code set during the run wins. `CliExit.layer` mints a fresh cell per provide (`Layer.fresh`) — `CliRuntime.main` provides it for you.
|
|
156
|
+
- `CliColor.enabled` — `Effect<boolean, never, Stdio>`, the no-color.org decision: off when stdout is not a terminal, or `NO_COLOR` is a non-empty value. `FORCE_COLOR` is ignored.
|
|
157
|
+
- `CliColor.formatterLayer(overrides?)` — core's `CliOutput.Formatter`, coloured by the same decision as `CliColor.enabled`.
|
|
105
158
|
- `SchemaIssueRenderer.render(issue)` — a `SchemaIssue` tree becomes one line per rejected value.
|
|
106
159
|
- `ConfigIssueRenderer.render(error)` — the same rendering, reading `issue` off a `ConfigValidationError`.
|
|
107
160
|
|
|
@@ -110,6 +163,40 @@ Two behaviours worth knowing before you rely on them:
|
|
|
110
163
|
- `CliLogger` honours `References.LogToStderr` as a **one-way** override — it can force everything to stderr, and can never move an error onto stdout.
|
|
111
164
|
- `CliRuntime` keeps an exit code the error already carries via `Runtime.errorExitCode`; the `exitCode` option is a fallback, not an override. An interrupt is left alone.
|
|
112
165
|
|
|
166
|
+
## Testing
|
|
167
|
+
|
|
168
|
+
`@effected/cli/testing` is a separate entrypoint — importing `@effected/cli`
|
|
169
|
+
never pulls it in — for spawning a **built** bin hermetically and reading its
|
|
170
|
+
exit code and streams as data:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { CliTest } from "@effected/cli/testing";
|
|
174
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
175
|
+
import { assert, describe, it } from "@effect/vitest";
|
|
176
|
+
import { Effect } from "effect";
|
|
177
|
+
|
|
178
|
+
describe("check", () => {
|
|
179
|
+
it.effect("exits 1 when it finds a problem", () =>
|
|
180
|
+
Effect.gen(function* () {
|
|
181
|
+
const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" });
|
|
182
|
+
const result = yield* CliTest.run("./dist/check.js", ["--strict"], {
|
|
183
|
+
sandbox,
|
|
184
|
+
execPath: process.execPath,
|
|
185
|
+
});
|
|
186
|
+
assert.strictEqual(result.exitCode, 1);
|
|
187
|
+
assert.include(result.stdout, "missing changeset");
|
|
188
|
+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`CliTest.sandbox({ path })` mints a scoped temp directory with a fresh `HOME`
|
|
194
|
+
and `XDG_{CONFIG,DATA,STATE,CACHE}_HOME`, `NO_COLOR: "1"`, and the `path` you
|
|
195
|
+
pass as `PATH` — the host environment is never inherited. `CliTest.run` never
|
|
196
|
+
leaves `stdin` as an open pipe: when you omit it, or pass `""`, the child
|
|
197
|
+
receives an already-ended empty input, so a stdin-reading bin cannot hang the
|
|
198
|
+
test.
|
|
199
|
+
|
|
113
200
|
## License
|
|
114
201
|
|
|
115
202
|
[MIT](LICENSE)
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,122 @@
|
|
|
1
|
-
import { Effect, Layer, LogLevel, Logger } from "effect";
|
|
1
|
+
import { Cause, Context, Effect, Layer, LogLevel, Logger, MutableRef, Stdio } from "effect";
|
|
2
|
+
import { CliOutput } from "effect/unstable/cli";
|
|
2
3
|
import { ConfigValidationError } from "@effected/config-file";
|
|
4
|
+
//#region src/CliColor.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Whether a CLI's output should carry ANSI colour, decided once and shared by
|
|
7
|
+
* everything that renders — help text, error output, and any rendered result.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Follows the no-color.org rule: colour is off when stdout is not a
|
|
11
|
+
* terminal, or when `NO_COLOR` is set to any **non-empty** value — an empty
|
|
12
|
+
* `NO_COLOR=""` does not disable colour. `FORCE_COLOR` is ignored, matching
|
|
13
|
+
* core's own formatter. The environment is read through the ambient
|
|
14
|
+
* `ConfigProvider`, never `process`, so a test swaps it with
|
|
15
|
+
* `Effect.provideService(ConfigProvider.ConfigProvider, ...)`. The kit's
|
|
16
|
+
* default providers (`fromEnv`, `fromUnknown`) already treat an empty
|
|
17
|
+
* `NO_COLOR` as unset, so the explicit `set === ""` check exists for a
|
|
18
|
+
* provider constructed with `{ preserveEmptyStrings: true }`.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export declare class CliColor {
|
|
23
|
+
private constructor();
|
|
24
|
+
/**
|
|
25
|
+
* The decision, for passing to a pure renderer as a plain boolean.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
static readonly enabled: Effect.Effect<boolean, never, Stdio.Stdio>;
|
|
30
|
+
/**
|
|
31
|
+
* Core's default `CliOutput.Formatter`, coloured by the same decision as
|
|
32
|
+
* {@link CliColor.enabled}, so help text, parse errors and rendered
|
|
33
|
+
* output never disagree on whether colour is on.
|
|
34
|
+
*
|
|
35
|
+
* @remarks
|
|
36
|
+
* `overrides` replaces individual methods of the default formatter — for
|
|
37
|
+
* example `formatVersion`, to append a "via `<carrier>`" line without
|
|
38
|
+
* losing the other defaults. Each call mints a fresh layer; bind the
|
|
39
|
+
* result to a `const` or the decision is re-read every time it is
|
|
40
|
+
* provided.
|
|
41
|
+
*
|
|
42
|
+
* The `never` in its output does not mean it installs nothing.
|
|
43
|
+
* `CliOutput.Formatter` is a `Context.Reference`, whose key type is
|
|
44
|
+
* `never`, so this layer sets the formatter reference rather than
|
|
45
|
+
* providing a service. Every command it is provided to renders with the
|
|
46
|
+
* formatter it sets, and without it they fall back to core's default
|
|
47
|
+
* formatter.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
static readonly formatterLayer: (overrides?: Partial<CliOutput.Formatter>) => Layer.Layer<never, never, Stdio.Stdio>;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/CliExit.d.ts
|
|
55
|
+
/**
|
|
56
|
+
* The shape behind {@link CliExit}.
|
|
57
|
+
*
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
interface CliExitShape {
|
|
61
|
+
/** The highest exit code recorded during the run; `0` until one is set. */
|
|
62
|
+
readonly code: MutableRef.MutableRef<number>;
|
|
63
|
+
}
|
|
64
|
+
declare const CliExit_base: Context.ServiceClass<CliExit, "@effected/cli/CliExit", CliExitShape>;
|
|
65
|
+
/**
|
|
66
|
+
* The exit code a successful run wants, for commands whose findings are a
|
|
67
|
+
* result rather than a failure (a linter that found problems, say).
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* Findings are not a failure — a JSON `tapError` must not fire, and the
|
|
71
|
+
* handler must return normally — yet the process must exit non-zero. Writing
|
|
72
|
+
* `process.exitCode` works only because Node's `runMain` skips
|
|
73
|
+
* `process.exit(0)` on success; `process.exit(n)` in a handler skips every
|
|
74
|
+
* finalizer. {@link CliRuntime.main} reads this cell after the program
|
|
75
|
+
* succeeds and turns a non-zero code into a marked failure the runtime's
|
|
76
|
+
* teardown honours, on any runtime, with finalizers intact.
|
|
77
|
+
*
|
|
78
|
+
* A `Service`, not a `Reference`: forgetting `CliRuntime.main` is a type error,
|
|
79
|
+
* never a silently ignored global.
|
|
80
|
+
*
|
|
81
|
+
* @public
|
|
82
|
+
*/
|
|
83
|
+
export declare class CliExit extends CliExit_base {
|
|
84
|
+
/**
|
|
85
|
+
* A fresh cell at `0`; `CliRuntime.main` provides it, and tests provide it
|
|
86
|
+
* directly.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* Every provide mints a new cell. Layers memoize by reference across
|
|
90
|
+
* `Effect.provide` calls, so without `Layer.fresh` a second provide of this
|
|
91
|
+
* layer anywhere in the program — a nested `CliRuntime.main`, a test
|
|
92
|
+
* helper — would silently share the first run's cell and inherit its code.
|
|
93
|
+
*
|
|
94
|
+
* A program run under `CliRuntime.main` must NOT provide `CliExit.layer`
|
|
95
|
+
* itself: `main` already provides one, and a second provide mints a second,
|
|
96
|
+
* unrelated cell that `main` never reads back, so `CliExit.set` calls made
|
|
97
|
+
* against it are silently discarded and the run exits `0`.
|
|
98
|
+
*/
|
|
99
|
+
static readonly layer: Layer.Layer<CliExit>;
|
|
100
|
+
/**
|
|
101
|
+
* Record an exit code; the highest code set during the run wins.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* Highest-wins, not last-wins, so a later "clean" step cannot quietly
|
|
105
|
+
* downgrade an earlier finding's code.
|
|
106
|
+
*
|
|
107
|
+
* The code must be an integer in `0..255` — the range a POSIX exit status
|
|
108
|
+
* can carry. Anything else dies as a defect naming the value: `256` would
|
|
109
|
+
* wrap to exit `0` and silently pass a run with findings, and a fraction
|
|
110
|
+
* such as `1.5` makes `process.exit` throw `ERR_OUT_OF_RANGE` after the
|
|
111
|
+
* program has finished.
|
|
112
|
+
*
|
|
113
|
+
* The code only applies to a run that **succeeds**. A program failure beats
|
|
114
|
+
* findings: when the program fails, `CliRuntime.main` never reads this
|
|
115
|
+
* cell, and the failure's own exit code (or the `exitCode` fallback) wins.
|
|
116
|
+
*/
|
|
117
|
+
static readonly set: (code: number) => Effect.Effect<void, never, CliExit>;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
3
120
|
//#region src/CliLogger.d.ts
|
|
4
121
|
/**
|
|
5
122
|
* How a log record is turned into a line.
|
|
@@ -16,8 +133,14 @@ interface CliLoggerOptions {
|
|
|
16
133
|
*/
|
|
17
134
|
readonly render?: ((message: unknown) => string) | undefined;
|
|
18
135
|
/**
|
|
19
|
-
* The level at and above which output goes to stderr. Defaults to `"
|
|
20
|
-
* so
|
|
136
|
+
* The level at and above which output goes to stderr. Defaults to `"All"`,
|
|
137
|
+
* so every log level is a diagnostic and stdout carries only what the
|
|
138
|
+
* program writes with `Console.log`.
|
|
139
|
+
*
|
|
140
|
+
* @remarks
|
|
141
|
+
* Pass `"Error"` to restore the old split, for a tool whose output *is*
|
|
142
|
+
* its log lines rather than a separate document written with
|
|
143
|
+
* `Console.log`.
|
|
21
144
|
*/
|
|
22
145
|
readonly stderrFrom?: LogLevel.LogLevel | undefined;
|
|
23
146
|
}
|
|
@@ -56,16 +179,24 @@ interface CliLoggerOptions {
|
|
|
56
179
|
* @example
|
|
57
180
|
* ```ts
|
|
58
181
|
* import { CliLogger } from "@effected/cli"
|
|
59
|
-
* import { Effect } from "effect"
|
|
182
|
+
* import { Console, Effect } from "effect"
|
|
60
183
|
*
|
|
61
184
|
* const program = Effect.gen(function* () {
|
|
62
|
-
* yield* Effect.log("synced 3 repos")
|
|
63
|
-
* yield*
|
|
185
|
+
* yield* Effect.log("synced 3 repos") // stderr, no timestamp — a diagnostic
|
|
186
|
+
* yield* Console.log("3 repos synced") // stdout — the program's actual output
|
|
187
|
+
* yield* Effect.logError("one failed") // stderr
|
|
64
188
|
* })
|
|
65
189
|
*
|
|
66
190
|
* program.pipe(Effect.provide(CliLogger.layer()))
|
|
67
191
|
* ```
|
|
68
192
|
*
|
|
193
|
+
* @remarks
|
|
194
|
+
* `stderrFrom` defaults to `"All"`: a CLI's stdout is its product, so every
|
|
195
|
+
* log level is a diagnostic unless a consumer narrows the threshold. Write
|
|
196
|
+
* program output with `Console.log`, never `Effect.log`. Pass
|
|
197
|
+
* `stderrFrom: "Error"` for a tool whose output *is* its log lines. This is
|
|
198
|
+
* a breaking change on the 0.x line (#716).
|
|
199
|
+
*
|
|
69
200
|
* @public
|
|
70
201
|
*/
|
|
71
202
|
export declare class CliLogger {
|
|
@@ -92,6 +223,21 @@ export declare class CliLogger {
|
|
|
92
223
|
}
|
|
93
224
|
//#endregion
|
|
94
225
|
//#region src/CliRuntime.d.ts
|
|
226
|
+
/**
|
|
227
|
+
* What `render` is told about a failure beyond the squashed error.
|
|
228
|
+
*
|
|
229
|
+
* @public
|
|
230
|
+
*/
|
|
231
|
+
interface FailureDetails {
|
|
232
|
+
/** The whole cause the program failed with, before squashing. */
|
|
233
|
+
readonly cause: Cause.Cause<unknown>;
|
|
234
|
+
/**
|
|
235
|
+
* `true` when the cause carries no typed failure, so `error` is a defect:
|
|
236
|
+
* a `die`, a thrown exception, a bug. `false` when `error` is a typed
|
|
237
|
+
* failure from the error channel.
|
|
238
|
+
*/
|
|
239
|
+
readonly isDefect: boolean;
|
|
240
|
+
}
|
|
95
241
|
/**
|
|
96
242
|
* How a failure is turned into output and an exit code.
|
|
97
243
|
*
|
|
@@ -104,16 +250,72 @@ interface ReportFailuresOptions {
|
|
|
104
250
|
* @remarks
|
|
105
251
|
* Return several lines to print several: a config error's own message
|
|
106
252
|
* followed by the rendered issue lines, say.
|
|
253
|
+
*
|
|
254
|
+
* `error` is the squashed cause: the first typed failure when there is
|
|
255
|
+
* one, otherwise the first defect. `details` says which it is, so a typed
|
|
256
|
+
* failure can render as one line and a defect as a full report, without
|
|
257
|
+
* guessing from the error's shape. A renderer that takes only `error`
|
|
258
|
+
* still fits.
|
|
107
259
|
*/
|
|
108
|
-
readonly render?: ((error: unknown) => string | ReadonlyArray<string>) | undefined;
|
|
260
|
+
readonly render?: ((error: unknown, details: FailureDetails) => string | ReadonlyArray<string>) | undefined;
|
|
109
261
|
/**
|
|
110
262
|
* The exit code to use when the error does not carry one.
|
|
111
263
|
*
|
|
112
264
|
* @remarks
|
|
113
265
|
* An error carrying `Runtime.errorExitCode` keeps its own; this is only the
|
|
114
|
-
* fallback, and it defaults to `1`.
|
|
266
|
+
* fallback, and it defaults to `1`. Pass an integer in `0..255`, the range a
|
|
267
|
+
* POSIX exit status can carry — `256` wraps to `0` and passes a failed run.
|
|
115
268
|
*/
|
|
116
269
|
readonly exitCode?: number | undefined;
|
|
270
|
+
/**
|
|
271
|
+
* The exit code for a usage error: a `ShowHelp` carrying parse errors, or a
|
|
272
|
+
* `CliError.UserError` `Command.runWith` already printed.
|
|
273
|
+
*
|
|
274
|
+
* @remarks
|
|
275
|
+
* Defaults to `64` (BSD `EX_USAGE`); pass an integer in `0..255`. A
|
|
276
|
+
* `ShowHelp` with no errors — a bare root invocation, or `--help` — always
|
|
277
|
+
* exits `0`. A `UserError` that carries its own `Runtime.errorExitCode` —
|
|
278
|
+
* one marked with `CliRuntime.reported(error, 3)` — keeps that code instead.
|
|
279
|
+
*
|
|
280
|
+
* Keep `Command.runWith`'s default `renderErrors`: with `renderErrors: false`
|
|
281
|
+
* runWith prints no parse errors and `reportFailures` never renders a
|
|
282
|
+
* `ShowHelp`, so a parse error would exit with this code having printed
|
|
283
|
+
* nothing on stderr.
|
|
284
|
+
*/
|
|
285
|
+
readonly usageExitCode?: number | undefined;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Options for {@link CliRuntime.main}.
|
|
289
|
+
*
|
|
290
|
+
* @public
|
|
291
|
+
*/
|
|
292
|
+
interface MainOptions<RP, EP> extends ReportFailuresOptions {
|
|
293
|
+
/**
|
|
294
|
+
* The platform layer, usually `NodeServices.layer` or an app platform built
|
|
295
|
+
* on it. Passed in so this package never imports a platform.
|
|
296
|
+
*/
|
|
297
|
+
readonly platform: Layer.Layer<RP, EP>;
|
|
298
|
+
/** The logger, provided outermost. Defaults to `CliLogger.layer()`. */
|
|
299
|
+
readonly logger?: Layer.Layer<never> | undefined;
|
|
300
|
+
/**
|
|
301
|
+
* Where the help document goes when it is printed with a usage error:
|
|
302
|
+
* `"stdout"` (the default, core's behaviour) or `"stderr"`, beside the
|
|
303
|
+
* errors.
|
|
304
|
+
*
|
|
305
|
+
* @remarks
|
|
306
|
+
* `"stderr"` keeps stdout clean for a caller that parses it, such as a
|
|
307
|
+
* hook piping JSON into `jq`: an unknown flag, a bad value or an unknown
|
|
308
|
+
* subcommand then writes nothing to stdout. An explicit `--help` and a
|
|
309
|
+
* bare invocation of a command group still print help on stdout: neither
|
|
310
|
+
* is an error.
|
|
311
|
+
*
|
|
312
|
+
* Two cases keep help on stdout even under `"stderr"`. A `CliOutput`
|
|
313
|
+
* Formatter or a `Console` provided inside `program` is not seen by
|
|
314
|
+
* `main`, so its help is not rerouted; provide the Formatter through
|
|
315
|
+
* `platform` instead. And with `Command.runWith`'s `renderErrors: false`
|
|
316
|
+
* no errors are printed, so nothing marks the help as a usage error's.
|
|
317
|
+
*/
|
|
318
|
+
readonly helpOnUsageError?: "stdout" | "stderr" | undefined;
|
|
117
319
|
}
|
|
118
320
|
/**
|
|
119
321
|
* Report a CLI program's failures through the program's own logger.
|
|
@@ -173,6 +375,23 @@ interface ReportFailuresOptions {
|
|
|
173
375
|
* An **interrupt is left alone**: it is not a failure to report, and the
|
|
174
376
|
* default teardown already maps an interrupt-only cause to `130`.
|
|
175
377
|
*
|
|
378
|
+
* A `CliError.ShowHelp` is never rendered: `Command.runWith` already printed
|
|
379
|
+
* the help text (and any parse errors) before re-failing with it, so
|
|
380
|
+
* rendering it again would print nothing but a stray "Help requested" line.
|
|
381
|
+
* A `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`,
|
|
382
|
+
* BSD `EX_USAGE`); a bare `--help` or root invocation — `errors` empty —
|
|
383
|
+
* keeps exit `0`. A `CliError.UserError` whose `Runtime.errorReported` mark
|
|
384
|
+
* is `false` is likewise skipped: that is how `Command.runWith` leaves one it
|
|
385
|
+
* already rendered through its `CliOutput` formatter. It exits with its own
|
|
386
|
+
* `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
|
|
387
|
+
* under runWith's `renderErrors: false` the mark stays set, and it renders
|
|
388
|
+
* here like any other failure. Every other
|
|
389
|
+
* error renders even when it already carries the reported mark — a gate
|
|
390
|
+
* failure marked with `CliRuntime.reported` still prints its line. The
|
|
391
|
+
* private `ExitRequested` sentinel `CliRuntime.main`
|
|
392
|
+
* raises is likewise never rendered; it only carries the exit code a
|
|
393
|
+
* successful program recorded through `CliExit`.
|
|
394
|
+
*
|
|
176
395
|
* @public
|
|
177
396
|
*/
|
|
178
397
|
export declare class CliRuntime {
|
|
@@ -182,6 +401,28 @@ export declare class CliRuntime {
|
|
|
182
401
|
* and the no-double-report mark.
|
|
183
402
|
*/
|
|
184
403
|
static readonly reportFailures: (options?: ReportFailuresOptions) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, Error, R>;
|
|
404
|
+
/**
|
|
405
|
+
* Assemble a CLI program in the one order that reports every failure well.
|
|
406
|
+
*
|
|
407
|
+
* @remarks
|
|
408
|
+
* - `CliExit` is provided fresh, and a non-zero code after success becomes a
|
|
409
|
+
* marked failure the teardown honours.
|
|
410
|
+
* - The platform layer is provided **inside** failure reporting, so a
|
|
411
|
+
* layer-build failure (`HOME` unset, say) renders as one line with the
|
|
412
|
+
* fallback code rather than escaping to the runtime's stack trace.
|
|
413
|
+
* - The logger is provided **outermost**, so it is present whichever branch
|
|
414
|
+
* fails.
|
|
415
|
+
*
|
|
416
|
+
* You still call your platform's runner:
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```ts
|
|
420
|
+
* NodeRuntime.runMain(
|
|
421
|
+
* CliRuntime.main(Command.run(root, { version }), { platform: NodeServices.layer, exitCode: 3 }),
|
|
422
|
+
* )
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
425
|
+
static readonly main: <A, E, R, RP, EP>(program: Effect.Effect<A, E, R>, options: MainOptions<RP, EP>) => Effect.Effect<void, Error, Exclude<Exclude<R, CliExit>, RP>>;
|
|
185
426
|
/**
|
|
186
427
|
* Mark an error as already reported, carrying an exit code.
|
|
187
428
|
*
|
|
@@ -190,6 +431,21 @@ export declare class CliRuntime {
|
|
|
190
431
|
* command that prints its own diagnostics, say — needs the same two marks
|
|
191
432
|
* and should not have to rediscover the inverted polarity.
|
|
192
433
|
*
|
|
434
|
+
* Under {@link CliRuntime.main} or {@link CliRuntime.reportFailures}, do NOT
|
|
435
|
+
* print the failure yourself before failing with it: `reportFailures`
|
|
436
|
+
* renders every error except a `ShowHelp` and a `CliError.UserError` whose
|
|
437
|
+
* reported mark is `false`, so it would print twice. Fail with the marked
|
|
438
|
+
* error and put any multi-line rendering in the `render` option instead. The
|
|
439
|
+
* mark matters for a program run WITHOUT `reportFailures`, where it keeps the
|
|
440
|
+
* runtime from reporting a failure the program already printed.
|
|
441
|
+
*
|
|
442
|
+
* A `CliError.UserError` marked with `reported` is treated as already
|
|
443
|
+
* printed and is not rendered — use a different error type if the program
|
|
444
|
+
* has not printed it. `reportFailures` cannot tell a `UserError` that
|
|
445
|
+
* `Command.runWith` printed from one marked here: both carry the same `false`
|
|
446
|
+
* mark. It does keep the code you pass: `reported(userError, 3)` exits `3`,
|
|
447
|
+
* not `usageExitCode`.
|
|
448
|
+
*
|
|
193
449
|
* The marks are added in place, so a typed error comes back as its own
|
|
194
450
|
* type: the `E` overload returns the very instance it was given, and a
|
|
195
451
|
* program failing with it keeps `catchTags` narrowing downstream without a
|
|
@@ -327,5 +583,5 @@ export declare class SchemaIssueRenderer {
|
|
|
327
583
|
static readonly render: (issue: unknown) => ReadonlyArray<string>;
|
|
328
584
|
}
|
|
329
585
|
//#endregion
|
|
330
|
-
export type { CliLoggerOptions, ReportFailuresOptions };
|
|
586
|
+
export type { CliExitShape, CliLoggerOptions, FailureDetails, MainOptions, ReportFailuresOptions };
|
|
331
587
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { CliColor } from "./CliColor.js";
|
|
2
|
+
import { CliExit } from "./CliExit.js";
|
|
1
3
|
import { CliLogger } from "./CliLogger.js";
|
|
2
4
|
import { CliRuntime } from "./CliRuntime.js";
|
|
3
5
|
import { ConfigIssueRenderer } from "./ConfigIssueRenderer.js";
|
|
4
6
|
import { SchemaIssueRenderer } from "./SchemaIssueRenderer.js";
|
|
5
7
|
|
|
6
|
-
export { CliLogger, CliRuntime, ConfigIssueRenderer, SchemaIssueRenderer };
|
|
8
|
+
export { CliColor, CliExit, CliLogger, CliRuntime, ConfigIssueRenderer, SchemaIssueRenderer };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Runtime } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/ExitRequested.ts
|
|
4
|
+
/**
|
|
5
|
+
* The failure `CliRuntime.main` raises when a successful program recorded a
|
|
6
|
+
* non-zero exit code through `CliExit`. Private: nothing outside this package
|
|
7
|
+
* constructs or matches it, and `reportFailures` never renders it.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
var ExitRequested = class extends Error {
|
|
12
|
+
[Runtime.errorReported] = false;
|
|
13
|
+
[Runtime.errorExitCode];
|
|
14
|
+
constructor(code) {
|
|
15
|
+
super(`exit ${code}`);
|
|
16
|
+
this.name = "ExitRequested";
|
|
17
|
+
this[Runtime.errorExitCode] = code;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { ExitRequested };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Console, Effect } from "effect";
|
|
2
|
+
import { CliOutput } from "effect/unstable/cli";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/HelpRouting.ts
|
|
5
|
+
const OTHER_METHODS = Object.keys({
|
|
6
|
+
assert: true,
|
|
7
|
+
clear: true,
|
|
8
|
+
count: true,
|
|
9
|
+
countReset: true,
|
|
10
|
+
debug: true,
|
|
11
|
+
dir: true,
|
|
12
|
+
dirxml: true,
|
|
13
|
+
group: true,
|
|
14
|
+
groupCollapsed: true,
|
|
15
|
+
groupEnd: true,
|
|
16
|
+
info: true,
|
|
17
|
+
table: true,
|
|
18
|
+
time: true,
|
|
19
|
+
timeEnd: true,
|
|
20
|
+
timeLog: true,
|
|
21
|
+
trace: true,
|
|
22
|
+
warn: true
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Run `program` so a help document printed together with parse errors goes to
|
|
26
|
+
* stderr, beside the errors, instead of stdout.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Core's `Command.runWith` prints a usage error as `Console.log(help)` then
|
|
30
|
+
* `Console.error(errors)`, and an explicit `--help` or a bare group
|
|
31
|
+
* invocation as the same `Console.log(help)` with nothing after it. The two
|
|
32
|
+
* only differ in what follows, so the help is held: the Formatter records
|
|
33
|
+
* every string its `formatHelpDoc` and `formatErrors` return, and a `log` of
|
|
34
|
+
* a recorded help string waits for the next console call. An `error` of a
|
|
35
|
+
* recorded errors string moves it to stderr; anything else, or the program
|
|
36
|
+
* ending, releases it to stdout. At most one document is held, and output
|
|
37
|
+
* order is kept.
|
|
38
|
+
*
|
|
39
|
+
* A Formatter or Console provided inside `program` is not wrapped, so help
|
|
40
|
+
* stays on stdout there: core's own behaviour.
|
|
41
|
+
*
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
const routeHelpOnUsageError = (program) => Effect.gen(function* () {
|
|
45
|
+
const formatter = yield* CliOutput.Formatter;
|
|
46
|
+
const sink = yield* Console.Console;
|
|
47
|
+
const helps = /* @__PURE__ */ new Set();
|
|
48
|
+
const errors = /* @__PURE__ */ new Set();
|
|
49
|
+
let held;
|
|
50
|
+
const release = () => {
|
|
51
|
+
if (held === void 0) return;
|
|
52
|
+
const help = held;
|
|
53
|
+
held = void 0;
|
|
54
|
+
sink.log(...help);
|
|
55
|
+
};
|
|
56
|
+
const recording = Object.assign(Object.create(formatter), {
|
|
57
|
+
formatHelpDoc: (doc) => {
|
|
58
|
+
const text = formatter.formatHelpDoc(doc);
|
|
59
|
+
helps.add(text);
|
|
60
|
+
return text;
|
|
61
|
+
},
|
|
62
|
+
formatErrors: (list) => {
|
|
63
|
+
const text = formatter.formatErrors(list);
|
|
64
|
+
errors.add(text);
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const routing = Object.create(sink);
|
|
69
|
+
for (const method of OTHER_METHODS) routing[method] = (...args) => {
|
|
70
|
+
release();
|
|
71
|
+
return sink[method](...args);
|
|
72
|
+
};
|
|
73
|
+
routing.log = (...args) => {
|
|
74
|
+
release();
|
|
75
|
+
if (args.length === 1 && typeof args[0] === "string" && helps.has(args[0])) held = args;
|
|
76
|
+
else sink.log(...args);
|
|
77
|
+
};
|
|
78
|
+
routing.error = (...args) => {
|
|
79
|
+
if (held !== void 0 && args.length === 1 && typeof args[0] === "string" && errors.has(args[0])) {
|
|
80
|
+
const help = held;
|
|
81
|
+
held = void 0;
|
|
82
|
+
sink.error(...help);
|
|
83
|
+
} else release();
|
|
84
|
+
sink.error(...args);
|
|
85
|
+
};
|
|
86
|
+
return yield* program.pipe(Effect.provideService(CliOutput.Formatter, recording), Effect.provideService(Console.Console, routing), Effect.ensuring(Effect.sync(release)));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
export { routeHelpOnUsageError };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/internal/isExitCode.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether `code` is an exit status a POSIX process can carry: an integer in
|
|
4
|
+
* `0..255`. `NaN`, fractions and out-of-range values all fail — a relational
|
|
5
|
+
* check alone would admit `NaN`.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
const isExitCode = (code) => Number.isInteger(code) && code >= 0 && code <= 255;
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
export { isExitCode };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The boundary layer of an effect/unstable/cli program: plain CLI output, failure reporting, and schema-issue rendering",
|
|
6
6
|
"keywords": [
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"import": "./index.js",
|
|
33
33
|
"default": "./index.js"
|
|
34
34
|
},
|
|
35
|
+
"./testing": {
|
|
36
|
+
"types": "./testing.d.ts",
|
|
37
|
+
"import": "./testing.js",
|
|
38
|
+
"default": "./testing.js"
|
|
39
|
+
},
|
|
35
40
|
"./package.json": "./package.json"
|
|
36
41
|
},
|
|
37
42
|
"peerDependencies": {
|
package/testing.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, PlatformError, Scope } from "effect";
|
|
2
|
+
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
3
|
+
//#region src/CliTest.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A hermetic temp directory minted by {@link CliTest.sandbox}, removed when its
|
|
6
|
+
* scope closes.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
interface Sandbox {
|
|
11
|
+
/** The temp directory itself; the default working directory of {@link CliTest.run}. */
|
|
12
|
+
readonly root: string;
|
|
13
|
+
/** The fresh `HOME` inside `root`; the XDG base directories live under it. */
|
|
14
|
+
readonly home: string;
|
|
15
|
+
/**
|
|
16
|
+
* The complete child environment: `HOME`, the four `XDG_*_HOME` variables,
|
|
17
|
+
* the injected `PATH` and `NO_COLOR=1`. Nothing is inherited from the host.
|
|
18
|
+
*/
|
|
19
|
+
readonly env: Readonly<Record<string, string>>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* How {@link CliTest.run} spawns a bin.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
interface RunOptions {
|
|
27
|
+
/** The sandbox whose environment and root the child runs in. */
|
|
28
|
+
readonly sandbox: Sandbox;
|
|
29
|
+
/** The node binary — pass `process.execPath` from the test file, never a PATH lookup. */
|
|
30
|
+
readonly execPath: string;
|
|
31
|
+
/** The child's working directory. Defaults to the sandbox `root`. */
|
|
32
|
+
readonly cwd?: string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Extra environment variables, merged over the sandbox environment — the
|
|
35
|
+
* way to override `PATH` for one run.
|
|
36
|
+
*/
|
|
37
|
+
readonly env?: Readonly<Record<string, string>> | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Text written to the child's stdin, which is then closed. Omitted or `""`,
|
|
40
|
+
* the child gets an already-ended empty input, never an open pipe.
|
|
41
|
+
*/
|
|
42
|
+
readonly stdin?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What a spawned bin did, as data: a non-zero exit is a result, not a failure.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
interface RunResult {
|
|
50
|
+
/** The child's exit code. */
|
|
51
|
+
readonly exitCode: number;
|
|
52
|
+
/** Everything the child wrote to stdout, decoded as UTF-8. */
|
|
53
|
+
readonly stdout: string;
|
|
54
|
+
/** Everything the child wrote to stderr, decoded as UTF-8. */
|
|
55
|
+
readonly stderr: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Spawn a built CLI bin hermetically and read its exit code and streams as data.
|
|
59
|
+
*
|
|
60
|
+
* @public
|
|
61
|
+
*/
|
|
62
|
+
export declare class CliTest {
|
|
63
|
+
private constructor();
|
|
64
|
+
/**
|
|
65
|
+
* A scoped temp directory with a fresh `HOME` and XDG tree, `NO_COLOR=1`,
|
|
66
|
+
* and the `PATH` you pass — the host environment is never inherited.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* Each call mints a fresh temp directory; bind the result to a `const`
|
|
70
|
+
* within one test rather than calling this more than once per assertion.
|
|
71
|
+
*/
|
|
72
|
+
static readonly sandbox: (options: {
|
|
73
|
+
/**
|
|
74
|
+
* The `PATH` the child sees, passed explicitly because nothing else is
|
|
75
|
+
* inherited — `process.env.PATH` when the bin shells out to host tools,
|
|
76
|
+
* a narrower list to prove it does not.
|
|
77
|
+
*/
|
|
78
|
+
readonly path: string;
|
|
79
|
+
}) => Effect.Effect<Sandbox, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path | Scope.Scope>;
|
|
80
|
+
/**
|
|
81
|
+
* Run `execPath bin ...args`; a non-zero exit is returned, never failed.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* `stdin` is never left as an inherited open pipe: when omitted or `""`
|
|
85
|
+
* the child gets an already-ended empty input (`Stream.empty`), so a
|
|
86
|
+
* stdin-reading bin exits instead of hanging on `effect/unstable/process`'s
|
|
87
|
+
* default `"pipe"` stdio, which stays open until something writes to and
|
|
88
|
+
* ends it.
|
|
89
|
+
*/
|
|
90
|
+
static readonly run: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<RunResult, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export type { RunOptions, RunResult, Sandbox };
|
|
94
|
+
//# sourceMappingURL=testing.d.ts.map
|
package/testing.js
ADDED