@effected/cli 0.9.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.
Files changed (90) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +14 -20
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +253 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +294 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +83 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +104 -54
  14. package/CliTest.js +18 -2
  15. package/CliTheme.js +128 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +512 -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 +129 -131
  23. package/Render.js +254 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +163 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +2923 -171
  29. package/index.js +19 -1
  30. package/internal/HelpRouting.js +1 -1
  31. package/internal/ansi.js +230 -0
  32. package/internal/autoFormat.js +34 -0
  33. package/internal/canPrompt.js +15 -0
  34. package/internal/counts.js +69 -0
  35. package/internal/diagnostics.js +32 -0
  36. package/internal/displayWidth.js +35 -0
  37. package/internal/failureTarget.js +156 -0
  38. package/internal/fallbackAnswer.js +18 -0
  39. package/internal/fileSink.js +62 -0
  40. package/internal/format.js +62 -7
  41. package/internal/layout.js +250 -0
  42. package/internal/linkScheme.js +30 -0
  43. package/internal/linkTarget.js +50 -0
  44. package/internal/logSafety.js +46 -0
  45. package/internal/renderAnsi.js +52 -0
  46. package/internal/renderDoc.js +319 -0
  47. package/internal/renderGithubLog.js +46 -0
  48. package/internal/renderMarkdown.js +367 -0
  49. package/internal/renderPlain.js +50 -0
  50. package/internal/scanAudience.js +106 -0
  51. package/internal/splitFrame.js +56 -0
  52. package/internal/wizardGate.js +18 -0
  53. package/package.json +35 -5
  54. package/testing.d.ts +90 -4
  55. package/testing.js +2 -1
  56. package/ui/CliUi.js +348 -0
  57. package/ui/CliUiLive.js +399 -0
  58. package/ui/Confirm.js +245 -0
  59. package/ui/DocView.js +74 -0
  60. package/ui/KeyHelp.js +62 -0
  61. package/ui/KeyTable.js +199 -0
  62. package/ui/MultiSelect.js +260 -0
  63. package/ui/Select.js +226 -0
  64. package/ui/Tabs.js +202 -0
  65. package/ui/TextInput.js +250 -0
  66. package/ui/Toggle.js +32 -0
  67. package/ui/UiKey.js +44 -0
  68. package/ui/UiProvider.js +60 -0
  69. package/ui/UiStreams.js +18 -0
  70. package/ui/UiTheme.js +119 -0
  71. package/ui/Viewport.js +204 -0
  72. package/ui/internal/ErrorBoundary.js +30 -0
  73. package/ui/internal/Holder.js +74 -0
  74. package/ui/internal/ScreenContext.js +52 -0
  75. package/ui/internal/UiProviders.js +21 -0
  76. package/ui/internal/ink.js +122 -0
  77. package/ui/internal/inkChalk.js +58 -0
  78. package/ui/internal/inkConsole.js +146 -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 +735 -0
  85. package/ui/testing/fakeStreams.js +76 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing.d.ts +446 -0
  88. package/ui-testing.js +3 -0
  89. package/ui.d.ts +1648 -0
  90. package/ui.js +17 -0
@@ -0,0 +1,122 @@
1
+ import { inkChalk } from "./inkChalk.js";
2
+ import { Effect, Option } from "effect";
3
+
4
+ //#region src/ui/internal/ink.ts
5
+ const MISSING_PEERS = "@effected/cli/ui could not load its optional peers ink and react: install both beside @effected/cli to mount a screen";
6
+ const READ_BEFORE_LOAD = "@effected/cli/ui read Ink before loading it: run CliUi.context (or mount a screen with CliUi.run) before rendering UiProvider or a kit component in a tree of your own";
7
+ const UNRESOLVED_CHALK = "@effected/cli/ui could not resolve the chalk Ink uses (is ink bundled?), so Ink decides its own colour level";
8
+ let modules;
9
+ let loading;
10
+ const importPeers = async () => {
11
+ const [ink, react] = await Promise.all([import("ink"), import("react")]);
12
+ return {
13
+ ink,
14
+ react: react.default
15
+ };
16
+ };
17
+ /**
18
+ * Load `ink` and `react`, once. The only runtime access the kit has to either package: nothing imports a value
19
+ * from them, so importing `./ui` loads neither, and only mounting a screen does.
20
+ *
21
+ * @remarks
22
+ * Concurrent loads share one import, and a failed one is retried by the next call. A missing peer is a defect, not
23
+ * a typed failure, because it is an installation error no handler can recover from; its message names both peers.
24
+ *
25
+ * @internal
26
+ */
27
+ const loadInk = Effect.suspend(() => {
28
+ if (modules !== void 0) return Effect.succeed(modules);
29
+ return Effect.tryPromise({
30
+ try: () => {
31
+ loading ??= importPeers();
32
+ return loading;
33
+ },
34
+ catch: (cause) => new Error(MISSING_PEERS, { cause })
35
+ }).pipe(Effect.tap((loaded) => Effect.sync(() => {
36
+ modules = loaded;
37
+ })), Effect.tapError(() => Effect.sync(() => {
38
+ loading = void 0;
39
+ })), Effect.orDie);
40
+ });
41
+ /**
42
+ * The modules {@link loadInk} loaded, read at render time by kit components.
43
+ *
44
+ * @remarks
45
+ * Throws when nothing has loaded them: a kit component rendered outside a screen. Inside a render that throw is
46
+ * caught by the screen's error boundary and becomes a defect.
47
+ *
48
+ * @internal
49
+ */
50
+ const inkModules = () => {
51
+ if (modules === void 0) throw new Error(READ_BEFORE_LOAD);
52
+ return modules;
53
+ };
54
+ /**
55
+ * A value built once from the loaded React, such as a class component or a context, which cannot be declared at
56
+ * module scope because the kit holds no runtime React until {@link loadInk} runs.
57
+ *
58
+ * @remarks
59
+ * The returned accessor builds on first call and returns the same value thereafter; like {@link inkModules}, it
60
+ * throws if called before the load.
61
+ *
62
+ * @internal
63
+ */
64
+ const fromReact = (build) => {
65
+ let built;
66
+ return () => {
67
+ const { react } = inkModules();
68
+ if (built === void 0 || built.react !== react) built = {
69
+ react,
70
+ value: build(react)
71
+ };
72
+ return built.value;
73
+ };
74
+ };
75
+ const LEVELS = {
76
+ none: 0,
77
+ basic: 1,
78
+ "256": 2,
79
+ truecolor: 3
80
+ };
81
+ /**
82
+ * A stream's colour level as a chalk level: `none` 0, `basic` 1, `256` 2, `truecolor` 3.
83
+ *
84
+ * @internal
85
+ */
86
+ const levelOf = (colour) => LEVELS[colour];
87
+ let chalk;
88
+ const resolveChalk = Effect.promise(() => {
89
+ chalk ??= inkChalk();
90
+ return chalk;
91
+ });
92
+ let warned = false;
93
+ /**
94
+ * Hold `chalk` at `colour`'s level for the enclosing scope, restoring the saved level when the scope closes, by
95
+ * release or by interruption. With no chalk to hold, warn once and leave Ink's own detection in place.
96
+ *
97
+ * @internal
98
+ */
99
+ const holdChalkLevel = (found, colour) => Option.match(found, {
100
+ onNone: () => Effect.suspend(() => {
101
+ if (warned) return Effect.void;
102
+ warned = true;
103
+ return Effect.logWarning(UNRESOLVED_CHALK);
104
+ }),
105
+ onSome: (instance) => Effect.asVoid(Effect.acquireRelease(Effect.sync(() => {
106
+ const saved = instance.level;
107
+ instance.level = levelOf(colour);
108
+ return saved;
109
+ }), (saved) => Effect.sync(() => {
110
+ instance.level = saved;
111
+ })))
112
+ });
113
+ /**
114
+ * Set Ink's colour level from the stream's `ColorLevel` for the enclosing scope, on Ink's own chalk. The level is process-global while held: the last screen
115
+ * mounted wins.
116
+ *
117
+ * @internal
118
+ */
119
+ const withInkColour = (colour) => Effect.flatMap(resolveChalk, (found) => holdChalkLevel(found, colour));
120
+
121
+ //#endregion
122
+ export { fromReact, holdChalkLevel, inkModules, levelOf, loadInk, withInkColour };
@@ -0,0 +1,58 @@
1
+ import { createRequire } from "node:module";
2
+ import { Option } from "effect";
3
+ import { realpathSync } from "node:fs";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ //#region src/ui/internal/inkChalk.ts
7
+ const isInkChalk = (value) => (typeof value === "function" || typeof value === "object" && value !== null) && typeof value.level === "number";
8
+ /** The runtime's `import.meta.resolve`, when it has one. */
9
+ const esmResolve = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
10
+ /**
11
+ * The path of Ink's entry: through `resolve` (the runtime's `import.meta.resolve` by default), or, where that is
12
+ * missing or throws, through Node's CommonJS resolution from this module.
13
+ *
14
+ * @remarks
15
+ * Vite's module runner, which evaluates a Vitest reporter loaded by path, has an `import.meta.resolve` that throws
16
+ * ("not supported"); the CommonJS resolution finds the same file, since Ink's `exports` has a `default` condition.
17
+ *
18
+ * @param resolve - an ESM resolver, to stand in for the runtime's in a test
19
+ *
20
+ * @internal
21
+ */
22
+ const resolveInkEntry = (resolve = esmResolve) => {
23
+ if (resolve !== void 0) try {
24
+ return fileURLToPath(resolve("ink"));
25
+ } catch {}
26
+ return createRequire(import.meta.url).resolve("ink");
27
+ };
28
+ /**
29
+ * The chalk instance Ink itself imports, resolved from Ink's own location; `None` when it cannot be resolved.
30
+ *
31
+ * @remarks
32
+ * One of the three files licensed to touch Node. Ink's `exports` has
33
+ * only `"."` and chalk is its own dependency, so the kit cannot import Ink's chalk by name: a `chalk` of the kit's
34
+ * own could be a different copy, and setting its level would silently change nothing. Resolving `chalk` from Ink's
35
+ * entry ({@link resolveInkEntry}) and importing its realpath yields the very module Ink imports, because Node keys ES
36
+ * modules by realpath. A consumer that bundles Ink leaves nothing
37
+ * to resolve, and the answer is `None`; it never rejects.
38
+ *
39
+ * @param inkEntryOf - how Ink's entry is found; {@link resolveInkEntry} by default
40
+ *
41
+ * @internal
42
+ */
43
+ const inkChalk = async (inkEntryOf = () => resolveInkEntry()) => {
44
+ try {
45
+ const inkEntry = inkEntryOf();
46
+ const chalkPath = realpathSync(createRequire(inkEntry).resolve("chalk"));
47
+ const chalk = await import(
48
+ /* @vite-ignore */
49
+ pathToFileURL(chalkPath).href
50
+ );
51
+ return isInkChalk(chalk.default) ? Option.some(chalk.default) : Option.none();
52
+ } catch {
53
+ return Option.none();
54
+ }
55
+ };
56
+
57
+ //#endregion
58
+ export { inkChalk, resolveInkEntry };
@@ -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,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 };