@effected/cli 0.1.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/CliLogger.js ADDED
@@ -0,0 +1,83 @@
1
+ import { Console, LogLevel, Logger, References } from "effect";
2
+
3
+ //#region src/CliLogger.ts
4
+ const defaultRender = (message) => Array.isArray(message) ? message.map(String).join(" ") : String(message);
5
+ /**
6
+ * A `Logger` that renders CLI output rather than service logs.
7
+ *
8
+ * @remarks
9
+ * Effect's default logger emits `[00:33:56.619] INFO (#2): message`. That is
10
+ * the right shape for a long-running service being scraped and the wrong one
11
+ * for a tool a person is watching: the timestamp, level and fiber id are noise
12
+ * in front of output a human is reading, and they make a formatted block — a
13
+ * permissions table, a summary — unreadable.
14
+ *
15
+ * **This is not a preference, and it is not visible from the call site.** A
16
+ * program that never installs a CLI logger looks correct in review and ships
17
+ * timestamps to its users; the consumer this package was extracted from had it
18
+ * missing for a day while its own docs claimed it existed.
19
+ *
20
+ * ## Why the `Console` reference, and not `Stdio`
21
+ *
22
+ * The obvious design — write through `Stdio`'s `stdout()` / `stderr()` sinks —
23
+ * does not fit. `Logger.make` takes a **synchronous** callback and a `Sink`
24
+ * write is an `Effect`: a logger cannot `yield*`.
25
+ *
26
+ * Writing to `process.stdout` would fit, and is what the consumer did first,
27
+ * but it drags a platform assumption into a library and — the part that
28
+ * actually matters — makes the stream split **untestable**, because asserting
29
+ * it means monkey-patching a global inside a runner that is itself writing to
30
+ * those streams.
31
+ *
32
+ * So this takes the path core's own `defaultLogger` takes: read the `Console`
33
+ * off the fiber, synchronously. `Console.Console` is a public
34
+ * `Context.Reference`, so it carries a default and never appears in `R`, and a
35
+ * test swaps the reference instead of stubbing a global.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { CliLogger } from "@effected/cli"
40
+ * import { Effect } from "effect"
41
+ *
42
+ * const program = Effect.gen(function* () {
43
+ * yield* Effect.log("synced 3 repos") // stdout, no timestamp
44
+ * yield* Effect.logError("one failed") // stderr
45
+ * })
46
+ *
47
+ * program.pipe(Effect.provide(CliLogger.layer()))
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ var CliLogger = class CliLogger {
53
+ constructor() {}
54
+ /**
55
+ * The logger itself, for composing into an existing `Logger.layer` set.
56
+ *
57
+ * @remarks
58
+ * Prefer {@link CliLogger.layer}. Reach for this only when you are building
59
+ * the logger set yourself and want this one among several.
60
+ */
61
+ static make = (options = {}) => {
62
+ const render = options.render ?? defaultRender;
63
+ const stderrFrom = options.stderrFrom ?? "Error";
64
+ return Logger.make(({ fiber, logLevel, message }) => {
65
+ const console = fiber.getRef(Console.Console);
66
+ (fiber.getRef(References.LogToStderr) || LogLevel.isGreaterThanOrEqualTo(logLevel, stderrFrom) ? console.error : console.log)(render(message));
67
+ });
68
+ };
69
+ /**
70
+ * Replace the default logger with this one.
71
+ *
72
+ * @remarks
73
+ * `Logger.layer` **replaces** rather than merges, so nothing is emitted twice.
74
+ *
75
+ * Merge this into the layer you provide to the whole program rather than
76
+ * providing it beneath: merged, it also covers lines emitted during layer
77
+ * construction, which is exactly where a startup failure prints.
78
+ */
79
+ static layer = (options = {}) => Logger.layer([CliLogger.make(options)]);
80
+ };
81
+
82
+ //#endregion
83
+ export { CliLogger };
package/CliRuntime.js ADDED
@@ -0,0 +1,108 @@
1
+ import { Cause, Effect, Runtime } from "effect";
2
+
3
+ //#region src/CliRuntime.ts
4
+ const toLines = (rendered) => typeof rendered === "string" ? [rendered] : rendered;
5
+ /**
6
+ * The error's own exit code when it carries one, otherwise the fallback.
7
+ *
8
+ * @remarks
9
+ * `Runtime.getErrorExitCode` cannot serve alone here: it answers `1` both for
10
+ * an error marked `1` and for an unmarked one, so an `exitCode` option would
11
+ * silently override a deliberate `1`. Testing for the marker keeps "the error
12
+ * chose its code" distinct from "nothing chose".
13
+ */
14
+ const chooseExitCode = (error, fallback) => typeof error === "object" && error !== null && Runtime.errorExitCode in error ? Runtime.getErrorExitCode(error) : fallback ?? 1;
15
+ /**
16
+ * Report a CLI program's failures through the program's own logger.
17
+ *
18
+ * @remarks
19
+ * ## The bug this exists to prevent
20
+ *
21
+ * A platform `runMain` reports an unhandled failure using Effect's **default**
22
+ * logger. That logger sits **outside** the layers the program was provided —
23
+ * `makeRunMain` composes its reporting `tapCause` around the already-provided
24
+ * effect — so a program that carefully installs {@link CliLogger} still prints
25
+ * its failures in the structured format that logger exists to replace, and
26
+ * prints them on **stdout**, the one stream errors must not use.
27
+ *
28
+ * Nothing about the call site suggests this. The program looks correct, the
29
+ * logger is installed, every success path is right, and only a failure reveals
30
+ * it.
31
+ *
32
+ * ## Why a combinator, and not a `runMain`
33
+ *
34
+ * The fix has to happen **inside** the effect, before any `runMain` sees it. So
35
+ * this is a combinator you apply to your program, and you still call your own
36
+ * platform's runner:
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { CliRuntime } from "@effected/cli"
41
+ * import { NodeRuntime } from "@effect/platform-node"
42
+ * import { Effect } from "effect"
43
+ *
44
+ * NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)))
45
+ * ```
46
+ *
47
+ * Wrapping `runMain` itself would drag a platform choice into a library that
48
+ * has no business making one, and would make this package unusable from Bun or
49
+ * Deno for no gain.
50
+ *
51
+ * ## What it does with the failure
52
+ *
53
+ * Renders it through the ambient logger, then **re-fails with a marked error**
54
+ * so the runtime still sees a failure — the exit code is the runtime's to set,
55
+ * and swallowing the failure would make a broken run exit `0`.
56
+ *
57
+ * The marks are core's own, and they are what make this work without a platform
58
+ * import:
59
+ *
60
+ * - `Runtime.errorExitCode` — `defaultTeardown` takes the squashed error's
61
+ * value as the process exit code. An error that already carries one **keeps
62
+ * it**: that was a deliberate choice by whatever raised it, and flattening
63
+ * every failure to a single code would discard it.
64
+ * - `Runtime.errorReported` — set to `false`, which suppresses the runtime's own
65
+ * duplicate report. **The polarity is inverted relative to the name**: the
66
+ * marker means "should this be reported", so the intuitive
67
+ * `errorReported: true` — "I have reported it, stay quiet" — produces exactly
68
+ * the double report it was meant to prevent.
69
+ *
70
+ * An **interrupt is left alone**: it is not a failure to report, and the
71
+ * default teardown already maps an interrupt-only cause to `130`.
72
+ *
73
+ * @public
74
+ */
75
+ var CliRuntime = class CliRuntime {
76
+ constructor() {}
77
+ /**
78
+ * Catch, render through the ambient logger, and re-fail with the exit code
79
+ * and the no-double-report mark.
80
+ */
81
+ static reportFailures = (options = {}) => (effect) => effect.pipe(Effect.catchCause((cause) => {
82
+ if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
83
+ const error = Cause.squash(cause);
84
+ const render = options.render ?? ((value) => String(value));
85
+ return Effect.gen(function* () {
86
+ for (const line of toLines(render(error))) yield* Effect.logError(line);
87
+ return yield* Effect.fail(CliRuntime.reported(error, chooseExitCode(error, options.exitCode)));
88
+ });
89
+ }));
90
+ /**
91
+ * Mark an error as already reported, carrying an exit code.
92
+ *
93
+ * @remarks
94
+ * Exported because a program that reports a failure itself — a validation
95
+ * command that prints its own diagnostics, say — needs the same two marks
96
+ * and should not have to rediscover the inverted polarity.
97
+ */
98
+ static reported = (error, exitCode = 1) => {
99
+ const marked = error instanceof Error ? error : new Error(String(error));
100
+ return Object.assign(marked, {
101
+ [Runtime.errorReported]: false,
102
+ [Runtime.errorExitCode]: exitCode
103
+ });
104
+ };
105
+ };
106
+
107
+ //#endregion
108
+ export { CliRuntime };
@@ -0,0 +1,77 @@
1
+ import { formatIssue } from "./internal/format.js";
2
+
3
+ //#region src/ConfigIssueRenderer.ts
4
+ /**
5
+ * Render a `@effected/config-file` validation failure.
6
+ *
7
+ * @remarks
8
+ * `ConfigValidationError` carries the structured `issue` tree rather than a
9
+ * string, which is the right design and leaves the consumer holding a tree it
10
+ * has to turn into sentences. This is that step, and it is the same treatment
11
+ * {@link SchemaIssueRenderer} gives a bare issue.
12
+ *
13
+ * **This module is the only thing in the package that references
14
+ * `@effected/config-file`, deliberately.** The peer is declared optional, and
15
+ * an optional peer whose import is reachable from a shared module is not
16
+ * optional — it is a crash for every consumer who took the manifest at its word
17
+ * and did not install it. So nothing else in this package imports this module;
18
+ * only the entrypoint re-exports it, and the shared rendering lives in
19
+ * `internal/format`.
20
+ *
21
+ * The import is additionally `import type`, so it is erased at build time and
22
+ * the runtime reach is **zero** — a consumer without `@effected/config-file`
23
+ * installed can import this module and call `render` on any value without the
24
+ * resolver ever being asked for the package.
25
+ *
26
+ * ## Why this and not just the error's message
27
+ *
28
+ * `ConfigValidationError.message` names the file. It does not name the value,
29
+ * and the difference is the whole diagnostic: a consumer that printed only the
30
+ * message told users "your config is invalid, run the doctor command", and the
31
+ * doctor command — which guessed from a hand-written list of known keys — could
32
+ * only ever find a *misspelling*. A wrongly **shaped** value left the two
33
+ * commands pointing at each other and neither saying what was wrong.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { ConfigIssueRenderer } from "@effected/cli"
38
+ * import { Effect } from "effect"
39
+ *
40
+ * const load = configFile.load.pipe(
41
+ * Effect.catchTag("ConfigValidationError", (error) =>
42
+ * Effect.gen(function* () {
43
+ * yield* Effect.logError(String(error))
44
+ * for (const line of ConfigIssueRenderer.render(error)) yield* Effect.logError(` ${line}`)
45
+ * }),
46
+ * ),
47
+ * )
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ var ConfigIssueRenderer = class {
53
+ constructor() {}
54
+ /**
55
+ * One line per rejected value.
56
+ *
57
+ * @remarks
58
+ * Takes the **error**, not its `issue`, because that is what a `catchTag`
59
+ * hands you and because `issue` is typed `Schema.Defect` — reaching into it
60
+ * at every call site is exactly the ceremony this removes.
61
+ *
62
+ * The parameter is the **typed** error rather than `unknown`. An earlier
63
+ * draft wrote `ConfigValidationError | unknown` to be accommodating, which
64
+ * collapses to plain `unknown` in TypeScript — so it accepted anything, said
65
+ * nothing, and left the type import in the `.d.ts` earning nothing. Inside
66
+ * `Effect.catchTag("ConfigValidationError", …)` the error is already this
67
+ * type, which is where this is called.
68
+ *
69
+ * It still cannot throw on a malformed value: the issue tree is validated by
70
+ * a guard before it is read, so a renderer on an error path never becomes the
71
+ * reason a program dies.
72
+ */
73
+ static render = (error) => formatIssue(error?.issue);
74
+ };
75
+
76
+ //#endregion
77
+ export { ConfigIssueRenderer };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @effected/cli
2
+
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fcli?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/cli)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
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`.
9
+
10
+ > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
+ > development against a single pinned Effect v4 beta. Packages graduate to
12
+ > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
+ > exactly the ones the kit is built and tested against, install
14
+ > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
+ >
16
+ > **Stability: unstable.** This package's API surface is not yet considered
17
+ > complete and may change across `0.x` releases. Pin an exact version — even a
18
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
19
+ > accident, and an exact pin turns that into a type-check error rather than a
20
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
+
22
+ ## Why @effected/cli
23
+
24
+ Everything here shares one property: **you only discover you needed it by shipping bad output to a person.** None of it fails a type-check, a test, or a review of the code in isolation.
25
+
26
+ Effect's default logger emits `[00:33:56.619] INFO (#2): message`. That is correct for a service being scraped and wrong for a tool someone is watching — it turns a formatted table into noise — and nothing at the call site suggests it. A platform `runMain` then reports an unhandled failure through that *same* default logger, which sits outside the layers your program was provided, so a program that carefully installs a CLI logger still prints its failures in the format that logger exists to replace, on **stdout**, the one stream errors must not use. And a decode failure arrives as a structured tree when what a user needs is a sentence naming the key they got wrong; core does ship formatters for this, but they live on `SchemaIssue` rather than `SchemaError`, are named `makeFormatter*`, and are not referenced by `SchemaError.message` — two engineers searched for two rounds and concluded they did not exist.
27
+
28
+ This package is **not a CLI framework**. `effect/unstable/cli` owns argument parsing, flags, the command tree and help, and this package must never grow a second one.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install @effected/cli effect
34
+ ```
35
+
36
+ ```bash
37
+ pnpm add @effected/cli effect
38
+ ```
39
+
40
+ Requires Node.js >=24.11.0. `effect` v4 is a peer dependency.
41
+
42
+ All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
43
+
44
+ `@effected/config-file` is an **optional** peer, needed only for `ConfigIssueRenderer`. It lives in its own module and is imported as a type, so nothing at runtime reaches for it.
45
+
46
+ ## Quick start
47
+
48
+ ```ts
49
+ import { CliLogger, CliRuntime } from "@effected/cli";
50
+ import { NodeRuntime } from "@effect/platform-node";
51
+ import { Effect, Layer } from "effect";
52
+
53
+ declare const AppLive: Layer.Layer<never>;
54
+
55
+ const program = Effect.gen(function* () {
56
+ yield* Effect.log("building 3 packages");
57
+ yield* Effect.logError("nothing to build");
58
+ });
59
+
60
+ // Merged, not provided beneath: this way it also covers lines emitted during
61
+ // layer construction, which is exactly where a startup failure prints.
62
+ const MainLive = Layer.mergeAll(AppLive, CliLogger.layer());
63
+
64
+ NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)));
65
+ // stdout: building 3 packages
66
+ // stderr: nothing to build
67
+ // No timestamp, no level, no fiber id — and the diagnostic line never lands on stdout.
68
+ ```
69
+
70
+ Rendering a bad config into something actionable:
71
+
72
+ ```ts
73
+ import { CliRuntime, ConfigIssueRenderer } from "@effected/cli";
74
+ import { Effect } from "effect";
75
+
76
+ configFile.load.pipe(
77
+ Effect.catchTag("ConfigValidationError", (error) =>
78
+ Effect.gen(function* () {
79
+ yield* Effect.logError(String(error));
80
+ for (const line of ConfigIssueRenderer.render(error)) yield* Effect.logError(` ${line}`);
81
+
82
+ // Re-fail, or the handler SUCCEEDS and a CLI exits 0 on invalid config.
83
+ // `reported` carries the exit code and the mark that stops the runtime
84
+ // printing the same failure a second time.
85
+ return yield* Effect.fail(CliRuntime.reported(error));
86
+ }),
87
+ ),
88
+ );
89
+ ```
90
+
91
+ ```text
92
+ ConfigValidationError: Config validation failed at "/home/me/.config/app/config.toml"
93
+ unknown key at variables.keep.KEEP_ME
94
+ Missing key at variables.keep.file
95
+ Missing key at variables.keep.value
96
+ Missing key at variables.keep.resolved
97
+ ```
98
+
99
+ ## Features
100
+
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.
102
+ - `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.
105
+ - `SchemaIssueRenderer.render(issue)` — a `SchemaIssue` tree becomes one line per rejected value.
106
+ - `ConfigIssueRenderer.render(error)` — the same rendering, reading `issue` off a `ConfigValidationError`.
107
+
108
+ Two behaviours worth knowing before you rely on them:
109
+
110
+ - `CliLogger` honours `References.LogToStderr` as a **one-way** override — it can force everything to stderr, and can never move an error onto stdout.
111
+ - `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
+
113
+ ## License
114
+
115
+ [MIT](LICENSE)
@@ -0,0 +1,51 @@
1
+ import { formatIssue } from "./internal/format.js";
2
+
3
+ //#region src/SchemaIssueRenderer.ts
4
+ /**
5
+ * Turn a `SchemaIssue` tree into lines a user can act on.
6
+ *
7
+ * @remarks
8
+ * A decode failure arrives as a structured tree; a person needs
9
+ * `unknown key at groups.g.cleanup.rulesetz`.
10
+ *
11
+ * **Core already ships the formatters this wraps, and they are effectively
12
+ * undiscoverable.** They live on `SchemaIssue` rather than `SchemaError` or
13
+ * `Schema`, they are named `makeFormatter*` rather than anything containing
14
+ * "render" or "format issue", and `SchemaError.message` does not use them — so
15
+ * the obvious probe, printing the error, hints at nothing. Two engineers
16
+ * searched for two rounds and concluded core had none. This export exists to
17
+ * end that search, and the one phrasing override is a bonus rather than the
18
+ * point.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { SchemaIssueRenderer } from "@effected/cli"
23
+ * import { Effect, Schema } from "effect"
24
+ *
25
+ * const result = Schema.decodeUnknownEffect(MySchema)(input, {
26
+ * onExcessProperty: "error",
27
+ * errors: "all",
28
+ * })
29
+ *
30
+ * const reported = result.pipe(
31
+ * Effect.catchTag("SchemaError", (error) =>
32
+ * Effect.forEach(SchemaIssueRenderer.render(error.issue), (line) => Effect.logError(` ${line}`)),
33
+ * ),
34
+ * )
35
+ * ```
36
+ *
37
+ * @public
38
+ */
39
+ var SchemaIssueRenderer = class {
40
+ constructor() {}
41
+ /**
42
+ * One line per rejected value, deepest path last.
43
+ *
44
+ * @param issue - a `SchemaIssue` tree, or any value
45
+ * @returns the lines, or empty when `issue` is not an issue tree
46
+ */
47
+ static render = (issue) => formatIssue(issue);
48
+ };
49
+
50
+ //#endregion
51
+ export { SchemaIssueRenderer };
package/index.d.ts ADDED
@@ -0,0 +1,317 @@
1
+ import { Effect, Layer, LogLevel, Logger } from "effect";
2
+ import { ConfigValidationError } from "@effected/config-file";
3
+ //#region src/CliLogger.d.ts
4
+ /**
5
+ * How a log record is turned into a line.
6
+ *
7
+ * @public
8
+ */
9
+ interface CliLoggerOptions {
10
+ /**
11
+ * Render one message. Defaults to joining an array with spaces and
12
+ * `String`-ing anything else.
13
+ *
14
+ * @remarks
15
+ * An array arrives because `Effect.log("synced", 3, "repos")` is variadic.
16
+ */
17
+ readonly render?: ((message: unknown) => string) | undefined;
18
+ /**
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.
21
+ */
22
+ readonly stderrFrom?: LogLevel.LogLevel | undefined;
23
+ }
24
+ /**
25
+ * A `Logger` that renders CLI output rather than service logs.
26
+ *
27
+ * @remarks
28
+ * Effect's default logger emits `[00:33:56.619] INFO (#2): message`. That is
29
+ * the right shape for a long-running service being scraped and the wrong one
30
+ * for a tool a person is watching: the timestamp, level and fiber id are noise
31
+ * in front of output a human is reading, and they make a formatted block — a
32
+ * permissions table, a summary — unreadable.
33
+ *
34
+ * **This is not a preference, and it is not visible from the call site.** A
35
+ * program that never installs a CLI logger looks correct in review and ships
36
+ * timestamps to its users; the consumer this package was extracted from had it
37
+ * missing for a day while its own docs claimed it existed.
38
+ *
39
+ * ## Why the `Console` reference, and not `Stdio`
40
+ *
41
+ * The obvious design — write through `Stdio`'s `stdout()` / `stderr()` sinks —
42
+ * does not fit. `Logger.make` takes a **synchronous** callback and a `Sink`
43
+ * write is an `Effect`: a logger cannot `yield*`.
44
+ *
45
+ * Writing to `process.stdout` would fit, and is what the consumer did first,
46
+ * but it drags a platform assumption into a library and — the part that
47
+ * actually matters — makes the stream split **untestable**, because asserting
48
+ * it means monkey-patching a global inside a runner that is itself writing to
49
+ * those streams.
50
+ *
51
+ * So this takes the path core's own `defaultLogger` takes: read the `Console`
52
+ * off the fiber, synchronously. `Console.Console` is a public
53
+ * `Context.Reference`, so it carries a default and never appears in `R`, and a
54
+ * test swaps the reference instead of stubbing a global.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { CliLogger } from "@effected/cli"
59
+ * import { Effect } from "effect"
60
+ *
61
+ * const program = Effect.gen(function* () {
62
+ * yield* Effect.log("synced 3 repos") // stdout, no timestamp
63
+ * yield* Effect.logError("one failed") // stderr
64
+ * })
65
+ *
66
+ * program.pipe(Effect.provide(CliLogger.layer()))
67
+ * ```
68
+ *
69
+ * @public
70
+ */
71
+ declare class CliLogger {
72
+ private constructor();
73
+ /**
74
+ * The logger itself, for composing into an existing `Logger.layer` set.
75
+ *
76
+ * @remarks
77
+ * Prefer {@link CliLogger.layer}. Reach for this only when you are building
78
+ * the logger set yourself and want this one among several.
79
+ */
80
+ static readonly make: (options?: CliLoggerOptions) => Logger.Logger<unknown, void>;
81
+ /**
82
+ * Replace the default logger with this one.
83
+ *
84
+ * @remarks
85
+ * `Logger.layer` **replaces** rather than merges, so nothing is emitted twice.
86
+ *
87
+ * Merge this into the layer you provide to the whole program rather than
88
+ * providing it beneath: merged, it also covers lines emitted during layer
89
+ * construction, which is exactly where a startup failure prints.
90
+ */
91
+ static readonly layer: (options?: CliLoggerOptions) => Layer.Layer<never>;
92
+ }
93
+ //#endregion
94
+ //#region src/CliRuntime.d.ts
95
+ /**
96
+ * How a failure is turned into output and an exit code.
97
+ *
98
+ * @public
99
+ */
100
+ interface ReportFailuresOptions {
101
+ /**
102
+ * Render the failure. Defaults to `String(error)`, one line.
103
+ *
104
+ * @remarks
105
+ * Return several lines to print several: a config error's own message
106
+ * followed by the rendered issue lines, say.
107
+ */
108
+ readonly render?: ((error: unknown) => string | ReadonlyArray<string>) | undefined;
109
+ /**
110
+ * The exit code to use when the error does not carry one.
111
+ *
112
+ * @remarks
113
+ * An error carrying `Runtime.errorExitCode` keeps its own; this is only the
114
+ * fallback, and it defaults to `1`.
115
+ */
116
+ readonly exitCode?: number | undefined;
117
+ }
118
+ /**
119
+ * Report a CLI program's failures through the program's own logger.
120
+ *
121
+ * @remarks
122
+ * ## The bug this exists to prevent
123
+ *
124
+ * A platform `runMain` reports an unhandled failure using Effect's **default**
125
+ * logger. That logger sits **outside** the layers the program was provided —
126
+ * `makeRunMain` composes its reporting `tapCause` around the already-provided
127
+ * effect — so a program that carefully installs {@link CliLogger} still prints
128
+ * its failures in the structured format that logger exists to replace, and
129
+ * prints them on **stdout**, the one stream errors must not use.
130
+ *
131
+ * Nothing about the call site suggests this. The program looks correct, the
132
+ * logger is installed, every success path is right, and only a failure reveals
133
+ * it.
134
+ *
135
+ * ## Why a combinator, and not a `runMain`
136
+ *
137
+ * The fix has to happen **inside** the effect, before any `runMain` sees it. So
138
+ * this is a combinator you apply to your program, and you still call your own
139
+ * platform's runner:
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * import { CliRuntime } from "@effected/cli"
144
+ * import { NodeRuntime } from "@effect/platform-node"
145
+ * import { Effect } from "effect"
146
+ *
147
+ * NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)))
148
+ * ```
149
+ *
150
+ * Wrapping `runMain` itself would drag a platform choice into a library that
151
+ * has no business making one, and would make this package unusable from Bun or
152
+ * Deno for no gain.
153
+ *
154
+ * ## What it does with the failure
155
+ *
156
+ * Renders it through the ambient logger, then **re-fails with a marked error**
157
+ * so the runtime still sees a failure — the exit code is the runtime's to set,
158
+ * and swallowing the failure would make a broken run exit `0`.
159
+ *
160
+ * The marks are core's own, and they are what make this work without a platform
161
+ * import:
162
+ *
163
+ * - `Runtime.errorExitCode` — `defaultTeardown` takes the squashed error's
164
+ * value as the process exit code. An error that already carries one **keeps
165
+ * it**: that was a deliberate choice by whatever raised it, and flattening
166
+ * every failure to a single code would discard it.
167
+ * - `Runtime.errorReported` — set to `false`, which suppresses the runtime's own
168
+ * duplicate report. **The polarity is inverted relative to the name**: the
169
+ * marker means "should this be reported", so the intuitive
170
+ * `errorReported: true` — "I have reported it, stay quiet" — produces exactly
171
+ * the double report it was meant to prevent.
172
+ *
173
+ * An **interrupt is left alone**: it is not a failure to report, and the
174
+ * default teardown already maps an interrupt-only cause to `130`.
175
+ *
176
+ * @public
177
+ */
178
+ declare class CliRuntime {
179
+ private constructor();
180
+ /**
181
+ * Catch, render through the ambient logger, and re-fail with the exit code
182
+ * and the no-double-report mark.
183
+ */
184
+ static readonly reportFailures: (options?: ReportFailuresOptions) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, Error, R>;
185
+ /**
186
+ * Mark an error as already reported, carrying an exit code.
187
+ *
188
+ * @remarks
189
+ * Exported because a program that reports a failure itself — a validation
190
+ * command that prints its own diagnostics, say — needs the same two marks
191
+ * and should not have to rediscover the inverted polarity.
192
+ */
193
+ static readonly reported: (error: unknown, exitCode?: number) => Error;
194
+ }
195
+ //#endregion
196
+ //#region src/ConfigIssueRenderer.d.ts
197
+ /**
198
+ * Render a `@effected/config-file` validation failure.
199
+ *
200
+ * @remarks
201
+ * `ConfigValidationError` carries the structured `issue` tree rather than a
202
+ * string, which is the right design and leaves the consumer holding a tree it
203
+ * has to turn into sentences. This is that step, and it is the same treatment
204
+ * {@link SchemaIssueRenderer} gives a bare issue.
205
+ *
206
+ * **This module is the only thing in the package that references
207
+ * `@effected/config-file`, deliberately.** The peer is declared optional, and
208
+ * an optional peer whose import is reachable from a shared module is not
209
+ * optional — it is a crash for every consumer who took the manifest at its word
210
+ * and did not install it. So nothing else in this package imports this module;
211
+ * only the entrypoint re-exports it, and the shared rendering lives in
212
+ * `internal/format`.
213
+ *
214
+ * The import is additionally `import type`, so it is erased at build time and
215
+ * the runtime reach is **zero** — a consumer without `@effected/config-file`
216
+ * installed can import this module and call `render` on any value without the
217
+ * resolver ever being asked for the package.
218
+ *
219
+ * ## Why this and not just the error's message
220
+ *
221
+ * `ConfigValidationError.message` names the file. It does not name the value,
222
+ * and the difference is the whole diagnostic: a consumer that printed only the
223
+ * message told users "your config is invalid, run the doctor command", and the
224
+ * doctor command — which guessed from a hand-written list of known keys — could
225
+ * only ever find a *misspelling*. A wrongly **shaped** value left the two
226
+ * commands pointing at each other and neither saying what was wrong.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * import { ConfigIssueRenderer } from "@effected/cli"
231
+ * import { Effect } from "effect"
232
+ *
233
+ * const load = configFile.load.pipe(
234
+ * Effect.catchTag("ConfigValidationError", (error) =>
235
+ * Effect.gen(function* () {
236
+ * yield* Effect.logError(String(error))
237
+ * for (const line of ConfigIssueRenderer.render(error)) yield* Effect.logError(` ${line}`)
238
+ * }),
239
+ * ),
240
+ * )
241
+ * ```
242
+ *
243
+ * @public
244
+ */
245
+ declare class ConfigIssueRenderer {
246
+ private constructor();
247
+ /**
248
+ * One line per rejected value.
249
+ *
250
+ * @remarks
251
+ * Takes the **error**, not its `issue`, because that is what a `catchTag`
252
+ * hands you and because `issue` is typed `Schema.Defect` — reaching into it
253
+ * at every call site is exactly the ceremony this removes.
254
+ *
255
+ * The parameter is the **typed** error rather than `unknown`. An earlier
256
+ * draft wrote `ConfigValidationError | unknown` to be accommodating, which
257
+ * collapses to plain `unknown` in TypeScript — so it accepted anything, said
258
+ * nothing, and left the type import in the `.d.ts` earning nothing. Inside
259
+ * `Effect.catchTag("ConfigValidationError", …)` the error is already this
260
+ * type, which is where this is called.
261
+ *
262
+ * It still cannot throw on a malformed value: the issue tree is validated by
263
+ * a guard before it is read, so a renderer on an error path never becomes the
264
+ * reason a program dies.
265
+ */
266
+ static readonly render: (error: ConfigValidationError) => ReadonlyArray<string>;
267
+ }
268
+ //#endregion
269
+ //#region src/SchemaIssueRenderer.d.ts
270
+ /**
271
+ * Turn a `SchemaIssue` tree into lines a user can act on.
272
+ *
273
+ * @remarks
274
+ * A decode failure arrives as a structured tree; a person needs
275
+ * `unknown key at groups.g.cleanup.rulesetz`.
276
+ *
277
+ * **Core already ships the formatters this wraps, and they are effectively
278
+ * undiscoverable.** They live on `SchemaIssue` rather than `SchemaError` or
279
+ * `Schema`, they are named `makeFormatter*` rather than anything containing
280
+ * "render" or "format issue", and `SchemaError.message` does not use them — so
281
+ * the obvious probe, printing the error, hints at nothing. Two engineers
282
+ * searched for two rounds and concluded core had none. This export exists to
283
+ * end that search, and the one phrasing override is a bonus rather than the
284
+ * point.
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * import { SchemaIssueRenderer } from "@effected/cli"
289
+ * import { Effect, Schema } from "effect"
290
+ *
291
+ * const result = Schema.decodeUnknownEffect(MySchema)(input, {
292
+ * onExcessProperty: "error",
293
+ * errors: "all",
294
+ * })
295
+ *
296
+ * const reported = result.pipe(
297
+ * Effect.catchTag("SchemaError", (error) =>
298
+ * Effect.forEach(SchemaIssueRenderer.render(error.issue), (line) => Effect.logError(` ${line}`)),
299
+ * ),
300
+ * )
301
+ * ```
302
+ *
303
+ * @public
304
+ */
305
+ declare class SchemaIssueRenderer {
306
+ private constructor();
307
+ /**
308
+ * One line per rejected value, deepest path last.
309
+ *
310
+ * @param issue - a `SchemaIssue` tree, or any value
311
+ * @returns the lines, or empty when `issue` is not an issue tree
312
+ */
313
+ static readonly render: (issue: unknown) => ReadonlyArray<string>;
314
+ }
315
+ //#endregion
316
+ export { CliLogger, type CliLoggerOptions, CliRuntime, ConfigIssueRenderer, type ReportFailuresOptions, SchemaIssueRenderer };
317
+ //# sourceMappingURL=index.d.ts.map
package/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import { CliLogger } from "./CliLogger.js";
2
+ import { CliRuntime } from "./CliRuntime.js";
3
+ import { ConfigIssueRenderer } from "./ConfigIssueRenderer.js";
4
+ import { SchemaIssueRenderer } from "./SchemaIssueRenderer.js";
5
+
6
+ export { CliLogger, CliRuntime, ConfigIssueRenderer, SchemaIssueRenderer };
@@ -0,0 +1,50 @@
1
+ import { SchemaIssue } from "effect";
2
+
3
+ //#region src/internal/format.ts
4
+ /**
5
+ * Core's structured formatter, with one phrasing override.
6
+ *
7
+ * @remarks
8
+ * Built once: it is a pure function of the issue tree and carries no state.
9
+ *
10
+ * Core renders an excess property as `"Expected no excess property"`, which
11
+ * describes the **schema's rule** rather than the **user's mistake**. The path
12
+ * already names the key, so `unknown key at groups.g.cleanup.rulesetz` says the
13
+ * same thing in the words someone editing a config file would use. Every other
14
+ * leaf keeps core's phrasing, which is why this goes through `defaultLeafHook`
15
+ * rather than a table of our own.
16
+ *
17
+ * @internal
18
+ */
19
+ const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? "unknown key" : SchemaIssue.defaultLeafHook(issue) });
20
+ /**
21
+ * Flatten an issue tree to one line per rejected value.
22
+ *
23
+ * @remarks
24
+ * Shared by `SchemaIssueRenderer` and `ConfigIssueRenderer` and imported by
25
+ * nothing else. It lives here rather than in either module so that
26
+ * `ConfigIssueRenderer` — the only export that references the optional
27
+ * `@effected/config-file` peer — can stay a module no other module imports.
28
+ *
29
+ * **No walker of our own.** `makeFormatterStandardSchemaV1` already flattens the
30
+ * tree to `{ message, path }` entries, and `defaultLeafHook` covers every leaf
31
+ * variant, so a wrong *type* renders as sensibly as an unknown key and a
32
+ * variant added upstream is covered without a change here.
33
+ *
34
+ * Nodes are never stringified: each carries the entire AST inline, annotations
35
+ * included, so `String(node)` would dump the schema rather than describe the
36
+ * failure. Only `message` and `path` are read.
37
+ *
38
+ * @internal
39
+ */
40
+ const formatIssue = (issue) => {
41
+ if (!SchemaIssue.isIssue(issue)) return [];
42
+ const lines = formatter(issue).issues.map((entry) => {
43
+ const path = (entry.path ?? []).map(String).join(".");
44
+ return path === "" ? entry.message : `${entry.message} at ${path}`;
45
+ });
46
+ return [...new Set(lines)];
47
+ };
48
+
49
+ //#endregion
50
+ export { formatIssue };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@effected/cli",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "The boundary layer of an effect/unstable/cli program: plain CLI output, failure reporting, and schema-issue rendering",
6
+ "keywords": [
7
+ "effect",
8
+ "cli",
9
+ "logger",
10
+ "terminal"
11
+ ],
12
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/cli#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/spencerbeggs/effected/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/spencerbeggs/effected.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "license": "MIT",
22
+ "author": {
23
+ "name": "C. Spencer Beggs",
24
+ "email": "spencer@beggs.codes",
25
+ "url": "https://spencerbeg.gs"
26
+ },
27
+ "sideEffects": false,
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./index.d.ts",
32
+ "import": "./index.js",
33
+ "default": "./index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "peerDependencies": {
38
+ "@effected/config-file": "^0.4.0",
39
+ "effect": "4.0.0-beta.107"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@effected/config-file": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "engines": {
47
+ "node": ">=24.11.0"
48
+ }
49
+ }
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.58.12"
9
+ }
10
+ ]
11
+ }