@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.
- 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 +302 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +346 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +80 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +110 -54
- package/CliTest.js +16 -0
- package/CliTheme.js +141 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +536 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +145 -131
- package/Render.js +255 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +166 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +3089 -169
- 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 +84 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +195 -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 +320 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +368 -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 +40 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +432 -0
- package/ui/CliUiLive.js +446 -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 +230 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +290 -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/lazyView.js +74 -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 +760 -0
- package/ui/testing/fakeStreams.js +79 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +527 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1790 -0
- package/ui.js +17 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { inkModules } from "./ink.js";
|
|
2
|
+
import { UiStreams } from "../UiStreams.js";
|
|
3
|
+
import { Effect, Inspectable } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/ui/internal/inkConsole.ts
|
|
6
|
+
/** One argument as a console shows it: a string as is, an `Error` with its stack, anything else as data (JSON). */
|
|
7
|
+
const shown = (arg) => arg instanceof Error ? arg.stack ?? String(arg) : Inspectable.toStringUnknown(arg, 0);
|
|
8
|
+
/** A console call's arguments as one line of text, joined by spaces. */
|
|
9
|
+
const textOf = (args) => args.map(shown).join(" ");
|
|
10
|
+
/** `console.table`'s rows, as a plain pipe table: an `(index)` column, then each key, then `Values` for scalars. */
|
|
11
|
+
const tableOf = (data, properties) => {
|
|
12
|
+
if (data === null || typeof data !== "object") return textOf([data]);
|
|
13
|
+
const rows = Object.entries(data);
|
|
14
|
+
const isRecord = (value) => value !== null && typeof value === "object";
|
|
15
|
+
const keys = properties ?? [...new Set(rows.flatMap(([, value]) => isRecord(value) ? Object.keys(value) : []))];
|
|
16
|
+
const scalars = rows.some(([, value]) => !isRecord(value));
|
|
17
|
+
const header = [
|
|
18
|
+
"(index)",
|
|
19
|
+
...keys,
|
|
20
|
+
...scalars ? ["Values"] : []
|
|
21
|
+
];
|
|
22
|
+
const cell = (value) => value === void 0 ? "" : typeof value === "string" ? value : shown(value);
|
|
23
|
+
const body = rows.map(([index, value]) => [
|
|
24
|
+
index,
|
|
25
|
+
...keys.map((key) => isRecord(value) ? cell(value[key]) : ""),
|
|
26
|
+
...scalars ? [isRecord(value) ? "" : cell(value)] : []
|
|
27
|
+
]);
|
|
28
|
+
const widths = header.map((title, column) => Math.max(title.length, ...body.map((row) => (row[column] ?? "").length)));
|
|
29
|
+
const line = (cells) => `| ${cells.map((text, column) => text.padEnd(widths[column] ?? 0)).join(" | ")} |`;
|
|
30
|
+
return [line(header), ...body.map(line)].join("\n");
|
|
31
|
+
};
|
|
32
|
+
/** Milliseconds now, for `time`; the platform clock when there is one. */
|
|
33
|
+
const now = () => globalThis.performance?.now() ?? Date.now();
|
|
34
|
+
/**
|
|
35
|
+
* Build an `InkConsole` over `UiStreams`.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Ink's writers (`useStdout().write`, `useStderr().write`) clear the frame, write the line and repaint the frame, so a
|
|
39
|
+
* line written through them lands above the frame without tearing it; a raw write to the stream lands inside the frame
|
|
40
|
+
* instead. They are reachable only from inside the tree, which is what the `Bridge` is for. Ink drops whatever they
|
|
41
|
+
* are handed once it has unmounted, so the owner detaches before it unmounts, and a line written after goes straight
|
|
42
|
+
* to the stream.
|
|
43
|
+
*
|
|
44
|
+
* Every `Console` method is the bridge's own, none the ambient console's: one that fell through would write to the
|
|
45
|
+
* process's terminal past Ink, tearing the frame and escaping `UiStreams`.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
const makeInkConsole = Effect.gen(function* () {
|
|
50
|
+
const streams = yield* UiStreams;
|
|
51
|
+
let attached;
|
|
52
|
+
let indent = "";
|
|
53
|
+
const counts = /* @__PURE__ */ new Map();
|
|
54
|
+
const timers = /* @__PURE__ */ new Map();
|
|
55
|
+
const emit = (stream, text) => {
|
|
56
|
+
const data = `${text.split("\n").map((line) => indent + line).join("\n")}\n`;
|
|
57
|
+
if (attached !== void 0) (stream === "out" ? attached.out : attached.err)(data);
|
|
58
|
+
else if (stream === "out") streams.stdout.write(data);
|
|
59
|
+
else streams.stderr.write(data);
|
|
60
|
+
};
|
|
61
|
+
const toOut = (...args) => emit("out", textOf(args));
|
|
62
|
+
const toErr = (...args) => emit("err", textOf(args));
|
|
63
|
+
const elapsed = (label, method, extra) => {
|
|
64
|
+
const started = timers.get(label);
|
|
65
|
+
if (started === void 0) {
|
|
66
|
+
emit("err", `Warning: No such label '${label}' for console.${method}()`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const tail = extra.length === 0 ? "" : ` ${textOf(extra)}`;
|
|
70
|
+
emit("out", `${label}: ${(now() - started).toFixed(3)}ms${tail}`);
|
|
71
|
+
};
|
|
72
|
+
const group = (...label) => {
|
|
73
|
+
if (label.length > 0) toOut(...label);
|
|
74
|
+
indent += " ";
|
|
75
|
+
};
|
|
76
|
+
const writer = {
|
|
77
|
+
log: toOut,
|
|
78
|
+
info: toOut,
|
|
79
|
+
debug: toOut,
|
|
80
|
+
dirxml: toOut,
|
|
81
|
+
error: toErr,
|
|
82
|
+
warn: toErr,
|
|
83
|
+
trace: (...args) => {
|
|
84
|
+
const stack = ((/* @__PURE__ */ new Error()).stack ?? "").split("\n").slice(2).join("\n");
|
|
85
|
+
emit("err", `Trace${args.length === 0 ? "" : `: ${textOf(args)}`}${stack === "" ? "" : `\n${stack}`}`);
|
|
86
|
+
},
|
|
87
|
+
dir: (item) => toOut(item),
|
|
88
|
+
table: (data, properties) => emit("out", tableOf(data, properties)),
|
|
89
|
+
assert: (condition, ...args) => {
|
|
90
|
+
if (!condition) emit("err", `Assertion failed${args.length === 0 ? "" : `: ${textOf(args)}`}`);
|
|
91
|
+
},
|
|
92
|
+
count: (label = "default") => {
|
|
93
|
+
const next = (counts.get(label) ?? 0) + 1;
|
|
94
|
+
counts.set(label, next);
|
|
95
|
+
emit("out", `${label}: ${next}`);
|
|
96
|
+
},
|
|
97
|
+
countReset: (label = "default") => {
|
|
98
|
+
counts.delete(label);
|
|
99
|
+
},
|
|
100
|
+
group,
|
|
101
|
+
groupCollapsed: group,
|
|
102
|
+
groupEnd: () => {
|
|
103
|
+
indent = indent.slice(2);
|
|
104
|
+
},
|
|
105
|
+
time: (label = "default") => {
|
|
106
|
+
timers.set(label, now());
|
|
107
|
+
},
|
|
108
|
+
timeLog: (label = "default", ...extra) => elapsed(label, "timeLog", extra),
|
|
109
|
+
timeEnd: (label = "default") => {
|
|
110
|
+
elapsed(label, "timeEnd", []);
|
|
111
|
+
timers.delete(label);
|
|
112
|
+
},
|
|
113
|
+
clear: () => void 0
|
|
114
|
+
};
|
|
115
|
+
const Bridge = (props) => {
|
|
116
|
+
const { ink, react } = inkModules();
|
|
117
|
+
const out = ink.useStdout().write;
|
|
118
|
+
const err = ink.useStderr().write;
|
|
119
|
+
react.useLayoutEffect(() => {
|
|
120
|
+
const writers = {
|
|
121
|
+
out,
|
|
122
|
+
err
|
|
123
|
+
};
|
|
124
|
+
attached = writers;
|
|
125
|
+
return () => {
|
|
126
|
+
if (attached === writers) attached = void 0;
|
|
127
|
+
};
|
|
128
|
+
}, [out, err]);
|
|
129
|
+
return props.children ?? null;
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
writer,
|
|
133
|
+
print: (text) => {
|
|
134
|
+
const data = `${text}\n`;
|
|
135
|
+
if (attached !== void 0) attached.out(data);
|
|
136
|
+
else streams.stdout.write(data);
|
|
137
|
+
},
|
|
138
|
+
Bridge,
|
|
139
|
+
detach: () => {
|
|
140
|
+
attached = void 0;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { makeInkConsole };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/internal/lazyView.ts
|
|
4
|
+
/** Where a lazy view keeps its loader: a `Symbol.for` key, so two copies of the package agree on it. */
|
|
5
|
+
const LOAD = Symbol.for("@effected/cli/ui/lazyView");
|
|
6
|
+
const NOT_LOADED = "@effected/cli/ui: a CliUi.lazyView render was called before its module loaded; only CliUi.live loads it first";
|
|
7
|
+
const kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "object" ? "an object" : typeof value;
|
|
8
|
+
const EXPECTED = "a view, (state, frame) => ReactElement, or a module whose default export is one: { default: view }";
|
|
9
|
+
/** What a `load` resolved to that is no view: said with what was received and what is expected. */
|
|
10
|
+
const NO_VIEW = (resolved) => {
|
|
11
|
+
if (typeof resolved !== "object" || resolved === null) return `@effected/cli/ui: CliUi.lazyView's load resolved to ${kindOf(resolved)}. Expected ${EXPECTED}`;
|
|
12
|
+
const named = Object.keys(resolved).filter((key) => key !== "default");
|
|
13
|
+
const exports = named.length === 0 ? "" : ` (it exports ${named.join(", ")})`;
|
|
14
|
+
return `@effected/cli/ui: CliUi.lazyView's load resolved to ${Object.hasOwn(resolved, "default") ? `a module whose default export is ${kindOf(resolved.default)}, not a function${exports}; a CommonJS module imported as ESM nests it one level deeper, as default.default` : `a module with no default export${exports}; resolve to the export itself, as .then((module) => module.name)`}. Expected ${EXPECTED}`;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A load that resolved to no view: deterministic, so a lazy view keeps it (one error object for the handle's life) and
|
|
18
|
+
* the live view warns about it once, where a failed import is tried again by the next run.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
var LazyViewShapeError = class extends Error {};
|
|
23
|
+
/** The view `load` resolved to: the value itself when it is a function (a `default` property on it is ignored), else
|
|
24
|
+
* its `default` when that is a function; anything else throws, saying what it got. */
|
|
25
|
+
const pick = (resolved) => {
|
|
26
|
+
if (typeof resolved === "function") return resolved;
|
|
27
|
+
if (typeof resolved === "object" && resolved !== null) {
|
|
28
|
+
const fallback = resolved.default;
|
|
29
|
+
if (typeof fallback === "function") return fallback;
|
|
30
|
+
}
|
|
31
|
+
throw new LazyViewShapeError(NO_VIEW(resolved));
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* `CliUi.lazyView`: a `render` that draws with what `load` resolves to, loaded on first use: the render itself, or a
|
|
35
|
+
* module whose default export it is. One load is shared: an import that rejected is cleared, so the next run loads
|
|
36
|
+
* again; a load that resolved to no view is kept, a `LazyViewShapeError` every later run gets as is. Calling the render
|
|
37
|
+
* before it has loaded is a defect, since only `CliUi.live` knows to load it first.
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
const lazyView = (load) => {
|
|
42
|
+
let loaded;
|
|
43
|
+
let pending;
|
|
44
|
+
const ensure = () => {
|
|
45
|
+
pending ??= load().then((resolved) => {
|
|
46
|
+
loaded = pick(resolved);
|
|
47
|
+
}, (error) => {
|
|
48
|
+
pending = void 0;
|
|
49
|
+
throw error;
|
|
50
|
+
});
|
|
51
|
+
return pending;
|
|
52
|
+
};
|
|
53
|
+
const render = (state, frame) => {
|
|
54
|
+
if (loaded === void 0) throw new Error(NOT_LOADED);
|
|
55
|
+
return loaded(state, frame);
|
|
56
|
+
};
|
|
57
|
+
return Object.assign(render, { [LOAD]: ensure });
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Load a lazy view's module before its render is first called; nothing for a render that is not lazy. Fails with what
|
|
61
|
+
* the import failed with.
|
|
62
|
+
*
|
|
63
|
+
* @internal
|
|
64
|
+
*/
|
|
65
|
+
const loadView = (render) => {
|
|
66
|
+
const ensure = render[LOAD];
|
|
67
|
+
return ensure === void 0 ? Effect.void : Effect.asVoid(Effect.tryPromise({
|
|
68
|
+
try: ensure,
|
|
69
|
+
catch: (error) => error
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
export { LazyViewShapeError, lazyView, loadView };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sanitize } from "../../Fmt.js";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/internal/lineText.ts
|
|
4
|
+
/**
|
|
5
|
+
* Consumer text made safe for one row of a widget: sanitised as `Fmt.sanitize` does (no escape sequence, no other
|
|
6
|
+
* control character), with each line break (CR, LF or CR LF) folded to a space.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Every string a kit widget draws from data goes through it before it is measured or cut. Ink keeps SGR and OSC
|
|
10
|
+
* sequences it is handed, so unsanitised text would paint colour at colour `none` and could plant a hyperlink whose
|
|
11
|
+
* target differs from its label; and a widget's row budget counts one row per line, so a second line would push the
|
|
12
|
+
* frame past the terminal, where Ink wipes the screen and its scrollback.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
const lineText = (text) => sanitize(text).replace(/\r\n|\r|\n/g, " ");
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
export { lineText };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Semaphore } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/internal/mountPermit.ts
|
|
4
|
+
/**
|
|
5
|
+
* One Ink mount at a time, process-wide: a `CliUi.run` screen, or one run of a live view.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Ink keys its instances by stdout and owns raw mode on the one terminal, so concurrent mounts are meaningless, and
|
|
9
|
+
* serializing them keeps each mount's save-and-restore of Ink's colour level well nested.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
const mountPermit = Semaphore.makeUnsafe(1);
|
|
14
|
+
|
|
15
|
+
//#endregion
|
|
16
|
+
export { mountPermit };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Config, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/internal/perfDrain.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether a drain mode drains: `true` and `false` as given, and `"auto"` unless `NODE_ENV` is exactly `"production"`.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* React picks its development build whenever `NODE_ENV` is not exactly `"production"`, and that build records
|
|
9
|
+
* user-timing entries on every render and never clears them. An
|
|
10
|
+
* unset `NODE_ENV`, the common case for a CLI, leaks like `"development"`, so `"auto"` drains then too. `NODE_ENV` is
|
|
11
|
+
* read through `Config`, so a test sets it with a `ConfigProvider` and `./ui` reads no `process`.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
const resolveDrain = (mode) => mode === "auto" ? Config.String("NODE_ENV").pipe(Config.withDefault(""), Effect.map((env) => env !== "production"), Effect.orElseSucceed(() => true)) : Effect.succeed(mode);
|
|
16
|
+
/**
|
|
17
|
+
* Clear every user-timing `measure` entry in the process, when `drain` is true.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* The clear is global: it removes a consumer's own measures too, because the platform's `clearMeasures` filters only
|
|
21
|
+
* by name, and React's entries (`Update`, `Mount`, tagged `detail.devtools`) share their names with anything a
|
|
22
|
+
* consumer might call a measure. Marks are left alone: React's development build leaks measures only, never marks,
|
|
23
|
+
* so clearing marks would only take a host's own. A runtime without `performance` is left alone.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
const drainPerformance = (drain) => {
|
|
28
|
+
if (!drain) return;
|
|
29
|
+
globalThis.performance?.clearMeasures?.();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { drainPerformance, resolveDrain };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/ui/internal/processStreams.ts
|
|
2
|
+
/**
|
|
3
|
+
* The process's own standard streams, the default `UiStreams`.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* One of the three files licensed to touch Node: it reads
|
|
7
|
+
* `process.stdin`, `process.stdout` and `process.stderr` and nothing else, and only when called, never at import.
|
|
8
|
+
* The boundary test holds that licence exact.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const processStreams = () => ({
|
|
13
|
+
stdin: process.stdin,
|
|
14
|
+
stdout: process.stdout,
|
|
15
|
+
stderr: process.stderr
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
export { processStreams };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/internal/renderOptions.ts
|
|
4
|
+
/**
|
|
5
|
+
* The render-option overrides in force: none by default. Internal, so `CliUi.run`'s public signature stays as
|
|
6
|
+
* specified; `CliUiTest` sets it.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
var UiRenderOptions = class extends Context.Reference("@effected/cli/ui/UiRenderOptions", { defaultValue: () => ({}) }) {};
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
export { UiRenderOptions };
|