@effected/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. package/ui.js +17 -0
package/CliLog.js ADDED
@@ -0,0 +1,346 @@
1
+ import { sanitize } from "./Fmt.js";
2
+ import { paintStyle } from "./internal/ansi.js";
3
+ import { CliTheme } from "./CliTheme.js";
4
+ import { scanAudience } from "./internal/scanAudience.js";
5
+ import { TrustedLine, neutralizeJson } from "./internal/logSafety.js";
6
+ import { makeCliLogger } from "./CliLogger.js";
7
+ import { Level, passes } from "./internal/diagnostics.js";
8
+ import { makeFileSink } from "./internal/fileSink.js";
9
+ import { Cause, Config, Console, Context, Effect, Layer, LogLevel, Logger, Option, Path, References } from "effect";
10
+ import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
11
+ import { CommandNeutralizer } from "@effected/github-commands";
12
+
13
+ //#region src/CliLog.ts
14
+ /** The most spaces a numeric `indent` writes. */
15
+ const MAX_INDENT = 64;
16
+ /** An indent as written before the glyph: spaces for a number, a string with nothing in it that is not text. */
17
+ const indentOf = (indent) => {
18
+ if (indent === void 0) return "";
19
+ if (typeof indent === "number") return Number.isFinite(indent) && indent > 0 ? " ".repeat(Math.min(MAX_INDENT, Math.floor(indent))) : "";
20
+ return sanitize(indent).replace(/\r\n|\r|\n/g, "");
21
+ };
22
+ /** The accepted spellings of a level, lower-cased, to the level they mean. */
23
+ const LEVELS = {
24
+ all: "All",
25
+ trace: "Trace",
26
+ debug: "Debug",
27
+ info: "Info",
28
+ warn: "Warn",
29
+ warning: "Warn",
30
+ error: "Error",
31
+ fatal: "Fatal",
32
+ none: "None"
33
+ };
34
+ const LEVEL_STYLES = {
35
+ FATAL: {
36
+ fg: "red",
37
+ bold: true
38
+ },
39
+ ERROR: { fg: "red" },
40
+ WARN: { fg: "yellow" },
41
+ INFO: { fg: "cyan" },
42
+ DEBUG: { fg: "blue" },
43
+ TRACE: { dim: true }
44
+ };
45
+ /** The diagnostics level: the option when given, else the env var, and the raw text when that is not a level. */
46
+ const readLevel = (explicit, envVar) => Effect.gen(function* () {
47
+ if (explicit !== void 0) return {
48
+ level: explicit,
49
+ invalid: void 0
50
+ };
51
+ if (envVar === void 0) return {
52
+ level: "None",
53
+ invalid: void 0
54
+ };
55
+ const raw = yield* Config.option(Config.String(envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
56
+ if (Option.isNone(raw) || raw.value === "") return {
57
+ level: "None",
58
+ invalid: void 0
59
+ };
60
+ const level = LEVELS[raw.value.toLowerCase()];
61
+ if (level !== void 0) return {
62
+ level,
63
+ invalid: void 0
64
+ };
65
+ return {
66
+ level: "None",
67
+ invalid: `${envVar}=${raw.value} is not a log level (${Object.keys(LEVELS).join("|")}); ignoring it`
68
+ };
69
+ });
70
+ /**
71
+ * Whether a record's line is neutralized: the `neutralize` option when it is a boolean, else whether the logging
72
+ * fiber's `CurrentRuntimeEnv`, or `fallback` where the fiber has none, says GitHub Actions.
73
+ */
74
+ const actionsDecision = (neutralize, fallback) => (fiber) => {
75
+ if (neutralize !== "auto") return neutralize;
76
+ const inFiber = Context.getOption(fiber.context, CurrentRuntimeEnv);
77
+ const runtime = Option.isSome(inFiber) ? inFiber : fallback;
78
+ return Option.contains(Option.flatMap(runtime, (env) => env.ci), "github-actions");
79
+ };
80
+ /**
81
+ * Diagnostics kept apart from a program's output: a level, a format and a place to write.
82
+ *
83
+ * @remarks
84
+ * Two kinds of line must never be silenced by a diagnostics default: the failure report
85
+ * `CliRuntime.reportFailures` writes through `CliLogger`, and the `CliMessage` lines. This package therefore
86
+ * never makes the diagnostics level a global switch. The diagnostics logger filters on its **own** threshold,
87
+ * {@link CliLog.Level}, and writes to stderr only.
88
+ *
89
+ * `CliLog.layer` **owns the whole logger set**. It builds a `CliLogger` for ordinary log lines and the
90
+ * diagnostics sink itself and replaces whatever was installed, without reading it, so there is no order to get
91
+ * wrong. Use it instead of `CliLogger.layer`, not with it: `CliLogger.layer` alone is the no-diagnostics path,
92
+ * and a `CliLogger.layer` layered on top would replace this layer's set.
93
+ *
94
+ * Effect drops a record below `MinimumLogLevel` before any logger runs, so to let a debug record reach the
95
+ * diagnostics logger `MinimumLogLevel` has to be lowered. `CliLog.layer` does that only when the
96
+ * diagnostics level is below the ambient minimum, and in the same step floors the `CliLogger` it built at the
97
+ * minimum it had, so it never prints a record the diagnostics level alone let through. A failure report is
98
+ * written outside the scope core's `--log-level` flag sets, so `--log-level none` does not silence it either.
99
+ *
100
+ * `format: "auto"`, the default, decides by the audience alone, at build time and at runtime alike: NDJSON for an
101
+ * agent or a CI, the pretty line for a human, whatever stderr's terminal state. A human piping stderr therefore gets
102
+ * plain lines (without escapes unless stderr is a colour terminal); pass `format: "json"` for machine-readable logs.
103
+ *
104
+ * The text a program logs is sanitised before anything is painted in the pretty line (the message, the component and an
105
+ * error's cause lose their escape sequences and control characters), and under GitHub Actions, where
106
+ * `CurrentRuntimeEnv` in the logging fiber's context says so, a line the runner would read as a workflow command is
107
+ * neutralized. An NDJSON record is not safe merely because `JSON.stringify` escapes control characters: the runner's
108
+ * legacy parser reads `##[` anywhere in a line, so under Actions it is written as the JSON escape `#\u0023[`, which
109
+ * decodes to the identical text. The file sink's lines are not read by the runner and are written as they are.
110
+ *
111
+ * `CurrentRuntimeEnv` is read from the logging fiber's context, and where that has none, from the `runtimeEnv` option,
112
+ * else from the layer's own build context (captured if present, never required), so a host that builds the layer over
113
+ * its environment, or names it with `runtimeEnv`, neutralizes every record. A record with none of the three (a program
114
+ * with no `CurrentRuntimeEnv` anywhere) is sanitised but not neutralized, unless the `neutralize` option says otherwise.
115
+ *
116
+ * Core's `--log-level` flag sets `MinimumLogLevel` inside the command. While it is set to something other than
117
+ * the value this layer installed, the diagnostics logger follows the flag instead of its own level: it writes
118
+ * every record that reaches it. The `CliLogger` prints the same record too, so a record at or above the ambient
119
+ * minimum is written twice, once plain and once to the diagnostics sink. Stderr is therefore not pure NDJSON while
120
+ * diagnostics are on: a parser reads the lines that start with `{`.
121
+ *
122
+ * One edge: a `--log-level` value that EQUALS the level this layer installed cannot be told from no flag, so the
123
+ * sink keeps filtering on its own level rather than following the flag. The plain `CliLogger` prints those records
124
+ * regardless.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * import { CliRuntime } from "@effected/cli"
129
+ * import { NodeRuntime, NodeServices } from "@effect/platform-node"
130
+ *
131
+ * // `env.log` makes `main` install `CliLog.layer`: set MYTOOL_LOG_LEVEL=debug to get diagnostics on stderr.
132
+ * NodeRuntime.runMain(
133
+ * CliRuntime.main(program, {
134
+ * platform: NodeServices.layer,
135
+ * env: { audienceEnvVar: "MYTOOL_AUDIENCE", log: { envVar: "MYTOOL_LOG_LEVEL" } },
136
+ * }),
137
+ * )
138
+ * ```
139
+ *
140
+ * @public
141
+ */
142
+ var CliLog = class CliLog {
143
+ constructor() {}
144
+ /**
145
+ * The diagnostics threshold. Defaults to `None`, silent.
146
+ *
147
+ * @remarks
148
+ * `CliLog.layer` sets it from the environment variable. A scope may raise it to narrow the output; it
149
+ * cannot lower it below the level the layer installed, because Effect has already dropped those records.
150
+ */
151
+ static Level = Level;
152
+ static layer(options = {}) {
153
+ const file = "file" in options ? options.file : void 0;
154
+ const format = options.format ?? "auto";
155
+ return Layer.unwrap(Effect.gen(function* () {
156
+ const audience = format === "auto" ? yield* Audience : void 0;
157
+ const terminal = format === "json" ? void 0 : yield* TerminalEnv;
158
+ const { level, invalid } = yield* readLevel(options.level, options.envVar);
159
+ const ambient = yield* References.MinimumLogLevel;
160
+ const captured = yield* Effect.serviceOption(CurrentRuntimeEnv);
161
+ const fallback = options.runtimeEnv === void 0 ? captured : Option.some(options.runtimeEnv);
162
+ const underActionsIn = actionsDecision(options.neutralize ?? "auto", fallback);
163
+ const color = terminal?.stderr.color ?? "none";
164
+ const isPretty = (record) => {
165
+ if (format !== "auto") return format === "pretty";
166
+ const inForce = Context.getOption(record.fiber.context, Audience);
167
+ return (Option.isSome(inForce) ? inForce.value.kind : audience?.kind) === "human";
168
+ };
169
+ const lowered = LogLevel.isLessThan(level, ambient) ? level : ambient;
170
+ const isLowered = lowered !== ambient;
171
+ const render = (record) => {
172
+ const underActions = underActionsIn(record.fiber);
173
+ if (!isPretty(record)) {
174
+ const json = Logger.formatJson.log(record);
175
+ return underActions ? neutralizeJson(json) : json;
176
+ }
177
+ const annotations = record.fiber.getRef(References.CurrentLogAnnotations);
178
+ const component = annotations.component === void 0 ? "" : ` [${sanitize(String(annotations.component))}]`;
179
+ const message = sanitize(Array.isArray(record.message) ? record.message.map(String).join(" ") : String(record.message));
180
+ const name = record.logLevel.toUpperCase();
181
+ const levelText = paintStyle(LEVEL_STYLES[name] ?? {}, color, name);
182
+ const cause = record.cause.reasons.length > 0 ? `\n${sanitize(Cause.pretty(record.cause))}` : "";
183
+ const line = `${record.date.toISOString().slice(11, 23)} ${levelText}${component} ${message}${cause}`;
184
+ return underActions ? CommandNeutralizer.text(line) : line;
185
+ };
186
+ const sink = Logger.make((record) => {
187
+ if (!passes(record, lowered)) return;
188
+ record.fiber.getRef(Console.Console).error(render(record));
189
+ });
190
+ const floor = (inner) => isLowered ? Logger.make((record) => {
191
+ const current = record.fiber.getRef(References.MinimumLogLevel);
192
+ const threshold = current === lowered ? ambient : current;
193
+ if (LogLevel.isGreaterThanOrEqualTo(record.logLevel, threshold)) inner.log(record);
194
+ }) : inner;
195
+ const cliLogger = floor(makeCliLogger(options.logger, underActionsIn));
196
+ const extras = (options.extraLoggers ?? []).map(floor);
197
+ if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([cliLogger])));
198
+ return Layer.mergeAll(Layer.succeed(CliLog.Level, level), isLowered ? Layer.succeed(References.MinimumLogLevel, lowered) : Layer.empty, Layer.effect(Logger.CurrentLoggers, Effect.gen(function* () {
199
+ const loggers = [
200
+ ...options.plainLogger === false ? [] : [cliLogger],
201
+ sink,
202
+ ...extras
203
+ ];
204
+ if (file !== void 0) {
205
+ const target = "path" in file ? Option.some(file.path) : yield* Config.option(Config.String(file.envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
206
+ if (Option.isSome(target) && target.value !== "") {
207
+ const location = yield* Path.Path;
208
+ loggers.push(yield* makeFileSink(location.resolve(target.value), lowered, underActionsIn));
209
+ }
210
+ }
211
+ return new Set(loggers);
212
+ })));
213
+ }));
214
+ }
215
+ /**
216
+ * Log a status line: its glyph painted through the theme, then `text`, at a level that follows the status.
217
+ *
218
+ * @remarks
219
+ * The line goes through the logger, so it is a diagnostic like any `Effect.log*` call: filtered by the level in
220
+ * force (`--log-level`, `CliLog.Level`), routed by `CliLogger`'s `stderrFrom` (stderr by default) and neutralized
221
+ * under GitHub Actions. What differs is the glyph: the logger sanitises every line a program logs, which strips a
222
+ * colour a program painted itself, so a glyph on the log channel was always drawn bare. Here the kit paints it and
223
+ * marks the line as its own, so the plain `CliLogger` line keeps the colour, while `text` is still sanitised:
224
+ * escape sequences and control characters in it are removed, as in every line the kit writes.
225
+ *
226
+ * The glyph is painted with stderr's theme (where diagnostics go) through {@link CliTheme.forAudience}, so an agent
227
+ * gets it unpainted, and an `Audience` is read only when provided, so it stays out of the requirements. ASCII glyphs
228
+ * give the status's ASCII form. A diagnostics record carries the line as its message as any record does: the `CliLog`
229
+ * sink's pretty line sanitises it (the glyph is drawn bare there), and NDJSON keeps it, JSON-escaped.
230
+ *
231
+ * The level defaults to the status's rank in `vocab`: `Error` at or above `failure`'s, `Warn` at or above
232
+ * `warning`'s, `Info` below, so a custom status follows its own rank. Pass `level` to choose it, and `indent` (a
233
+ * number of spaces, or a string, sanitised) to start the line inside an indented block: ` ✗ error x: red`.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * import { CliLog, Status } from "@effected/cli"
238
+ *
239
+ * // ✗ in the failure colour, then the message: on stderr, filtered by the log level.
240
+ * const reportError = (resource: string, message: string) =>
241
+ * CliLog.status(Status.core, "failure", `${resource}: ${message}`)
242
+ * ```
243
+ *
244
+ * @param vocab - the vocabulary the status belongs to
245
+ * @param name - the status
246
+ * @param text - the text after the glyph, sanitised
247
+ * @param options - the level to log at, and the indent before the glyph
248
+ */
249
+ static status = (vocab, name, text, options) => Effect.gen(function* () {
250
+ const audience = yield* Effect.serviceOption(Audience);
251
+ const theme = CliTheme.forAudience((yield* CliTheme).forStream("stderr"), Option.isSome(audience) ? audience.value.kind : void 0);
252
+ const line = `${indentOf(options?.indent)}${theme.status(vocab, name, sanitize(text))}`;
253
+ const core = vocab;
254
+ const rank = vocab.def(name).rank;
255
+ const level = options?.level ?? (rank >= core.def("failure").rank ? "Error" : rank >= core.def("warning").rank ? "Warn" : "Info");
256
+ yield* Effect.logWithLevel(level)(line).pipe(Effect.provideService(TrustedLine, true));
257
+ });
258
+ /**
259
+ * Mark the log records an effect emits as coming from `name`.
260
+ *
261
+ * @remarks
262
+ * Shown as `[name]` in pretty output and as `annotations.component` in NDJSON.
263
+ *
264
+ * @param name - the component
265
+ */
266
+ static component = (name) => (self) => Effect.annotateLogs(self, "component", name);
267
+ };
268
+ /**
269
+ * The audience while the platform builds, from what needs no platform: a flag in `argv` (the rule `CliAudience` reads
270
+ * argv with), else the override variable, else detection from the environment. No terminal is consulted. Silent: the
271
+ * environment layer warns about an invalid override value itself, once.
272
+ */
273
+ const buildTimeAudience = (argv, audienceEnvVar, detected) => {
274
+ const { given, conflict } = scanAudience(argv ?? []);
275
+ const [flagged] = given;
276
+ if (!conflict && flagged !== void 0) return Effect.succeed(flagged);
277
+ return Effect.map(Audience, (audience) => audience.kind).pipe(Effect.provide(Audience.layer(audienceEnvVar === void 0 ? void 0 : { envVar: audienceEnvVar }).pipe(Layer.provide(Layer.succeed(CurrentRuntimeEnv, detected)))), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set()));
278
+ };
279
+ /** What the build-time loggers decide from, read once: the format and the runtime environment to neutralize by. */
280
+ const buildTimeDecision = (options, audienceEnvVar) => Effect.gen(function* () {
281
+ const detected = yield* Effect.provide(CurrentRuntimeEnv, CurrentRuntimeEnv.layer);
282
+ const format = options.format ?? "auto";
283
+ return {
284
+ ndjson: format === "json" || format === "auto" && (yield* buildTimeAudience(options.argv, audienceEnvVar, detected)) !== "human",
285
+ runtimeEnv: options.runtimeEnv ?? detected
286
+ };
287
+ });
288
+ /**
289
+ * The logger the platform is built under by `CliRuntime.main` with `env.log`: the log level and env var apply to
290
+ * what the platform logs while it builds, too.
291
+ *
292
+ * @remarks
293
+ * With `format: "json"`, or `auto` when the build-time audience (`argv`, the override variable, detection) is an
294
+ * agent or a CI, it is the full `CliLog.layer` in NDJSON (that format needs neither the audience nor the terminal,
295
+ * which the platform has not built yet), without the file sink, whose `FileSystem` the platform provides. Otherwise it
296
+ * is the plain `CliLogger`, with `MinimumLogLevel` lowered to the resolved level. Either way it neutralizes under
297
+ * GitHub Actions from the detected environment. The level is resolved silently: the program's own `CliLog.layer` warns
298
+ * about an invalid value, once.
299
+ *
300
+ * @internal
301
+ */
302
+ const platformLogLayer = (options, audienceEnvVar) => Layer.unwrap(Effect.gen(function* () {
303
+ const { level } = yield* readLevel(options.level, options.envVar);
304
+ const ambient = yield* References.MinimumLogLevel;
305
+ const { ndjson, runtimeEnv } = yield* buildTimeDecision(options, audienceEnvVar);
306
+ if (ndjson) return CliLog.layer({
307
+ level,
308
+ format: "json",
309
+ ...options.plainLogger === void 0 ? {} : { plainLogger: options.plainLogger },
310
+ ...options.logger === void 0 ? {} : { logger: options.logger },
311
+ ...options.extraLoggers === void 0 ? {} : { extraLoggers: options.extraLoggers },
312
+ ...options.neutralize === void 0 ? {} : { neutralize: options.neutralize },
313
+ runtimeEnv
314
+ });
315
+ return Layer.merge(Logger.layer([makeCliLogger(options.logger, actionsDecision(options.neutralize ?? "auto", Option.some(runtimeEnv)))]), LogLevel.isLessThan(level, ambient) ? Layer.succeed(References.MinimumLogLevel, level) : Layer.empty);
316
+ }));
317
+ /**
318
+ * The logger `CliRuntime.main` builds the environment layer under: one line per record, in the build-time format.
319
+ *
320
+ * @remarks
321
+ * What the environment layer logs is a configuration error (an invalid audience override, which interpolates the
322
+ * variable's value), so it is never silenced and never written twice: NDJSON alone for an agent or a CI (`json`, or
323
+ * `auto` for that build-time audience), a plain `CliLogger` line otherwise, whatever `plainLogger` and the diagnostics
324
+ * level say, and neutralized under GitHub Actions like the platform's lines. It goes to stderr alone, whatever
325
+ * `stderrFrom` the program's `CliLogger` options raise, and never to `extraLoggers` or the file sink. Only this
326
+ * logger is pinned to stderr: the platform's build-time logger keeps the host's `stderrFrom`. It floors at `Warning` and installs no
327
+ * `MinimumLogLevel`, so `CliLog.layer`'s own build, which shares this context, reads the ambient minimum.
328
+ *
329
+ * @internal
330
+ */
331
+ const envBuildLogLayer = (options, audienceEnvVar) => Layer.unwrap(Effect.gen(function* () {
332
+ const { ndjson, runtimeEnv } = yield* buildTimeDecision(options, audienceEnvVar);
333
+ const underActions = actionsDecision(options.neutralize ?? "auto", Option.some(runtimeEnv));
334
+ if (!ndjson) return Logger.layer([makeCliLogger({
335
+ ...options.logger,
336
+ stderrFrom: "All"
337
+ }, underActions)]);
338
+ return Logger.layer([Logger.make((record) => {
339
+ if (!LogLevel.isGreaterThanOrEqualTo(record.logLevel, "Warn")) return;
340
+ const json = Logger.formatJson.log(record);
341
+ record.fiber.getRef(Console.Console).error(underActions(record.fiber) ? neutralizeJson(json) : json);
342
+ })]);
343
+ }));
344
+
345
+ //#endregion
346
+ export { CliLog, envBuildLogLayer, platformLogLayer };
package/CliLogger.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { sanitize } from "./Fmt.js";
2
+ import { TrustedLine, sanitizeParts, underActionsIn } from "./internal/logSafety.js";
1
3
  import { Console, LogLevel, Logger, References } from "effect";
4
+ import { CommandNeutralizer } from "@effected/github-commands";
2
5
 
3
6
  //#region src/CliLogger.ts
4
7
  const defaultRender = (message) => Array.isArray(message) ? message.map(String).join(" ") : String(message);
5
8
  /**
6
- * A `Logger` that renders CLI output rather than service logs.
9
+ * A `Logger` that renders CLI output rather than service logs: no timestamp, level or fiber id, with every level
10
+ * going to stderr by default so stdout carries only what the program writes.
7
11
  *
8
12
  * @remarks
9
13
  * Effect's default logger emits `[00:33:56.619] INFO (#2): message`. That is
@@ -12,27 +16,15 @@ const defaultRender = (message) => Array.isArray(message) ? message.map(String).
12
16
  * in front of output a human is reading, and they make a formatted block — a
13
17
  * permissions table, a summary — unreadable.
14
18
  *
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
+ * **A program that never installs a CLI logger looks correct in review and
20
+ * ships timestamps to its users**, so install this one at the program's
21
+ * boundary.
19
22
  *
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.
23
+ * It writes through the `Console` reference read off the logging fiber,
24
+ * synchronously, as core's own default logger does: `Logger.make` takes a
25
+ * synchronous callback, and a `Stdio` sink write is an `Effect` a logger cannot
26
+ * `yield*`. `Console.Console` is a `Context.Reference`, so it never appears in
27
+ * `R`, and a test swaps the reference rather than stubbing a global.
36
28
  *
37
29
  * @example
38
30
  * ```ts
@@ -48,12 +40,10 @@ const defaultRender = (message) => Array.isArray(message) ? message.map(String).
48
40
  * program.pipe(Effect.provide(CliLogger.layer()))
49
41
  * ```
50
42
  *
51
- * @remarks
52
43
  * `stderrFrom` defaults to `"All"`: a CLI's stdout is its product, so every
53
44
  * log level is a diagnostic unless a consumer narrows the threshold. Write
54
45
  * 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).
46
+ * `stderrFrom: "Error"` for a tool whose output *is* its log lines.
57
47
  *
58
48
  * @public
59
49
  */
@@ -66,14 +56,7 @@ var CliLogger = class CliLogger {
66
56
  * Prefer {@link CliLogger.layer}. Reach for this only when you are building
67
57
  * the logger set yourself and want this one among several.
68
58
  */
69
- static make = (options = {}) => {
70
- const render = options.render ?? defaultRender;
71
- const stderrFrom = options.stderrFrom ?? "All";
72
- return Logger.make(({ fiber, logLevel, message }) => {
73
- const console = fiber.getRef(Console.Console);
74
- (fiber.getRef(References.LogToStderr) || LogLevel.isGreaterThanOrEqualTo(logLevel, stderrFrom) ? console.error : console.log)(render(message));
75
- });
76
- };
59
+ static make = (options = {}) => makeCliLogger(options);
77
60
  /**
78
61
  * Replace the default logger with this one.
79
62
  *
@@ -86,6 +69,24 @@ var CliLogger = class CliLogger {
86
69
  */
87
70
  static layer = (options = {}) => Logger.layer([CliLogger.make(options)]);
88
71
  };
72
+ /**
73
+ * `CliLogger.make`, with the neutralizing decision as a parameter: `CliLog.layer` passes its own (the fiber's
74
+ * `CurrentRuntimeEnv`, else the one captured at build, or its `neutralize` option), so its plain line and its
75
+ * diagnostics line are neutralized alike.
76
+ *
77
+ * @internal
78
+ */
79
+ const makeCliLogger = (options = {}, underActions = underActionsIn) => {
80
+ const custom = options.render;
81
+ const render = custom ?? defaultRender;
82
+ const stderrFrom = options.stderrFrom ?? "All";
83
+ return Logger.make(({ fiber, logLevel, message }) => {
84
+ const console = fiber.getRef(Console.Console);
85
+ const write = fiber.getRef(References.LogToStderr) || LogLevel.isGreaterThanOrEqualTo(logLevel, stderrFrom) ? console.error : console.log;
86
+ const rendered = fiber.getRef(TrustedLine) ? render(message) : custom === void 0 ? sanitize(render(message)) : render(sanitizeParts(message));
87
+ write(underActions(fiber) ? CommandNeutralizer.text(rendered) : rendered);
88
+ });
89
+ };
89
90
 
90
91
  //#endregion
91
- export { CliLogger };
92
+ export { CliLogger, makeCliLogger };
package/CliMessage.js ADDED
@@ -0,0 +1,80 @@
1
+ import { underGithubActions } from "./internal/autoFormat.js";
2
+ import { sanitize } from "./Fmt.js";
3
+ import { CliTheme } from "./CliTheme.js";
4
+ import { Status } from "./Status.js";
5
+ import { Console, Effect } from "effect";
6
+ import { Audience } from "@effected/env";
7
+ import { CommandNeutralizer } from "@effected/github-commands";
8
+
9
+ //#region src/CliMessage.ts
10
+ /**
11
+ * One-line status messages: a glyph and some text, themed for a person and plain for an agent.
12
+ *
13
+ * @remarks
14
+ * Each line goes through `Console`, `log` for stdout and `error` for stderr, never through the logger, so no
15
+ * log level can silence it. Only the glyph is painted; the text stays plain. An `agent` audience gets the glyph
16
+ * and the text and never colour, even when the theme has colour. `success` and `info` go to stdout, `warning`
17
+ * and `failure` to stderr.
18
+ *
19
+ * The text is whatever the caller supplies, so it is sanitised: escape sequences and control characters are removed
20
+ * (a line break is kept as one, a tab becomes a space), as in a document. Under GitHub Actions, where
21
+ * `CurrentRuntimeEnv` says so, a line the runner would read as a workflow command is neutralized as well. The glyph
22
+ * comes from the vocabulary, sanitised too (`Status.glyph`), so a glyph built from data cannot inject an escape either.
23
+ *
24
+ * @public
25
+ */
26
+ var CliMessage = class CliMessage {
27
+ constructor() {}
28
+ /**
29
+ * Print a status line from a vocabulary.
30
+ *
31
+ * @remarks
32
+ * The stream defaults to stderr when the status ranks at or above `warning` in `vocab`, and stdout
33
+ * otherwise, so a custom status follows its own rank: a `timeout` ranked 85 goes to stderr.
34
+ *
35
+ * @param vocab - the vocabulary the status belongs to
36
+ * @param name - the status
37
+ * @param text - the text after the glyph
38
+ * @param options - the stream override
39
+ */
40
+ static status = (vocab, name, text, options) => Effect.gen(function* () {
41
+ const theme = yield* CliTheme;
42
+ const audience = yield* Audience;
43
+ const def = vocab.def(name);
44
+ const message = sanitize(text);
45
+ const warning = vocab.def("warning").rank;
46
+ const stream = options?.stream ?? (def.rank >= warning ? "stderr" : "stdout");
47
+ const streamTheme = theme.forStream(stream);
48
+ let line;
49
+ line = CliTheme.forAudience(streamTheme, audience.kind).status(vocab, name, message);
50
+ if (yield* underGithubActions) line = CommandNeutralizer.text(line);
51
+ yield* stream === "stderr" ? Console.error(line) : Console.log(line);
52
+ });
53
+ /**
54
+ * A success line, on stdout.
55
+ *
56
+ * @param text - the text after the glyph
57
+ */
58
+ static success = (text) => CliMessage.status(Status.core, "success", text);
59
+ /**
60
+ * An informational line, on stdout.
61
+ *
62
+ * @param text - the text after the glyph
63
+ */
64
+ static info = (text) => CliMessage.status(Status.core, "info", text);
65
+ /**
66
+ * A warning line, on stderr.
67
+ *
68
+ * @param text - the text after the glyph
69
+ */
70
+ static warning = (text) => CliMessage.status(Status.core, "warning", text);
71
+ /**
72
+ * A failure line, on stderr.
73
+ *
74
+ * @param text - the text after the glyph
75
+ */
76
+ static failure = (text) => CliMessage.status(Status.core, "failure", text);
77
+ };
78
+
79
+ //#endregion
80
+ export { CliMessage };
package/CliPrompt.js ADDED
@@ -0,0 +1,104 @@
1
+ import { Cancelled } from "./Cancelled.js";
2
+ import { CliInteractive } from "./CliInteractive.js";
3
+ import { WizardDropped } from "./internal/wizardGate.js";
4
+ import { answerWithoutPerson } from "./internal/fallbackAnswer.js";
5
+ import { Context, Effect, Layer, Queue, Terminal } from "effect";
6
+ import { CliConfig, GlobalFlag, Prompt } from "effect/cli";
7
+
8
+ //#region src/CliPrompt.ts
9
+ /**
10
+ * Prompts that know whether there is a person to ask.
11
+ *
12
+ * @public
13
+ */
14
+ var CliPrompt = class {
15
+ constructor() {}
16
+ /**
17
+ * A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that only prompts when the run is
18
+ * interactive.
19
+ *
20
+ * @remarks
21
+ * Interactive (`CliInteractive`): the prompt runs and its answer is used. Not interactive: `otherwise` is used
22
+ * when given, and the terminal is never read; without it the parameter fails as missing, exactly as it would
23
+ * with no fallback, so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with
24
+ * `flag` (the name without dashes, as given to `Flag.String`) or `argument` so that error can be built.
25
+ *
26
+ * Why the prompt runs inside this function rather than being handed back to core: core's
27
+ * `withFallbackPrompt` turns a quit, such as Ctrl-C, into the original missing-parameter error, which would
28
+ * exit `64` as if the flag had been forgotten. Here a quit is `Cancelled` with reason `interrupt`, exit `130`.
29
+ * It is raised as a defect because core's parse step turns every typed failure into a usage error. Core then
30
+ * runs the already-answered `Prompt.succeed` it is handed. Because it travels as a defect, a handler's
31
+ * `Effect.catchTag("Cancelled", ...)` cannot see it, and only `CliRuntime.main` (or
32
+ * `CliRuntime.reportFailures`) renders it as one line with exit `130`; under a bare `runMain` it prints a
33
+ * stack.
34
+ *
35
+ * Pair it with `CliPrompt.gateTerminal`, which `CliEnv.layer` installs: core still runs `Prompt.run` on the
36
+ * answered prompt it is handed, and `Prompt.run` subscribes the terminal's input, which on a real terminal
37
+ * attaches a reader to stdin and drops piped input. The gate makes that subscription harmless when the run is
38
+ * not interactive.
39
+ *
40
+ * @param prompt - the prompt to show
41
+ * @param options - the parameter it stands in for, and the non-interactive default
42
+ */
43
+ static fallback = (prompt, options) => Effect.gen(function* () {
44
+ if (yield* CliInteractive) {
45
+ const answer = yield* Prompt.run(prompt).pipe(Effect.catchTag("QuitError", () => Effect.die(new Cancelled({ reason: "interrupt" }))));
46
+ return Prompt.succeed(answer);
47
+ }
48
+ return yield* answerWithoutPerson(options);
49
+ });
50
+ /**
51
+ * Gates core's `Terminal` on `CliInteractive`, so a run that is not interactive never touches the real one.
52
+ *
53
+ * @remarks
54
+ * Core's prompt runner subscribes the terminal's input even for a prompt that is already answered, and the
55
+ * real Node terminal then attaches a readline to stdin, which drops piped input and puts a TTY stdin into raw
56
+ * mode. Not interactive, this terminal's input is an already-ended queue, its `readLine` fails as quit and its
57
+ * `display` writes nothing, so any prompt, the wizard included, is quit at once and the real terminal is never
58
+ * read. Its `columns` and `rows` always come from the real one, so layout keeps working. Interactive, every
59
+ * call passes through to the real terminal.
60
+ *
61
+ * The decision is made on every call, not when the layer is built, so a scope that narrows `CliInteractive`
62
+ * later, such as an audience flag under `CliAudience.runWith`, gates it too.
63
+ *
64
+ * It requires the real `Terminal`. `CliEnv.layer` installs it, after `TerminalEnv` is built from the real
65
+ * terminal, so consumers do not compose it.
66
+ */
67
+ static gateTerminal = Layer.effect(Terminal.Terminal, Effect.gen(function* () {
68
+ const real = yield* Terminal.Terminal;
69
+ const endedInput = Effect.map(Queue.unbounded(), (queue) => {
70
+ Queue.endUnsafe(queue);
71
+ return queue;
72
+ });
73
+ return Terminal.make({
74
+ columns: real.columns,
75
+ rows: real.rows,
76
+ readInput: Effect.flatMap(CliInteractive, (interactive) => interactive ? real.readInput : endedInput),
77
+ readLine: Effect.flatMap(CliInteractive, (interactive) => interactive ? real.readLine : Effect.fail(new Terminal.QuitError({}))),
78
+ display: (text) => Effect.flatMap(CliInteractive, (interactive) => interactive ? real.display(text) : Effect.void)
79
+ });
80
+ }));
81
+ /**
82
+ * Drops core's `--wizard` built-in flag when the run is not interactive.
83
+ *
84
+ * @remarks
85
+ * The wizard prompts, so offering it without a terminal only leads to a dead end. With the flag gone, core
86
+ * treats `--wizard` as an unknown flag, a usage error that exits `64`, and lists it nowhere in `--help`.
87
+ * The layer reads `CliInteractive` and the ambient `CliConfig` when it is built, so provide those first, for
88
+ * example `CliPrompt.gateWizard.pipe(Layer.provide(CliInteractive.layer))`. It filters the ambient `CliConfig`,
89
+ * so a consumer's own `builtIns` survive, and returns it untouched when interactive. An audience flag read
90
+ * later is covered by `CliAudience.runWith`, which drops the wizard too. When this layer removes the flag it records
91
+ * that it did, so `runWith` puts it back only when a flag turns interactivity on where this took it away: a
92
+ * consumer who never had `Wizard` in their `builtIns` keeps it out.
93
+ */
94
+ static gateWizard = Layer.effectContext(Effect.gen(function* () {
95
+ const ambient = yield* CliConfig.CliConfig;
96
+ const unchanged = Context.make(CliConfig.CliConfig, ambient);
97
+ if ((yield* CliInteractive) || !ambient.builtIns.includes(GlobalFlag.Wizard)) return unchanged;
98
+ const dropped = CliConfig.make({ builtIns: ambient.builtIns.filter((flag) => flag !== GlobalFlag.Wizard) });
99
+ return Context.make(CliConfig.CliConfig, dropped).pipe(Context.add(WizardDropped, dropped));
100
+ }));
101
+ };
102
+
103
+ //#endregion
104
+ export { CliPrompt };