@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 +83 -0
- package/CliRuntime.js +108 -0
- package/ConfigIssueRenderer.js +77 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/SchemaIssueRenderer.js +51 -0
- package/index.d.ts +317 -0
- package/index.js +6 -0
- package/internal/format.js +50 -0
- package/package.json +49 -0
- package/tsdoc-metadata.json +11 -0
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
|
+
[](https://www.npmjs.com/package/@effected/cli)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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
|
+
}
|