@effected/cli 0.10.0 → 0.11.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/Cancelled.js +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +13 -19
- package/CliEnv.js +89 -0
- package/CliExit.js +1 -1
- package/CliFailure.js +253 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +294 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +83 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +103 -53
- package/CliTest.js +16 -0
- package/CliTheme.js +128 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +512 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +129 -131
- package/Render.js +254 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +163 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +2923 -171
- package/index.js +19 -1
- package/internal/ansi.js +230 -0
- package/internal/autoFormat.js +34 -0
- package/internal/canPrompt.js +15 -0
- package/internal/counts.js +69 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +156 -0
- package/internal/fallbackAnswer.js +18 -0
- package/internal/fileSink.js +62 -0
- package/internal/format.js +62 -7
- package/internal/layout.js +250 -0
- package/internal/linkScheme.js +30 -0
- package/internal/linkTarget.js +50 -0
- package/internal/logSafety.js +46 -0
- package/internal/renderAnsi.js +52 -0
- package/internal/renderDoc.js +319 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +50 -0
- package/internal/scanAudience.js +106 -0
- package/internal/splitFrame.js +56 -0
- package/internal/wizardGate.js +18 -0
- package/package.json +35 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +348 -0
- package/ui/CliUiLive.js +399 -0
- package/ui/Confirm.js +245 -0
- package/ui/DocView.js +74 -0
- package/ui/KeyHelp.js +62 -0
- package/ui/KeyTable.js +199 -0
- package/ui/MultiSelect.js +260 -0
- package/ui/Select.js +226 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +250 -0
- package/ui/Toggle.js +32 -0
- package/ui/UiKey.js +44 -0
- package/ui/UiProvider.js +60 -0
- package/ui/UiStreams.js +18 -0
- package/ui/UiTheme.js +119 -0
- package/ui/Viewport.js +204 -0
- package/ui/internal/ErrorBoundary.js +30 -0
- package/ui/internal/Holder.js +74 -0
- package/ui/internal/ScreenContext.js +52 -0
- package/ui/internal/UiProviders.js +21 -0
- package/ui/internal/ink.js +122 -0
- package/ui/internal/inkChalk.js +58 -0
- package/ui/internal/inkConsole.js +146 -0
- package/ui/internal/lineText.js +19 -0
- package/ui/internal/mountPermit.js +16 -0
- package/ui/internal/perfDrain.js +33 -0
- package/ui/internal/processStreams.js +19 -0
- package/ui/internal/renderOptions.js +13 -0
- package/ui/testing/CliUiTest.js +735 -0
- package/ui/testing/fakeStreams.js +76 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing.d.ts +446 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1648 -0
- package/ui.js +17 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { canPrompt } from "./internal/canPrompt.js";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
3
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
4
|
+
|
|
5
|
+
//#region src/CliInteractive.ts
|
|
6
|
+
/**
|
|
7
|
+
* Whether this run may prompt a person: a human audience, with a terminal on
|
|
8
|
+
* both standard input and standard output, and a `TERM` that is not `dumb`.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* A `Context.Reference`, not a `Context.Service`, for three reasons. It is one
|
|
12
|
+
* boolean with a safe default, which is what a reference is for. A scoped
|
|
13
|
+
* override, {@link CliInteractive.unless}, is a plain
|
|
14
|
+
* `Effect.provideService`, so a command can switch prompting off for one
|
|
15
|
+
* subtree without a layer. And forgetting to provide it is not a type error
|
|
16
|
+
* but a harmless answer, since it reads `false` when no layer is provided: a
|
|
17
|
+
* program that never wired it refuses to prompt rather than hanging on a
|
|
18
|
+
* terminal that is not there. Read it with `yield* CliInteractive`.
|
|
19
|
+
*
|
|
20
|
+
* Because a reference's key type is `never`, {@link CliInteractive.layer} and
|
|
21
|
+
* {@link CliInteractive.layerTest} are typed `Layer<never>`: they set the
|
|
22
|
+
* reference rather than provide a service.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
var CliInteractive = class CliInteractive extends Context.Reference("@effected/cli/CliInteractive", { defaultValue: () => false }) {
|
|
27
|
+
/**
|
|
28
|
+
* Decide from the audience and the terminal: `true` only for a human audience with a terminal on both
|
|
29
|
+
* standard input and standard output, and a `TERM` that is not `dumb`.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* A dumb terminal is a terminal, but it cannot move the cursor or take synchronized output, which a prompt or a
|
|
33
|
+
* screen redrawing in place needs: it gets what a pipe gets. `TERM` is read through the ambient `ConfigProvider`,
|
|
34
|
+
* as `@effected/env` reads the environment, so it adds no requirement; a test fixes it with
|
|
35
|
+
* `Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ TERM: "dumb" }))`.
|
|
36
|
+
*
|
|
37
|
+
* Bind the layer to a constant and provide it once; `Audience` and `TerminalEnv` come from `@effected/env`.
|
|
38
|
+
*/
|
|
39
|
+
static layer = Layer.effect(CliInteractive, Effect.gen(function* () {
|
|
40
|
+
const audience = yield* Audience;
|
|
41
|
+
const terminal = yield* TerminalEnv;
|
|
42
|
+
return audience.kind === "human" && (yield* canPrompt(terminal));
|
|
43
|
+
}));
|
|
44
|
+
/**
|
|
45
|
+
* A fixed answer that needs nothing.
|
|
46
|
+
*
|
|
47
|
+
* @param value - whether the run is interactive
|
|
48
|
+
*/
|
|
49
|
+
static layerTest = (value) => Layer.succeed(CliInteractive, value);
|
|
50
|
+
/**
|
|
51
|
+
* Run `self` with interactivity switched off when `condition` holds.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* It only narrows: `unless(false)` leaves the current value alone and never turns interactivity on, so a
|
|
55
|
+
* non-interactive scope stays non-interactive. The outer value is restored when `self` ends, whether it
|
|
56
|
+
* succeeds, fails or is interrupted.
|
|
57
|
+
*
|
|
58
|
+
* A flag that resolves the audience (`--human`, under `CliAudience.runWith` or `provide`) recomputes interactivity
|
|
59
|
+
* from the terminal facts, so it can override an outer `unless` or a `layerTest(false)`: those narrow the
|
|
60
|
+
* environment's answer, and the flag is a later, explicit one.
|
|
61
|
+
*
|
|
62
|
+
* @param condition - `true` to switch prompting off for `self`
|
|
63
|
+
*/
|
|
64
|
+
static unless = (condition) => (self) => Effect.gen(function* () {
|
|
65
|
+
const current = yield* CliInteractive;
|
|
66
|
+
return yield* Effect.provideService(self, CliInteractive, current && !condition);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
export { CliInteractive };
|
package/CliLinks.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { sanitize } from "./Fmt.js";
|
|
2
|
+
import { isAllowedLinkUrl } from "./internal/linkScheme.js";
|
|
3
|
+
import { DRIVE, UNC, encodeForOsc8, fileUrlPath } from "./internal/linkTarget.js";
|
|
4
|
+
import { Config, Context, Effect, FileSystem, Layer, Option, Path } from "effect";
|
|
5
|
+
import { CurrentRuntimeEnv } from "@effected/env";
|
|
6
|
+
import { Walker } from "@effected/walker";
|
|
7
|
+
|
|
8
|
+
//#region src/CliLinks.ts
|
|
9
|
+
const MODES = [
|
|
10
|
+
"auto",
|
|
11
|
+
"vscode",
|
|
12
|
+
"file",
|
|
13
|
+
"off"
|
|
14
|
+
];
|
|
15
|
+
const parseSetting = (raw) => MODES.find((mode) => mode === raw.trim().toLowerCase());
|
|
16
|
+
/** A URL with its control characters and line breaks removed: none is legal in one, and each could end an OSC 8 early. */
|
|
17
|
+
const cleanUrl = (url) => sanitize(url).replace(/[\r\n]/g, "");
|
|
18
|
+
const makeTarget = (mode, absolute) => (target) => {
|
|
19
|
+
if ("url" in target) {
|
|
20
|
+
const url = cleanUrl(target.url);
|
|
21
|
+
return url === "" || !isAllowedLinkUrl(url) ? Option.none() : Option.some(url);
|
|
22
|
+
}
|
|
23
|
+
if (mode === "off") return Option.none();
|
|
24
|
+
const resolved = absolute(target.file);
|
|
25
|
+
if (resolved === void 0) return Option.none();
|
|
26
|
+
const path = fileUrlPath(resolved);
|
|
27
|
+
if (path === void 0) return Option.none();
|
|
28
|
+
if (mode === "file") return Option.some(`file://${path}`);
|
|
29
|
+
const position = target.line === void 0 ? "" : target.col === void 0 ? `:${target.line}` : `:${target.line}:${target.col}`;
|
|
30
|
+
return Option.some(`vscode://file${path}${position}`);
|
|
31
|
+
};
|
|
32
|
+
const isDirectory = (fs, path) => fs.stat(path).pipe(Effect.map((info) => info.type === "Directory"), Effect.orElseSucceed(() => false));
|
|
33
|
+
const exists = (fs, path) => fs.exists(path).pipe(Effect.orElseSucceed(() => false));
|
|
34
|
+
/**
|
|
35
|
+
* The nearest directory, from `cwd` up, that holds `.git` or `pnpm-workspace.yaml`.
|
|
36
|
+
*
|
|
37
|
+
* It looks at `cwd` and then climbs at most {@link MAX_ASCENT} directories, through `Walker.ascend`, which also stops
|
|
38
|
+
* where `dirname` reaches a fixpoint (the filesystem root). `Walker.findRoot` absorbs a failed probe as "not a root",
|
|
39
|
+
* so one unreadable directory never hides a root above it. `None` when there is none.
|
|
40
|
+
*/
|
|
41
|
+
const findRoot = (fs, path, cwd) => Walker.ascend(cwd, { maxDepth: 65 }).pipe(Effect.provideService(Path.Path, path), Effect.flatMap((directories) => Walker.findRoot(directories, (directory) => Effect.gen(function* () {
|
|
42
|
+
return (yield* exists(fs, path.join(directory, ".git"))) || (yield* exists(fs, path.join(directory, "pnpm-workspace.yaml")));
|
|
43
|
+
}))));
|
|
44
|
+
const readOption = (name) => Config.option(Config.String(name)).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
45
|
+
const build = (options, ambient) => Effect.gen(function* () {
|
|
46
|
+
const runtime = yield* CurrentRuntimeEnv;
|
|
47
|
+
const raw = options.envVar === void 0 ? "" : Option.getOrElse(yield* readOption(options.envVar), () => "").trim();
|
|
48
|
+
const fromEnv = parseSetting(raw);
|
|
49
|
+
if (options.envVar !== void 0 && raw !== "" && fromEnv === void 0) yield* Effect.logWarning(`${options.envVar}=${raw} is not one of ${MODES.join("|")}; ignoring it`);
|
|
50
|
+
const setting = fromEnv ?? options.editorLinks ?? "auto";
|
|
51
|
+
const pwd = options.cwd === void 0 ? yield* readOption("PWD") : Option.none();
|
|
52
|
+
const cwd = options.cwd ?? Option.getOrUndefined(pwd) ?? (Option.isSome(ambient.path) ? ambient.path.value.resolve(".") : void 0);
|
|
53
|
+
const path = Option.getOrUndefined(ambient.path);
|
|
54
|
+
const absolute = (file) => {
|
|
55
|
+
if (UNC.test(file)) return void 0;
|
|
56
|
+
if (DRIVE.test(file)) return file;
|
|
57
|
+
if (path === void 0) return file.startsWith("/") ? file : void 0;
|
|
58
|
+
if (path.isAbsolute(file)) return file;
|
|
59
|
+
return cwd === void 0 ? void 0 : path.resolve(cwd, file);
|
|
60
|
+
};
|
|
61
|
+
const mode = setting !== "auto" ? setting : Option.exists(runtime.terminal, (terminal) => terminal.name === "vscode") ? "vscode" : yield* Effect.gen(function* () {
|
|
62
|
+
if (Option.isNone(ambient.fs) || path === void 0 || cwd === void 0) return "file";
|
|
63
|
+
const root = yield* findRoot(ambient.fs.value, path, cwd);
|
|
64
|
+
const base = Option.getOrElse(root, () => cwd);
|
|
65
|
+
return (yield* isDirectory(ambient.fs.value, path.join(base, ".vscode"))) ? "vscode" : "file";
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
mode,
|
|
69
|
+
target: makeTarget(mode, absolute)
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Editor-aware links for file targets: where a link to a file opens.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* The mode is decided once, when the layer is built. `auto` is `vscode` when `CurrentRuntimeEnv.terminal` is
|
|
77
|
+
* `vscode` (`TERM_PROGRAM=vscode`) or a `.vscode/` directory sits at the project root, and `file` otherwise. The
|
|
78
|
+
* root is the nearest directory, from the working directory up, that holds `.git` or `pnpm-workspace.yaml`; the
|
|
79
|
+
* climb is bounded, at most 64 directories above the working directory, and stops where `Path.dirname` reaches the
|
|
80
|
+
* filesystem root. With no root, the working directory itself is checked.
|
|
81
|
+
*
|
|
82
|
+
* Whether a link is written at all is a separate question, answered by {@link CliLinks.linker}.
|
|
83
|
+
*
|
|
84
|
+
* @public
|
|
85
|
+
*/
|
|
86
|
+
var CliLinks = class CliLinks extends Context.Service()("@effected/cli/CliLinks") {
|
|
87
|
+
/**
|
|
88
|
+
* The links for the working directory, reading the filesystem for a `.vscode/` directory.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
|
|
92
|
+
*
|
|
93
|
+
* @param options - the setting, the environment variable that overrides it, and the working directory
|
|
94
|
+
*/
|
|
95
|
+
static layer = (options = {}) => Layer.effect(CliLinks, Effect.gen(function* () {
|
|
96
|
+
const fs = yield* FileSystem.FileSystem;
|
|
97
|
+
const path = yield* Path.Path;
|
|
98
|
+
return yield* build(options, {
|
|
99
|
+
fs: Option.some(fs),
|
|
100
|
+
path: Option.some(path)
|
|
101
|
+
});
|
|
102
|
+
}));
|
|
103
|
+
/**
|
|
104
|
+
* Links fixed to a mode, with no filesystem: a relative path has no link, since there is no working directory.
|
|
105
|
+
*
|
|
106
|
+
* @param mode - `vscode`, `file` or `off`
|
|
107
|
+
*/
|
|
108
|
+
static layerTest = (mode) => Layer.succeed(CliLinks, {
|
|
109
|
+
mode,
|
|
110
|
+
target: makeTarget(mode, (file) => file.startsWith("/") || DRIVE.test(file) ? file : void 0)
|
|
111
|
+
});
|
|
112
|
+
/**
|
|
113
|
+
* The function that writes a link: a target and a label in, the label out, wrapped in OSC 8 when it should be.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* It writes the hyperlink `ESC ] 8 ; ; URL ESC \ label ESC ] 8 ; ; ESC \` only when the stream's terminal can
|
|
117
|
+
* render it (`hyperlinks`) and the audience is not an agent, which never gets an escape of any kind; in every other
|
|
118
|
+
* case, and whenever the target has no URL, it returns the label unchanged. The URL has its control characters
|
|
119
|
+
* removed again here, so a hostile target cannot end the sequence early or start another, and a URL whose scheme is
|
|
120
|
+
* not one a link may have (`javascript:`, `data:`, and the like; the same list markdown uses) is the label alone. It is pure and cheap,
|
|
121
|
+
* which {@link RenderContext}'s `link` requires.
|
|
122
|
+
*
|
|
123
|
+
* @param options - the links, whether hyperlinks are available, and the audience
|
|
124
|
+
*/
|
|
125
|
+
static linker = (options) => (target, label) => {
|
|
126
|
+
if (!options.hyperlinks || options.audience === "agent") return label;
|
|
127
|
+
const url = options.links.target(target);
|
|
128
|
+
if (Option.isNone(url)) return label;
|
|
129
|
+
const written = encodeForOsc8(cleanUrl(url.value));
|
|
130
|
+
if (!isAllowedLinkUrl(written)) return label;
|
|
131
|
+
return `\u001B]8;;${written}\u001B\\${label}\u001B]8;;\u001B\\`;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* The links for {@link CliEnv.layer}: the same as {@link CliLinks.layer}, except that `FileSystem` and `Path` are
|
|
136
|
+
* taken from the environment if it has them, not required.
|
|
137
|
+
*
|
|
138
|
+
* Without them there is no `.vscode/` to look for and no working directory to resolve a relative path against, so
|
|
139
|
+
* `auto` is `vscode` only on the terminal signal. This keeps the requirements of `CliEnv.layer` and of every
|
|
140
|
+
* `CliRuntime.main` overload unchanged.
|
|
141
|
+
*
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
const ambientLinksLayer = (options = {}) => Layer.effect(CliLinks, Effect.gen(function* () {
|
|
145
|
+
const fs = yield* Effect.serviceOption(FileSystem.FileSystem);
|
|
146
|
+
const path = yield* Effect.serviceOption(Path.Path);
|
|
147
|
+
return yield* build(options, {
|
|
148
|
+
fs,
|
|
149
|
+
path
|
|
150
|
+
});
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
//#endregion
|
|
154
|
+
export { CliLinks, ambientLinksLayer };
|
package/CliLog.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { sanitize } from "./Fmt.js";
|
|
2
|
+
import { paintStyle } from "./internal/ansi.js";
|
|
3
|
+
import { scanAudience } from "./internal/scanAudience.js";
|
|
4
|
+
import { neutralizeJson } from "./internal/logSafety.js";
|
|
5
|
+
import { makeCliLogger } from "./CliLogger.js";
|
|
6
|
+
import { Level, passes } from "./internal/diagnostics.js";
|
|
7
|
+
import { makeFileSink } from "./internal/fileSink.js";
|
|
8
|
+
import { Cause, Config, Console, Context, Effect, Layer, LogLevel, Logger, Option, Path, References } from "effect";
|
|
9
|
+
import { Audience, CurrentRuntimeEnv, TerminalEnv } from "@effected/env";
|
|
10
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
11
|
+
|
|
12
|
+
//#region src/CliLog.ts
|
|
13
|
+
/** The accepted spellings of a level, lower-cased, to the level they mean. */
|
|
14
|
+
const LEVELS = {
|
|
15
|
+
all: "All",
|
|
16
|
+
trace: "Trace",
|
|
17
|
+
debug: "Debug",
|
|
18
|
+
info: "Info",
|
|
19
|
+
warn: "Warn",
|
|
20
|
+
warning: "Warn",
|
|
21
|
+
error: "Error",
|
|
22
|
+
fatal: "Fatal",
|
|
23
|
+
none: "None"
|
|
24
|
+
};
|
|
25
|
+
const LEVEL_STYLES = {
|
|
26
|
+
FATAL: {
|
|
27
|
+
fg: "red",
|
|
28
|
+
bold: true
|
|
29
|
+
},
|
|
30
|
+
ERROR: { fg: "red" },
|
|
31
|
+
WARN: { fg: "yellow" },
|
|
32
|
+
INFO: { fg: "cyan" },
|
|
33
|
+
DEBUG: { fg: "blue" },
|
|
34
|
+
TRACE: { dim: true }
|
|
35
|
+
};
|
|
36
|
+
/** The diagnostics level: the option when given, else the env var, and the raw text when that is not a level. */
|
|
37
|
+
const readLevel = (explicit, envVar) => Effect.gen(function* () {
|
|
38
|
+
if (explicit !== void 0) return {
|
|
39
|
+
level: explicit,
|
|
40
|
+
invalid: void 0
|
|
41
|
+
};
|
|
42
|
+
if (envVar === void 0) return {
|
|
43
|
+
level: "None",
|
|
44
|
+
invalid: void 0
|
|
45
|
+
};
|
|
46
|
+
const raw = yield* Config.option(Config.String(envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
47
|
+
if (Option.isNone(raw) || raw.value === "") return {
|
|
48
|
+
level: "None",
|
|
49
|
+
invalid: void 0
|
|
50
|
+
};
|
|
51
|
+
const level = LEVELS[raw.value.toLowerCase()];
|
|
52
|
+
if (level !== void 0) return {
|
|
53
|
+
level,
|
|
54
|
+
invalid: void 0
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
level: "None",
|
|
58
|
+
invalid: `${envVar}=${raw.value} is not a log level (${Object.keys(LEVELS).join("|")}); ignoring it`
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* Whether a record's line is neutralized: the `neutralize` option when it is a boolean, else whether the logging
|
|
63
|
+
* fiber's `CurrentRuntimeEnv`, or `fallback` where the fiber has none, says GitHub Actions.
|
|
64
|
+
*/
|
|
65
|
+
const actionsDecision = (neutralize, fallback) => (fiber) => {
|
|
66
|
+
if (neutralize !== "auto") return neutralize;
|
|
67
|
+
const inFiber = Context.getOption(fiber.context, CurrentRuntimeEnv);
|
|
68
|
+
const runtime = Option.isSome(inFiber) ? inFiber : fallback;
|
|
69
|
+
return Option.contains(Option.flatMap(runtime, (env) => env.ci), "github-actions");
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Diagnostics kept apart from a program's output: a level, a format and a place to write.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* Two kinds of line must never be silenced by a diagnostics default: the failure report
|
|
76
|
+
* `CliRuntime.reportFailures` writes through `CliLogger`, and the `CliMessage` lines. This package therefore
|
|
77
|
+
* never makes the diagnostics level a global switch. The diagnostics logger filters on its **own** threshold,
|
|
78
|
+
* {@link CliLog.Level}, and writes to stderr only.
|
|
79
|
+
*
|
|
80
|
+
* `CliLog.layer` **owns the whole logger set**. It builds a `CliLogger` for ordinary log lines and the
|
|
81
|
+
* diagnostics sink itself and replaces whatever was installed, without reading it, so there is no order to get
|
|
82
|
+
* wrong. Use it instead of `CliLogger.layer`, not with it: `CliLogger.layer` alone is the no-diagnostics path,
|
|
83
|
+
* and a `CliLogger.layer` layered on top would replace this layer's set.
|
|
84
|
+
*
|
|
85
|
+
* Effect drops a record below `MinimumLogLevel` before any logger runs, so to let a debug record reach the
|
|
86
|
+
* diagnostics logger `MinimumLogLevel` has to be lowered. `CliLog.layer` does that only when the
|
|
87
|
+
* diagnostics level is below the ambient minimum, and in the same step floors the `CliLogger` it built at the
|
|
88
|
+
* minimum it had, so it never prints a record the diagnostics level alone let through. A failure report is
|
|
89
|
+
* written outside the scope core's `--log-level` flag sets, so `--log-level none` does not silence it either.
|
|
90
|
+
*
|
|
91
|
+
* `format: "auto"`, the default, decides by the audience alone, at build time and at runtime alike: NDJSON for an
|
|
92
|
+
* agent or a CI, the pretty line for a human, whatever stderr's terminal state. A human piping stderr therefore gets
|
|
93
|
+
* plain lines (without escapes unless stderr is a colour terminal); pass `format: "json"` for machine-readable logs.
|
|
94
|
+
*
|
|
95
|
+
* The text a program logs is sanitised before anything is painted in the pretty line (the message, the component and an
|
|
96
|
+
* error's cause lose their escape sequences and control characters), and under GitHub Actions, where
|
|
97
|
+
* `CurrentRuntimeEnv` in the logging fiber's context says so, a line the runner would read as a workflow command is
|
|
98
|
+
* neutralized. An NDJSON record is not safe merely because `JSON.stringify` escapes control characters: the runner's
|
|
99
|
+
* legacy parser reads `##[` anywhere in a line, so under Actions it is written as the JSON escape `#\u0023[`, which
|
|
100
|
+
* decodes to the identical text. The file sink's lines are not read by the runner and are written as they are.
|
|
101
|
+
*
|
|
102
|
+
* `CurrentRuntimeEnv` is read from the logging fiber's context, and where that has none, from the `runtimeEnv` option,
|
|
103
|
+
* else from the layer's own build context (captured if present, never required), so a host that builds the layer over
|
|
104
|
+
* its environment, or names it with `runtimeEnv`, neutralizes every record. A record with none of the three (a program
|
|
105
|
+
* with no `CurrentRuntimeEnv` anywhere) is sanitised but not neutralized, unless the `neutralize` option says otherwise.
|
|
106
|
+
*
|
|
107
|
+
* Core's `--log-level` flag sets `MinimumLogLevel` inside the command. While it is set to something other than
|
|
108
|
+
* the value this layer installed, the diagnostics logger follows the flag instead of its own level: it writes
|
|
109
|
+
* every record that reaches it. The `CliLogger` prints the same record too, so a record at or above the ambient
|
|
110
|
+
* minimum is written twice, once plain and once to the diagnostics sink. Stderr is therefore not pure NDJSON while
|
|
111
|
+
* diagnostics are on: a parser reads the lines that start with `{`.
|
|
112
|
+
*
|
|
113
|
+
* One edge: a `--log-level` value that EQUALS the level this layer installed cannot be told from no flag, so the
|
|
114
|
+
* sink keeps filtering on its own level rather than following the flag. The plain `CliLogger` prints those records
|
|
115
|
+
* regardless.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* import { CliRuntime } from "@effected/cli"
|
|
120
|
+
* import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
|
121
|
+
*
|
|
122
|
+
* // `env.log` makes `main` install `CliLog.layer`: set MYTOOL_LOG_LEVEL=debug to get diagnostics on stderr.
|
|
123
|
+
* NodeRuntime.runMain(
|
|
124
|
+
* CliRuntime.main(program, {
|
|
125
|
+
* platform: NodeServices.layer,
|
|
126
|
+
* env: { audienceEnvVar: "MYTOOL_AUDIENCE", log: { envVar: "MYTOOL_LOG_LEVEL" } },
|
|
127
|
+
* }),
|
|
128
|
+
* )
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @public
|
|
132
|
+
*/
|
|
133
|
+
var CliLog = class CliLog {
|
|
134
|
+
constructor() {}
|
|
135
|
+
/**
|
|
136
|
+
* The diagnostics threshold. Defaults to `None`, silent.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* `CliLog.layer` sets it from the environment variable. A scope may raise it to narrow the output; it
|
|
140
|
+
* cannot lower it below the level the layer installed, because Effect has already dropped those records.
|
|
141
|
+
*/
|
|
142
|
+
static Level = Level;
|
|
143
|
+
static layer(options = {}) {
|
|
144
|
+
const file = "file" in options ? options.file : void 0;
|
|
145
|
+
const format = options.format ?? "auto";
|
|
146
|
+
return Layer.unwrap(Effect.gen(function* () {
|
|
147
|
+
const audience = format === "auto" ? yield* Audience : void 0;
|
|
148
|
+
const terminal = format === "json" ? void 0 : yield* TerminalEnv;
|
|
149
|
+
const { level, invalid } = yield* readLevel(options.level, options.envVar);
|
|
150
|
+
const ambient = yield* References.MinimumLogLevel;
|
|
151
|
+
const captured = yield* Effect.serviceOption(CurrentRuntimeEnv);
|
|
152
|
+
const fallback = options.runtimeEnv === void 0 ? captured : Option.some(options.runtimeEnv);
|
|
153
|
+
const underActionsIn = actionsDecision(options.neutralize ?? "auto", fallback);
|
|
154
|
+
const color = terminal?.stderr.color ?? "none";
|
|
155
|
+
const isPretty = (record) => {
|
|
156
|
+
if (format !== "auto") return format === "pretty";
|
|
157
|
+
const inForce = Context.getOption(record.fiber.context, Audience);
|
|
158
|
+
return (Option.isSome(inForce) ? inForce.value.kind : audience?.kind) === "human";
|
|
159
|
+
};
|
|
160
|
+
const lowered = LogLevel.isLessThan(level, ambient) ? level : ambient;
|
|
161
|
+
const isLowered = lowered !== ambient;
|
|
162
|
+
const render = (record) => {
|
|
163
|
+
const underActions = underActionsIn(record.fiber);
|
|
164
|
+
if (!isPretty(record)) {
|
|
165
|
+
const json = Logger.formatJson.log(record);
|
|
166
|
+
return underActions ? neutralizeJson(json) : json;
|
|
167
|
+
}
|
|
168
|
+
const annotations = record.fiber.getRef(References.CurrentLogAnnotations);
|
|
169
|
+
const component = annotations.component === void 0 ? "" : ` [${sanitize(String(annotations.component))}]`;
|
|
170
|
+
const message = sanitize(Array.isArray(record.message) ? record.message.map(String).join(" ") : String(record.message));
|
|
171
|
+
const name = record.logLevel.toUpperCase();
|
|
172
|
+
const levelText = paintStyle(LEVEL_STYLES[name] ?? {}, color, name);
|
|
173
|
+
const cause = record.cause.reasons.length > 0 ? `\n${sanitize(Cause.pretty(record.cause))}` : "";
|
|
174
|
+
const line = `${record.date.toISOString().slice(11, 23)} ${levelText}${component} ${message}${cause}`;
|
|
175
|
+
return underActions ? CommandNeutralizer.text(line) : line;
|
|
176
|
+
};
|
|
177
|
+
const sink = Logger.make((record) => {
|
|
178
|
+
if (!passes(record, lowered)) return;
|
|
179
|
+
record.fiber.getRef(Console.Console).error(render(record));
|
|
180
|
+
});
|
|
181
|
+
const floor = (inner) => isLowered ? Logger.make((record) => {
|
|
182
|
+
const current = record.fiber.getRef(References.MinimumLogLevel);
|
|
183
|
+
const threshold = current === lowered ? ambient : current;
|
|
184
|
+
if (LogLevel.isGreaterThanOrEqualTo(record.logLevel, threshold)) inner.log(record);
|
|
185
|
+
}) : inner;
|
|
186
|
+
const cliLogger = floor(makeCliLogger(options.logger, underActionsIn));
|
|
187
|
+
const extras = (options.extraLoggers ?? []).map(floor);
|
|
188
|
+
if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([cliLogger])));
|
|
189
|
+
return Layer.mergeAll(Layer.succeed(CliLog.Level, level), isLowered ? Layer.succeed(References.MinimumLogLevel, lowered) : Layer.empty, Layer.effect(Logger.CurrentLoggers, Effect.gen(function* () {
|
|
190
|
+
const loggers = [
|
|
191
|
+
...options.plainLogger === false ? [] : [cliLogger],
|
|
192
|
+
sink,
|
|
193
|
+
...extras
|
|
194
|
+
];
|
|
195
|
+
if (file !== void 0) {
|
|
196
|
+
const target = "path" in file ? Option.some(file.path) : yield* Config.option(Config.String(file.envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
197
|
+
if (Option.isSome(target) && target.value !== "") {
|
|
198
|
+
const location = yield* Path.Path;
|
|
199
|
+
loggers.push(yield* makeFileSink(location.resolve(target.value), lowered, underActionsIn));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return new Set(loggers);
|
|
203
|
+
})));
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Mark the log records an effect emits as coming from `name`.
|
|
208
|
+
*
|
|
209
|
+
* @remarks
|
|
210
|
+
* Shown as `[name]` in pretty output and as `annotations.component` in NDJSON.
|
|
211
|
+
*
|
|
212
|
+
* @param name - the component
|
|
213
|
+
*/
|
|
214
|
+
static component = (name) => (self) => Effect.annotateLogs(self, "component", name);
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* The audience while the platform builds, from what needs no platform: a flag in `argv` (the rule `CliAudience` reads
|
|
218
|
+
* argv with), else the override variable, else detection from the environment. No terminal is consulted. Silent: the
|
|
219
|
+
* environment layer warns about an invalid override value itself, once.
|
|
220
|
+
*/
|
|
221
|
+
const buildTimeAudience = (argv, audienceEnvVar, detected) => {
|
|
222
|
+
const { given, conflict } = scanAudience(argv ?? []);
|
|
223
|
+
const [flagged] = given;
|
|
224
|
+
if (!conflict && flagged !== void 0) return Effect.succeed(flagged);
|
|
225
|
+
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()));
|
|
226
|
+
};
|
|
227
|
+
/** What the build-time loggers decide from, read once: the format and the runtime environment to neutralize by. */
|
|
228
|
+
const buildTimeDecision = (options, audienceEnvVar) => Effect.gen(function* () {
|
|
229
|
+
const detected = yield* Effect.provide(CurrentRuntimeEnv, CurrentRuntimeEnv.layer);
|
|
230
|
+
const format = options.format ?? "auto";
|
|
231
|
+
return {
|
|
232
|
+
ndjson: format === "json" || format === "auto" && (yield* buildTimeAudience(options.argv, audienceEnvVar, detected)) !== "human",
|
|
233
|
+
runtimeEnv: options.runtimeEnv ?? detected
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
/**
|
|
237
|
+
* The logger the platform is built under by `CliRuntime.main` with `env.log`: the log level and env var apply to
|
|
238
|
+
* what the platform logs while it builds, too.
|
|
239
|
+
*
|
|
240
|
+
* @remarks
|
|
241
|
+
* With `format: "json"`, or `auto` when the build-time audience (`argv`, the override variable, detection) is an
|
|
242
|
+
* agent or a CI, it is the full `CliLog.layer` in NDJSON (that format needs neither the audience nor the terminal,
|
|
243
|
+
* which the platform has not built yet), without the file sink, whose `FileSystem` the platform provides. Otherwise it
|
|
244
|
+
* is the plain `CliLogger`, with `MinimumLogLevel` lowered to the resolved level. Either way it neutralizes under
|
|
245
|
+
* GitHub Actions from the detected environment. The level is resolved silently: the program's own `CliLog.layer` warns
|
|
246
|
+
* about an invalid value, once.
|
|
247
|
+
*
|
|
248
|
+
* @internal
|
|
249
|
+
*/
|
|
250
|
+
const platformLogLayer = (options, audienceEnvVar) => Layer.unwrap(Effect.gen(function* () {
|
|
251
|
+
const { level } = yield* readLevel(options.level, options.envVar);
|
|
252
|
+
const ambient = yield* References.MinimumLogLevel;
|
|
253
|
+
const { ndjson, runtimeEnv } = yield* buildTimeDecision(options, audienceEnvVar);
|
|
254
|
+
if (ndjson) return CliLog.layer({
|
|
255
|
+
level,
|
|
256
|
+
format: "json",
|
|
257
|
+
...options.plainLogger === void 0 ? {} : { plainLogger: options.plainLogger },
|
|
258
|
+
...options.logger === void 0 ? {} : { logger: options.logger },
|
|
259
|
+
...options.extraLoggers === void 0 ? {} : { extraLoggers: options.extraLoggers },
|
|
260
|
+
...options.neutralize === void 0 ? {} : { neutralize: options.neutralize },
|
|
261
|
+
runtimeEnv
|
|
262
|
+
});
|
|
263
|
+
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);
|
|
264
|
+
}));
|
|
265
|
+
/**
|
|
266
|
+
* The logger `CliRuntime.main` builds the environment layer under: one line per record, in the build-time format.
|
|
267
|
+
*
|
|
268
|
+
* @remarks
|
|
269
|
+
* What the environment layer logs is a configuration error (an invalid audience override, which interpolates the
|
|
270
|
+
* variable's value), so it is never silenced and never written twice: NDJSON alone for an agent or a CI (`json`, or
|
|
271
|
+
* `auto` for that build-time audience), a plain `CliLogger` line otherwise, whatever `plainLogger` and the diagnostics
|
|
272
|
+
* level say, and neutralized under GitHub Actions like the platform's lines. It goes to stderr alone, whatever
|
|
273
|
+
* `stderrFrom` the program's `CliLogger` options raise, and never to `extraLoggers` or the file sink. Only this
|
|
274
|
+
* logger is pinned to stderr: the platform's build-time logger keeps the host's `stderrFrom`. It floors at `Warning` and installs no
|
|
275
|
+
* `MinimumLogLevel`, so `CliLog.layer`'s own build, which shares this context, reads the ambient minimum.
|
|
276
|
+
*
|
|
277
|
+
* @internal
|
|
278
|
+
*/
|
|
279
|
+
const envBuildLogLayer = (options, audienceEnvVar) => Layer.unwrap(Effect.gen(function* () {
|
|
280
|
+
const { ndjson, runtimeEnv } = yield* buildTimeDecision(options, audienceEnvVar);
|
|
281
|
+
const underActions = actionsDecision(options.neutralize ?? "auto", Option.some(runtimeEnv));
|
|
282
|
+
if (!ndjson) return Logger.layer([makeCliLogger({
|
|
283
|
+
...options.logger,
|
|
284
|
+
stderrFrom: "All"
|
|
285
|
+
}, underActions)]);
|
|
286
|
+
return Logger.layer([Logger.make((record) => {
|
|
287
|
+
if (!LogLevel.isGreaterThanOrEqualTo(record.logLevel, "Warn")) return;
|
|
288
|
+
const json = Logger.formatJson.log(record);
|
|
289
|
+
record.fiber.getRef(Console.Console).error(underActions(record.fiber) ? neutralizeJson(json) : json);
|
|
290
|
+
})]);
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
//#endregion
|
|
294
|
+
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
|
-
* **
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
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 };
|