@effected/cli 0.7.0 → 0.8.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 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") // stdout, no timestamp
44
- * yield* Effect.logError("one failed") // stderr
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 ?? "Error";
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,14 @@
1
- import { Cause, Effect, Runtime } from "effect";
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 { Cause, Effect, MutableRef, Runtime } from "effect";
6
+ import { CliError } from "effect/unstable/cli";
2
7
 
3
8
  //#region src/CliRuntime.ts
9
+ const isShowHelp = (u) => CliError.isCliError(u) && u._tag === "ShowHelp";
10
+ /** A `UserError` `Command.runWith` already printed: it sets the mark to `false` after rendering. */
11
+ const isRenderedUserError = (u) => CliError.isCliError(u) && u._tag === "UserError" && Runtime.getErrorReported(u) === false;
4
12
  const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
5
13
  /**
6
14
  * The error's own exit code when it carries one, otherwise the fallback.
@@ -70,6 +78,23 @@ const chooseExitCode = (error, fallback) => typeof error === "object" && error !
70
78
  * An **interrupt is left alone**: it is not a failure to report, and the
71
79
  * default teardown already maps an interrupt-only cause to `130`.
72
80
  *
81
+ * A `CliError.ShowHelp` is never rendered: `Command.runWith` already printed
82
+ * the help text (and any parse errors) before re-failing with it, so
83
+ * rendering it again would print nothing but a stray "Help requested" line.
84
+ * A `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`,
85
+ * BSD `EX_USAGE`); a bare `--help` or root invocation — `errors` empty —
86
+ * keeps exit `0`. A `CliError.UserError` whose `Runtime.errorReported` mark
87
+ * is `false` is likewise skipped: that is how `Command.runWith` leaves one it
88
+ * already rendered through its `CliOutput` formatter. It exits with its own
89
+ * `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
90
+ * under runWith's `renderErrors: false` the mark stays set, and it renders
91
+ * here like any other failure. Every other
92
+ * error renders even when it already carries the reported mark — a gate
93
+ * failure marked with `CliRuntime.reported` still prints its line. The
94
+ * private `ExitRequested` sentinel `CliRuntime.main`
95
+ * raises is likewise never rendered; it only carries the exit code a
96
+ * successful program recorded through `CliExit`.
97
+ *
73
98
  * @public
74
99
  */
75
100
  var CliRuntime = class CliRuntime {
@@ -81,12 +106,46 @@ var CliRuntime = class CliRuntime {
81
106
  static reportFailures = (options = {}) => (effect) => effect.pipe(Effect.catchCause((cause) => {
82
107
  if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
83
108
  const error = Cause.squash(cause);
109
+ if (error instanceof ExitRequested) return Effect.fail(error);
110
+ if (isShowHelp(error)) {
111
+ const code = error.errors.length > 0 ? options.usageExitCode ?? 64 : 0;
112
+ return Effect.fail(CliRuntime.reported(error, code));
113
+ }
114
+ if (isRenderedUserError(error)) return Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.usageExitCode ?? 64)));
84
115
  const render = options.render ?? ((value) => String(value));
85
116
  return Effect.gen(function* () {
86
117
  for (const line of toLines(render(error))) yield* Effect.logError(line);
87
118
  return yield* Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode)));
88
119
  });
89
120
  }));
121
+ /**
122
+ * Assemble a CLI program in the one order that reports every failure well.
123
+ *
124
+ * @remarks
125
+ * - `CliExit` is provided fresh, and a non-zero code after success becomes a
126
+ * marked failure the teardown honours.
127
+ * - The platform layer is provided **inside** failure reporting, so a
128
+ * layer-build failure (`HOME` unset, say) renders as one line with the
129
+ * fallback code rather than escaping to the runtime's stack trace.
130
+ * - The logger is provided **outermost**, so it is present whichever branch
131
+ * fails.
132
+ *
133
+ * You still call your platform's runner:
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * NodeRuntime.runMain(
138
+ * CliRuntime.main(Command.run(root, { version }), { platform: NodeServices.layer, exitCode: 3 }),
139
+ * )
140
+ * ```
141
+ */
142
+ static main = (program, options) => Effect.gen(function* () {
143
+ yield* program;
144
+ const exit = yield* CliExit;
145
+ const code = MutableRef.get(exit.code);
146
+ if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
147
+ if (code !== 0) return yield* Effect.fail(new ExitRequested(code));
148
+ }).pipe(Effect.provide(CliExit.layer), Effect.provide(options.platform), CliRuntime.reportFailures(options), Effect.provide(options.logger ?? CliLogger.layer()));
90
149
  static reported(error, exitCode = 1) {
91
150
  const marked = error instanceof Error ? error : new Error(String(error));
92
151
  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
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](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.log("building 3 packages");
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: building 3 packages
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 the diagnostic line never lands on stdout.
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 `Error` and above to stderr. The threshold is the `stderrFrom` option, compared ordinally, so a level added upstream lands on the right stream without a change here.
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.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`.
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. 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.
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 { 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 `"Error"`,
20
- * so `Error` and `Fatal` are diagnostics and everything else is output.
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") // stdout, no timestamp
63
- * yield* Effect.logError("one failed") // stderr
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 {
@@ -111,9 +242,40 @@ interface ReportFailuresOptions {
111
242
  *
112
243
  * @remarks
113
244
  * An error carrying `Runtime.errorExitCode` keeps its own; this is only the
114
- * fallback, and it defaults to `1`.
245
+ * fallback, and it defaults to `1`. Pass an integer in `0..255`, the range a
246
+ * POSIX exit status can carry — `256` wraps to `0` and passes a failed run.
115
247
  */
116
248
  readonly exitCode?: number | undefined;
249
+ /**
250
+ * The exit code for a usage error: a `ShowHelp` carrying parse errors, or a
251
+ * `CliError.UserError` `Command.runWith` already printed.
252
+ *
253
+ * @remarks
254
+ * Defaults to `64` (BSD `EX_USAGE`); pass an integer in `0..255`. A
255
+ * `ShowHelp` with no errors — a bare root invocation, or `--help` — always
256
+ * exits `0`. A `UserError` that carries its own `Runtime.errorExitCode` —
257
+ * one marked with `CliRuntime.reported(error, 3)` — keeps that code instead.
258
+ *
259
+ * Keep `Command.runWith`'s default `renderErrors`: with `renderErrors: false`
260
+ * runWith prints no parse errors and `reportFailures` never renders a
261
+ * `ShowHelp`, so a parse error would exit with this code having printed
262
+ * nothing on stderr.
263
+ */
264
+ readonly usageExitCode?: number | undefined;
265
+ }
266
+ /**
267
+ * Options for {@link CliRuntime.main}.
268
+ *
269
+ * @public
270
+ */
271
+ interface MainOptions<RP, EP> extends ReportFailuresOptions {
272
+ /**
273
+ * The platform layer, usually `NodeServices.layer` or an app platform built
274
+ * on it. Passed in so this package never imports a platform.
275
+ */
276
+ readonly platform: Layer.Layer<RP, EP>;
277
+ /** The logger, provided outermost. Defaults to `CliLogger.layer()`. */
278
+ readonly logger?: Layer.Layer<never> | undefined;
117
279
  }
118
280
  /**
119
281
  * Report a CLI program's failures through the program's own logger.
@@ -173,6 +335,23 @@ interface ReportFailuresOptions {
173
335
  * An **interrupt is left alone**: it is not a failure to report, and the
174
336
  * default teardown already maps an interrupt-only cause to `130`.
175
337
  *
338
+ * A `CliError.ShowHelp` is never rendered: `Command.runWith` already printed
339
+ * the help text (and any parse errors) before re-failing with it, so
340
+ * rendering it again would print nothing but a stray "Help requested" line.
341
+ * A `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`,
342
+ * BSD `EX_USAGE`); a bare `--help` or root invocation — `errors` empty —
343
+ * keeps exit `0`. A `CliError.UserError` whose `Runtime.errorReported` mark
344
+ * is `false` is likewise skipped: that is how `Command.runWith` leaves one it
345
+ * already rendered through its `CliOutput` formatter. It exits with its own
346
+ * `Runtime.errorExitCode` when it carries one, otherwise `usageExitCode`;
347
+ * under runWith's `renderErrors: false` the mark stays set, and it renders
348
+ * here like any other failure. Every other
349
+ * error renders even when it already carries the reported mark — a gate
350
+ * failure marked with `CliRuntime.reported` still prints its line. The
351
+ * private `ExitRequested` sentinel `CliRuntime.main`
352
+ * raises is likewise never rendered; it only carries the exit code a
353
+ * successful program recorded through `CliExit`.
354
+ *
176
355
  * @public
177
356
  */
178
357
  export declare class CliRuntime {
@@ -182,6 +361,28 @@ export declare class CliRuntime {
182
361
  * and the no-double-report mark.
183
362
  */
184
363
  static readonly reportFailures: (options?: ReportFailuresOptions) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, Error, R>;
364
+ /**
365
+ * Assemble a CLI program in the one order that reports every failure well.
366
+ *
367
+ * @remarks
368
+ * - `CliExit` is provided fresh, and a non-zero code after success becomes a
369
+ * marked failure the teardown honours.
370
+ * - The platform layer is provided **inside** failure reporting, so a
371
+ * layer-build failure (`HOME` unset, say) renders as one line with the
372
+ * fallback code rather than escaping to the runtime's stack trace.
373
+ * - The logger is provided **outermost**, so it is present whichever branch
374
+ * fails.
375
+ *
376
+ * You still call your platform's runner:
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * NodeRuntime.runMain(
381
+ * CliRuntime.main(Command.run(root, { version }), { platform: NodeServices.layer, exitCode: 3 }),
382
+ * )
383
+ * ```
384
+ */
385
+ 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
386
  /**
186
387
  * Mark an error as already reported, carrying an exit code.
187
388
  *
@@ -190,6 +391,21 @@ export declare class CliRuntime {
190
391
  * command that prints its own diagnostics, say — needs the same two marks
191
392
  * and should not have to rediscover the inverted polarity.
192
393
  *
394
+ * Under {@link CliRuntime.main} or {@link CliRuntime.reportFailures}, do NOT
395
+ * print the failure yourself before failing with it: `reportFailures`
396
+ * renders every error except a `ShowHelp` and a `CliError.UserError` whose
397
+ * reported mark is `false`, so it would print twice. Fail with the marked
398
+ * error and put any multi-line rendering in the `render` option instead. The
399
+ * mark matters for a program run WITHOUT `reportFailures`, where it keeps the
400
+ * runtime from reporting a failure the program already printed.
401
+ *
402
+ * A `CliError.UserError` marked with `reported` is treated as already
403
+ * printed and is not rendered — use a different error type if the program
404
+ * has not printed it. `reportFailures` cannot tell a `UserError` that
405
+ * `Command.runWith` printed from one marked here: both carry the same `false`
406
+ * mark. It does keep the code you pass: `reported(userError, 3)` exits `3`,
407
+ * not `usageExitCode`.
408
+ *
193
409
  * The marks are added in place, so a typed error comes back as its own
194
410
  * type: the `E` overload returns the very instance it was given, and a
195
411
  * program failing with it keeps `catchTags` narrowing downstream without a
@@ -327,5 +543,5 @@ export declare class SchemaIssueRenderer {
327
543
  static readonly render: (issue: unknown) => ReadonlyArray<string>;
328
544
  }
329
545
  //#endregion
330
- export type { CliLoggerOptions, ReportFailuresOptions };
546
+ export type { CliExitShape, CliLoggerOptions, MainOptions, ReportFailuresOptions };
331
547
  //# 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,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.7.0",
3
+ "version": "0.8.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
@@ -0,0 +1,3 @@
1
+ import { CliTest } from "./CliTest.js";
2
+
3
+ export { CliTest };