@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
package/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, FileSystem, Path, PlatformError, Scope } from "effect";
|
|
1
|
+
import { Effect, FileSystem, Layer, Path, PlatformError, Scope, Terminal } from "effect";
|
|
2
2
|
import { ChildProcessSpawner } from "effect/process";
|
|
3
3
|
//#region src/CliTest.d.ts
|
|
4
4
|
/**
|
|
@@ -57,6 +57,22 @@ interface RunResult {
|
|
|
57
57
|
/**
|
|
58
58
|
* Spawn a built CLI bin hermetically and read its exit code and streams as data.
|
|
59
59
|
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
63
|
+
* import { assert, it } from "@effect/vitest"
|
|
64
|
+
* import { CliTest } from "@effected/cli/testing"
|
|
65
|
+
* import { Effect } from "effect"
|
|
66
|
+
*
|
|
67
|
+
* it.effect("prints its version", () =>
|
|
68
|
+
* Effect.gen(function* () {
|
|
69
|
+
* const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" })
|
|
70
|
+
* const result = yield* CliTest.run("dist/bin.js", ["--version"], { sandbox, execPath: process.execPath })
|
|
71
|
+
* assert.strictEqual(result.exitCode, 0)
|
|
72
|
+
* }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
|
|
73
|
+
* )
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
60
76
|
* @public
|
|
61
77
|
*/
|
|
62
78
|
export declare class CliTest {
|
|
@@ -90,5 +106,75 @@ export declare class CliTest {
|
|
|
90
106
|
static readonly run: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<RunResult, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
|
|
91
107
|
}
|
|
92
108
|
//#endregion
|
|
93
|
-
|
|
109
|
+
//#region src/TestTerminal.d.ts
|
|
110
|
+
/**
|
|
111
|
+
* One key press for {@link TestTerminal}.
|
|
112
|
+
*
|
|
113
|
+
* @public
|
|
114
|
+
*/
|
|
115
|
+
interface KeyInput {
|
|
116
|
+
/** The key name the prompts switch on: `down`, `up`, `enter`, `space`, `escape`, or a character. */
|
|
117
|
+
readonly name: string;
|
|
118
|
+
/** Whether Ctrl is held. */
|
|
119
|
+
readonly ctrl?: boolean | undefined;
|
|
120
|
+
/** Whether Meta is held. */
|
|
121
|
+
readonly meta?: boolean | undefined;
|
|
122
|
+
/** Whether Shift is held. */
|
|
123
|
+
readonly shift?: boolean | undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* What {@link TestTerminal.make} builds: the `Terminal` layer and the means to drive and inspect it.
|
|
127
|
+
*
|
|
128
|
+
* @public
|
|
129
|
+
*/
|
|
130
|
+
interface TestTerminalHandle {
|
|
131
|
+
/** Provides `Terminal` backed by this double. */
|
|
132
|
+
readonly layer: Layer.Layer<Terminal.Terminal>;
|
|
133
|
+
/** Queue key presses, as if the user had typed them. */
|
|
134
|
+
readonly input: (keys: ReadonlyArray<KeyInput>) => Effect.Effect<void>;
|
|
135
|
+
/** Queue `text` one character at a time. */
|
|
136
|
+
readonly type: (text: string) => Effect.Effect<void>;
|
|
137
|
+
/** End the input, as Ctrl-C or end-of-file does: a prompt waiting for a key is quit. */
|
|
138
|
+
readonly end: Effect.Effect<void>;
|
|
139
|
+
/** Everything written to the terminal so far, prompt frames and escape codes included. */
|
|
140
|
+
readonly output: Effect.Effect<string>;
|
|
141
|
+
/** How many queued key presses nobody has taken yet. */
|
|
142
|
+
readonly pending: Effect.Effect<number>;
|
|
143
|
+
/**
|
|
144
|
+
* What the program did with the input: `keys` taken from it, `lines` read with `readLine`, and
|
|
145
|
+
* `subscriptions` to `readInput`. All `0` proves a code path never touched the terminal's input. Counting the
|
|
146
|
+
* subscription matters: on a real terminal merely subscribing attaches a reader to stdin, even when no key is
|
|
147
|
+
* ever taken.
|
|
148
|
+
*/
|
|
149
|
+
readonly reads: Effect.Effect<{
|
|
150
|
+
readonly keys: number;
|
|
151
|
+
readonly lines: number;
|
|
152
|
+
readonly subscriptions: number;
|
|
153
|
+
}>;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A scripted `Terminal` for testing prompts and anything that reads the terminal.
|
|
157
|
+
*
|
|
158
|
+
* @remarks
|
|
159
|
+
* Queue keys with `input` or `type`, run the program under `layer`, then read `output`. To assert that a code path
|
|
160
|
+
* did NOT touch the terminal, queue some keys first and check `reads` is all zero (no subscription, no key, no
|
|
161
|
+
* line) and `pending` is unchanged afterwards. Only
|
|
162
|
+
* available from `@effected/cli/testing`.
|
|
163
|
+
*
|
|
164
|
+
* @public
|
|
165
|
+
*/
|
|
166
|
+
export declare class TestTerminal {
|
|
167
|
+
private constructor();
|
|
168
|
+
/**
|
|
169
|
+
* Build a test terminal.
|
|
170
|
+
*
|
|
171
|
+
* @param options - the reported size; 80 by 24 by default
|
|
172
|
+
*/
|
|
173
|
+
static readonly make: (options?: {
|
|
174
|
+
readonly columns?: number | undefined;
|
|
175
|
+
readonly rows?: number | undefined;
|
|
176
|
+
}) => Effect.Effect<TestTerminalHandle>;
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
export type { KeyInput, RunOptions, RunResult, Sandbox, TestTerminalHandle };
|
|
94
180
|
//# sourceMappingURL=testing.d.ts.map
|
package/testing.js
CHANGED
package/ui/CliUi.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { Cancelled } from "../Cancelled.js";
|
|
2
|
+
import { CliInteractive } from "../CliInteractive.js";
|
|
3
|
+
import { underGithubActions } from "../internal/autoFormat.js";
|
|
4
|
+
import { CliTheme } from "../CliTheme.js";
|
|
5
|
+
import { NotInteractive } from "../NotInteractive.js";
|
|
6
|
+
import { answerWithoutPerson } from "../internal/fallbackAnswer.js";
|
|
7
|
+
import { inkModules, loadInk, withInkColour } from "./internal/ink.js";
|
|
8
|
+
import { errorBoundary } from "./internal/ErrorBoundary.js";
|
|
9
|
+
import { UiStreams } from "./UiStreams.js";
|
|
10
|
+
import { lazyView } from "./internal/lazyView.js";
|
|
11
|
+
import { mountPermit } from "./internal/mountPermit.js";
|
|
12
|
+
import { UiRenderOptions } from "./internal/renderOptions.js";
|
|
13
|
+
import { useScreenGuard } from "./internal/ScreenContext.js";
|
|
14
|
+
import { uiProviders } from "./internal/UiProviders.js";
|
|
15
|
+
import { live } from "./CliUiLive.js";
|
|
16
|
+
import { KeyTable, useKeys } from "./KeyTable.js";
|
|
17
|
+
import { Cause, Deferred, Effect, Exit, Option, Semaphore } from "effect";
|
|
18
|
+
import { Audience } from "@effected/env";
|
|
19
|
+
import { Prompt } from "effect/cli";
|
|
20
|
+
|
|
21
|
+
//#region src/ui/CliUi.ts
|
|
22
|
+
/**
|
|
23
|
+
* stdout's theme as the audience sees it: colourless for an agent (as `Render.context` makes it), so a screen's
|
|
24
|
+
* `useTheme`, `Styled` and the widgets' colour-none markers never carry an escape for one. `Audience` is read only when
|
|
25
|
+
* provided, so it stays out of the requirements.
|
|
26
|
+
*/
|
|
27
|
+
const audienceTheme = Effect.gen(function* () {
|
|
28
|
+
const audience = yield* Effect.serviceOption(Audience);
|
|
29
|
+
return CliTheme.forAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
|
|
30
|
+
});
|
|
31
|
+
/** The root keys: Esc cancels with `"escape"`, Ctrl-C with `"interrupt"`. `q` belongs to widgets, never here. */
|
|
32
|
+
const RootKeys = (props) => {
|
|
33
|
+
useKeys(KeyTable.root, props.cancel);
|
|
34
|
+
const guard = useScreenGuard();
|
|
35
|
+
inkModules().ink.usePaste(guard(() => void 0));
|
|
36
|
+
return props.children;
|
|
37
|
+
};
|
|
38
|
+
const SCREEN_EXITED = "@effected/cli/ui: the screen exited without resolving or cancelling";
|
|
39
|
+
const mount = (screen, theme, clear, crash, neutralize) => Effect.gen(function* () {
|
|
40
|
+
const overrides = yield* UiRenderOptions;
|
|
41
|
+
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("screen")), (_, exit) => Effect.sync(() => {
|
|
42
|
+
const died = Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isDieReason) : void 0;
|
|
43
|
+
const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
44
|
+
overrides.onUnmount?.(died !== void 0 ? { defect: died.defect } : interrupted ? void 0 : crash.current);
|
|
45
|
+
}));
|
|
46
|
+
const { ink, react } = yield* loadInk;
|
|
47
|
+
const streams = yield* UiStreams;
|
|
48
|
+
yield* withInkColour(theme.color);
|
|
49
|
+
const result = yield* Deferred.make();
|
|
50
|
+
const control = {
|
|
51
|
+
resolve: (value) => {
|
|
52
|
+
Deferred.doneUnsafe(result, Exit.succeed(value));
|
|
53
|
+
},
|
|
54
|
+
cancel: (reason) => {
|
|
55
|
+
Deferred.doneUnsafe(result, Exit.fail(new Cancelled({ reason })));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const element = yield* Effect.promise(async () => screen(control));
|
|
59
|
+
const die = (error) => {
|
|
60
|
+
if (crash.current === void 0) crash.current = { defect: error };
|
|
61
|
+
Deferred.doneUnsafe(result, Exit.die(error));
|
|
62
|
+
};
|
|
63
|
+
const tree = react.createElement(errorBoundary(), {
|
|
64
|
+
onError: die,
|
|
65
|
+
children: uiProviders({
|
|
66
|
+
cancel: control.cancel,
|
|
67
|
+
die,
|
|
68
|
+
theme,
|
|
69
|
+
glyphs: theme.glyphs,
|
|
70
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
71
|
+
}, react.createElement(RootKeys, {
|
|
72
|
+
cancel: control.cancel,
|
|
73
|
+
children: element
|
|
74
|
+
}))
|
|
75
|
+
});
|
|
76
|
+
const instance = yield* Effect.acquireRelease(Effect.sync(() => ink.render(tree, {
|
|
77
|
+
stdin: streams.stdin,
|
|
78
|
+
stdout: streams.stdout,
|
|
79
|
+
stderr: streams.stderr,
|
|
80
|
+
interactive: true,
|
|
81
|
+
exitOnCtrlC: false,
|
|
82
|
+
patchConsole: false,
|
|
83
|
+
...overrides.debug === true ? { debug: true } : {},
|
|
84
|
+
...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender },
|
|
85
|
+
...overrides.maxFps === void 0 ? {} : { maxFps: overrides.maxFps }
|
|
86
|
+
})), (instance) => Effect.promise(async () => {
|
|
87
|
+
if (clear) instance.clear();
|
|
88
|
+
const exited = instance.waitUntilExit();
|
|
89
|
+
instance.unmount();
|
|
90
|
+
await exited.catch(() => void 0);
|
|
91
|
+
}));
|
|
92
|
+
const exited = Effect.tryPromise({
|
|
93
|
+
try: () => instance.waitUntilExit(),
|
|
94
|
+
catch: (cause) => cause
|
|
95
|
+
}).pipe(Effect.orDie, Effect.flatMap(() => Effect.flatMap(Deferred.isDone(result), (done) => done ? Deferred.await(result) : Effect.die(/* @__PURE__ */ new Error(SCREEN_EXITED)))));
|
|
96
|
+
return yield* Effect.raceFirst(Deferred.await(result), exited);
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* Interactive screens drawn with Ink, mounted as scoped resources.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { CliUi, Confirm } from "@effected/cli/ui"
|
|
104
|
+
* import { Effect } from "effect"
|
|
105
|
+
*
|
|
106
|
+
* // Answers `otherwise` when there is no person to ask, and fails with `Cancelled` when they back out.
|
|
107
|
+
* const ask = CliUi.prompt(Confirm.screen({ message: "Overwrite the config?" }), {
|
|
108
|
+
* otherwise: { confirmed: false, toggles: {} },
|
|
109
|
+
* })
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
var CliUi = class CliUi {
|
|
115
|
+
constructor() {}
|
|
116
|
+
/**
|
|
117
|
+
* Mount `screen` and wait for it to resolve or cancel.
|
|
118
|
+
*
|
|
119
|
+
* @remarks
|
|
120
|
+
* When `CliInteractive` is false it fails with `NotInteractive` and mounts nothing; Ink and React are not even
|
|
121
|
+
* loaded. Otherwise it loads them, holds Ink's colour level at stdout's, and mounts the screen on
|
|
122
|
+
* `UiStreams` with Ink's own Ctrl-C exit off: Ctrl-C cancels with `"interrupt"` and Esc with `"escape"`.
|
|
123
|
+
*
|
|
124
|
+
* Mounting is one scoped resource. However the screen ends (resolved, cancelled, crashed, or the fiber
|
|
125
|
+
* interrupted), it is unmounted, raw mode and bracketed paste are off, the cursor is shown, and the colour level is
|
|
126
|
+
* restored. A
|
|
127
|
+
* component that throws is a defect, never a hang or a typed failure, and nothing of Ink's crash screen reaches
|
|
128
|
+
* stdout. So is a `useKeys` handler that throws; a handler a consumer registers with Ink's own `useInput` or
|
|
129
|
+
* `usePaste` is outside the kit, and what it throws escapes as Ink leaves it. A crash wins over an end in the same
|
|
130
|
+
* tick: a handler that cancels or resolves and then throws, or a component that throws before the screen has
|
|
131
|
+
* unmounted, is a defect, never the `Cancelled` or the value; a defect raised while the screen unmounts stays beside
|
|
132
|
+
* that crash in the cause rather than replacing it. An interrupt stays an interrupt, even when the tree reports a
|
|
133
|
+
* crash as it unmounts.
|
|
134
|
+
*
|
|
135
|
+
* A screen draws on stdout (`UiStreams`), and mounts only when `CliInteractive` is true: a human audience, a
|
|
136
|
+
* terminal on both stdin and stdout, and a `TERM` that is not `dumb`. `CliInteractive` reads `false` until a layer
|
|
137
|
+
* sets it (`CliRuntime.main`'s `env` does), so a program that never provides one always gets `NotInteractive`.
|
|
138
|
+
*
|
|
139
|
+
* Screens run one at a time, process-wide: Ink owns raw mode on the one terminal, so a second `run` waits until the
|
|
140
|
+
* first is released; so does a `run` while a {@link CliUi.live} view has a run drawn. A screen that itself awaits
|
|
141
|
+
* another `CliUi.run` therefore deadlocks, and nothing guards against it.
|
|
142
|
+
*
|
|
143
|
+
* Do not log while a screen is mounted. Ink redraws its frame by counting the lines it last wrote, and it is
|
|
144
|
+
* mounted with `patchConsole` off, so a line written to the terminal from elsewhere (an `Effect.log`, `CliLog`, a
|
|
145
|
+
* background fiber) lands inside the frame and tears it. Log before the screen mounts or after it resolves.
|
|
146
|
+
*
|
|
147
|
+
* With `clear` the last frame is erased as the screen unmounts, so a wizard of several screens leaves only what the
|
|
148
|
+
* program prints; without it the last frame stays, with the highlight where the answer was.
|
|
149
|
+
*
|
|
150
|
+
* @param screen - builds the element to mount from its {@link ScreenControl}
|
|
151
|
+
* @param options - whether to erase the last frame
|
|
152
|
+
*/
|
|
153
|
+
static run = (screen, options) => Effect.gen(function* () {
|
|
154
|
+
if (!(yield* CliInteractive)) return yield* Effect.fail(new NotInteractive());
|
|
155
|
+
const theme = yield* audienceTheme;
|
|
156
|
+
const neutralize = yield* underGithubActions;
|
|
157
|
+
const crash = { current: void 0 };
|
|
158
|
+
const exit = yield* Effect.exit(Semaphore.withPermit(mountPermit, Effect.scoped(mount(screen, theme, options?.clear === true, crash, neutralize))));
|
|
159
|
+
const crashed = crash.current;
|
|
160
|
+
const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
161
|
+
if (crashed === void 0 || interrupted) return yield* exit;
|
|
162
|
+
const dies = Exit.isFailure(exit) ? exit.cause.reasons.filter(Cause.isDieReason) : [];
|
|
163
|
+
if (dies.some((reason) => reason.defect === crashed.defect)) return yield* exit;
|
|
164
|
+
return yield* Effect.failCause(Cause.fromReasons([Cause.makeDieReason(crashed.defect), ...dies]));
|
|
165
|
+
});
|
|
166
|
+
/**
|
|
167
|
+
* The kit's context for an Ink tree the kit did not mount: stdout's theme and glyph set.
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* Hand it to {@link UiProvider}. It loads Ink and React, as a screen's mount does, so the provider and the kit's
|
|
171
|
+
* hooks can render; a missing peer is a defect naming both. It is the only way to get a `UiContextValue`.
|
|
172
|
+
*
|
|
173
|
+
* Ink loads asynchronously, so this is an `Effect` that must run before the first render. A renderer that is itself
|
|
174
|
+
* synchronous (a test helper, a report-time `renderToString`) runs it once, with a top-level `await` at module
|
|
175
|
+
* scope, and then renders synchronously from the value as often as it likes:
|
|
176
|
+
*
|
|
177
|
+
* ```ts
|
|
178
|
+
* const value = await Effect.runPromise(CliUi.context.pipe(Effect.provide(themeLayer)))
|
|
179
|
+
*
|
|
180
|
+
* // later, synchronously:
|
|
181
|
+
* const text = renderToString(createElement(UiProvider, { value: { ...value, size: { columns, rows } } }, tree), {
|
|
182
|
+
* columns,
|
|
183
|
+
* })
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
static context = Effect.gen(function* () {
|
|
187
|
+
const theme = yield* audienceTheme;
|
|
188
|
+
const neutralize = yield* underGithubActions;
|
|
189
|
+
yield* loadInk;
|
|
190
|
+
return {
|
|
191
|
+
"~@effected/cli/ui/UiContextValue": true,
|
|
192
|
+
theme,
|
|
193
|
+
glyphs: theme.glyphs,
|
|
194
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
/**
|
|
198
|
+
* A live view over a stream: fold `events` into state, and draw it with Ink while a run is going, for the caller's
|
|
199
|
+
* scope.
|
|
200
|
+
*
|
|
201
|
+
* @remarks
|
|
202
|
+
* `events` is a `PubSub` subscription or a stream, folded in a fiber of the caller's scope. A subscription, made
|
|
203
|
+
* before the first publish, is the surest: nothing published after it is missed. `live` makes a stream's first pull
|
|
204
|
+
* before it returns, so one that subscribes on its first pull without forking (`Stream.fromPubSub`) is subscribed by
|
|
205
|
+
* then; one that forks its upstream (`Stream.merge`, `buffer`, a concurrent `flatMap`) subscribes later, and loses
|
|
206
|
+
* what is published before.
|
|
207
|
+
*
|
|
208
|
+
* `live` returns its handle at once, before Ink has loaded: it loads Ink when a run first mounts (or, when not
|
|
209
|
+
* interactive, when an owned run without a `final` prints its final frame), and it waits on nothing asynchronous before returning, so
|
|
210
|
+
* a host outside Effect can take the handle with `Effect.runSync`. The handle works from the start: a `close`
|
|
211
|
+
* before any run has mounted folds what is queued and ends the view as the events ending would, waiting for a mount
|
|
212
|
+
* already under way, and one with no run to end loads nothing.
|
|
213
|
+
*
|
|
214
|
+
* End the view with `handle.close`: it stops taking events, folds what is still queued (a subscription's queued
|
|
215
|
+
* messages included), commits or prints the run as the events ending would, and waits for `done`. Then close the
|
|
216
|
+
* scope. A publisher may instead end a subscription with `PubSub.end(pubsub, last)`, which keeps everything: the view
|
|
217
|
+
* folds what is buffered and `last` once, then ends. A `PubSub.shutdown` drops what the view has not taken yet, and
|
|
218
|
+
* closing the scope by itself stops the fold at once: both lose a run's tail. Closing the scope unmounts whatever is drawn: the terminal is restored (the
|
|
219
|
+
* cursor shown, Ink's colour level put back) and nothing more is written. `done` completes when the events end.
|
|
220
|
+
*
|
|
221
|
+
* A run begins at an `isStart` event (or wherever `begins` says, given the state before and after the event) and ends
|
|
222
|
+
* at an `isTerminal` event; an event while no run is going that begins none is folded and not drawn, so what a program
|
|
223
|
+
* reports after a run ends never mounts a second copy of it. A run mounts the view; its end unmounts it, which leaves
|
|
224
|
+
* its last frame on the terminal, and the next run mounts afresh below it. A start while a run is drawn redraws in
|
|
225
|
+
* place: the frame is never cleared, so nothing above it is erased. The state is never reset by the kit: a reducer that wants a fresh run
|
|
226
|
+
* resets it on the start.
|
|
227
|
+
*
|
|
228
|
+
* The frame is at most the terminal's rows less one, re-read on every render and on a resize, so a tall frame never
|
|
229
|
+
* makes Ink wipe the scrollback; its width is Ink's own. The clamp
|
|
230
|
+
* lags one paint when the terminal gets shorter: Ink re-lays out and repaints the tree it already has on a resize,
|
|
231
|
+
* before React re-renders with the new row count, so a frame already at the old height can be drawn once taller
|
|
232
|
+
* than the terminal, which Ink answers by clearing the screen and its scrollback. Only a shrink in height while
|
|
233
|
+
* the frame is at its full height does it; a frame that keeps a few rows spare never meets it.
|
|
234
|
+
*
|
|
235
|
+
* While a run is drawn the view also redraws on a tick of `tickMillis` (80 by default), a schedule in the run's
|
|
236
|
+
* scope: interrupted with the run or the scope, it never outlives them. Its timer is not unref'd, so while a run is
|
|
237
|
+
* drawn it keeps the process alive: the run's terminal event, or the scope's close, is what lets the process exit.
|
|
238
|
+
* The frame index never steps back. Events that arrive at
|
|
239
|
+
* once, in one chunk or in several the view had not yet caught up with, are folded together and drawn once. The view
|
|
240
|
+
* takes events from `events` as fast as the stream yields them, so a stream that applies backpressure buffers in the
|
|
241
|
+
* view while it draws.
|
|
242
|
+
*
|
|
243
|
+
* A run whose drawing fails (a `render` that throws, or a mount that fails) degrades rather than ending the view:
|
|
244
|
+
* it is unmounted, leaving its last good frame on the terminal, then one warning is logged (`Effect.logWarning`),
|
|
245
|
+
* and the fold goes on. At its terminal event, a run with no frame left on the terminal (it never painted, or its
|
|
246
|
+
* last good frame threw too) writes its final frame once, as a string. The next run mounts afresh, and so does a
|
|
247
|
+
* start that comes while a degraded run is going: it ends that run as its terminal event would. A `reduce` that
|
|
248
|
+
* throws, or an `events` stream that dies, unmounts the run, then `done` dies with the error.
|
|
249
|
+
*
|
|
250
|
+
* When the run is not interactive, nothing is mounted and Ink is loaded only when a string is due. In the `owned`
|
|
251
|
+
* mode (the default) each run's final frame is written once to stdout, as a string laid out at stdout's width (80
|
|
252
|
+
* when it reports none) with no height to fit, at its terminal event or when the stream ends. It is escape-free at
|
|
253
|
+
* colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do. With a
|
|
254
|
+
* `final` document, that document is printed instead, once per run, rendered as `Doc.print` renders it, and Ink,
|
|
255
|
+
* React and a `CliUi.lazyView` module are never loaded: an agent, CI or piped run of a command with a live view pays
|
|
256
|
+
* for none of them. In the `hosted` mode nothing is written.
|
|
257
|
+
*
|
|
258
|
+
* Keep React off the runs that never draw (`--help`, a usage error) with `render: CliUi.lazyView(() => import(...))`.
|
|
259
|
+
*
|
|
260
|
+
* No input is mounted: the view reads no keys and never enters raw mode, so Ctrl-C stays the platform's SIGINT,
|
|
261
|
+
* which interrupts the program and so closes the scope. Each run holds the process-wide mount permit from its
|
|
262
|
+
* mount to its end, so a `CliUi.run` during a run waits for the run to end, and one between runs mounts at once.
|
|
263
|
+
*
|
|
264
|
+
* While a run is drawn, write logs through `logConsole`, provided around the work the view reports on: its lines
|
|
265
|
+
* land above the frame. A line written to the terminal any other way tears the frame.
|
|
266
|
+
*
|
|
267
|
+
* The view draws on stdout (`UiStreams`), at stdout's colour level and glyphs, and mounts only when the run is
|
|
268
|
+
* interactive (`CliInteractive`).
|
|
269
|
+
*
|
|
270
|
+
* @param options - the events, the fold, the drawing, and what starts and ends a run
|
|
271
|
+
*/
|
|
272
|
+
static live = live;
|
|
273
|
+
/**
|
|
274
|
+
* Run `screen` from a handler when the run is interactive; otherwise answer with `otherwise`, or fail with
|
|
275
|
+
* `NotInteractive` when there is none.
|
|
276
|
+
*
|
|
277
|
+
* @remarks
|
|
278
|
+
* `CliUi.run` with a default: not interactive, it returns `otherwise` and Ink and React are never loaded.
|
|
279
|
+
* Interactive, it mounts the screen, and a cancel is the typed `Cancelled` a handler can catch, which
|
|
280
|
+
* `CliRuntime.main` otherwise renders as one line with exit `130`. A missing Ink in an interactive run is a defect
|
|
281
|
+
* naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
|
|
282
|
+
* pass each as an `otherwise`, and a non-interactive run returns exactly them.
|
|
283
|
+
*
|
|
284
|
+
* Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
|
|
285
|
+
* the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
|
|
286
|
+
* `CliRuntime.main`).
|
|
287
|
+
*
|
|
288
|
+
* As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
|
|
289
|
+
* the frame.
|
|
290
|
+
*
|
|
291
|
+
* @param screen - the screen to show
|
|
292
|
+
* @param options - the non-interactive default, and whether to erase the last frame
|
|
293
|
+
*/
|
|
294
|
+
static prompt = (screen, options) => CliUi.run(screen, options?.clear === true ? { clear: true } : void 0).pipe(Effect.catchTag("NotInteractive", (error) => {
|
|
295
|
+
const otherwise = options?.otherwise;
|
|
296
|
+
return otherwise === void 0 ? Effect.fail(error) : Effect.succeed(otherwise);
|
|
297
|
+
}));
|
|
298
|
+
/**
|
|
299
|
+
* A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that shows a screen when the run is
|
|
300
|
+
* interactive: `CliPrompt.fallback` for screens.
|
|
301
|
+
*
|
|
302
|
+
* @remarks
|
|
303
|
+
* Interactive, the screen mounts and its answer is the parameter's value. Not interactive, `otherwise` is used when
|
|
304
|
+
* given and Ink and React are never loaded; without it the parameter fails as missing, exactly as with no fallback,
|
|
305
|
+
* so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with `flag` (the name
|
|
306
|
+
* without dashes) or `argument` so that error can be built.
|
|
307
|
+
*
|
|
308
|
+
* The options are {@link @effected/cli!CliPromptFallbackOptions}, the same as `CliPrompt.fallback`'s, and `clear`
|
|
309
|
+
* as for {@link CliUi.run}.
|
|
310
|
+
*
|
|
311
|
+
* It runs during parsing, whose environment is core's alone, so it reads `CliTheme` if one is there: with
|
|
312
|
+
* `CliRuntime.main`'s `env` (`CliEnv.layer`), or provided around the program. With no theme it treats the run as
|
|
313
|
+
* not interactive, and when `CliInteractive` is on it says so once, at debug level. Interactivity is
|
|
314
|
+
* `CliInteractive`, which an audience flag can set before parsing under `CliAudience`.
|
|
315
|
+
*
|
|
316
|
+
* As with `CliPrompt.fallback`, the screen runs here rather than being handed to core, whose fallback runner
|
|
317
|
+
* turns a quit into the missing-parameter error, which would exit `64`. A cancel (Esc, Ctrl-C) is `Cancelled`,
|
|
318
|
+
* raised as a defect because core's parse step turns every typed failure into a usage error, so only
|
|
319
|
+
* `CliRuntime.main` (or `CliRuntime.reportFailures`) renders it, as one line with exit `130`. A missing Ink in an
|
|
320
|
+
* interactive run is a defect naming the peers, never a silent `otherwise`.
|
|
321
|
+
*
|
|
322
|
+
* After the screen has unmounted, core still runs the answered `Prompt.succeed` it is handed against the
|
|
323
|
+
* terminal, exactly as it does for `CliPrompt.fallback`: `Prompt.run` opens the terminal's input in a scope (on
|
|
324
|
+
* Node a readline over stdin, in raw mode) before looking at the prompt. That is harmless. The prompt is already
|
|
325
|
+
* answered, so nothing is read and no key is waited for; the scope closes at once, restoring the mode and closing
|
|
326
|
+
* the reader; and Ink has already let go of stdin, so the two never hold it together. Not interactive, the screen
|
|
327
|
+
* never mounts, and `CliPrompt.gateTerminal`, which `CliEnv.layer` installs, keeps that subscription off the real
|
|
328
|
+
* terminal altogether.
|
|
329
|
+
*
|
|
330
|
+
* @param screen - the screen to show
|
|
331
|
+
* @param options - the parameter it stands in for, the non-interactive default, and whether to erase the last frame
|
|
332
|
+
*/
|
|
333
|
+
static fallback = (screen, options) => {
|
|
334
|
+
let explained = false;
|
|
335
|
+
return Effect.gen(function* () {
|
|
336
|
+
const theme = yield* Effect.serviceOption(CliTheme);
|
|
337
|
+
if (Option.isNone(theme)) {
|
|
338
|
+
if (!explained && (yield* CliInteractive)) {
|
|
339
|
+
explained = true;
|
|
340
|
+
const name = "flag" in options ? `--${options.flag}` : `<${options.argument}>`;
|
|
341
|
+
yield* Effect.logDebug(`@effected/cli/ui: CliUi.fallback for ${name} answered without its screen: CliInteractive is on, but no CliTheme is provided around parsing (CliRuntime.main's env provides one)`);
|
|
342
|
+
}
|
|
343
|
+
return yield* answerWithoutPerson(options);
|
|
344
|
+
}
|
|
345
|
+
return yield* CliUi.run(screen, options.clear === true ? { clear: true } : void 0).pipe(Effect.provideService(CliTheme, theme.value), Effect.map((answer) => Prompt.succeed(answer)), Effect.catchTag("Cancelled", (cancelled) => Effect.die(cancelled)), Effect.catchTag("NotInteractive", () => answerWithoutPerson(options)));
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
/**
|
|
349
|
+
* A screen whose module is loaded only when it mounts, so importing the command that uses it loads neither the
|
|
350
|
+
* screen's own code nor React.
|
|
351
|
+
*
|
|
352
|
+
* @param load - imports the module whose default export is the screen
|
|
353
|
+
*/
|
|
354
|
+
static lazy = (load) => async (control) => (await load()).default(control);
|
|
355
|
+
/**
|
|
356
|
+
* A live view's `render` whose module is loaded only when a run first draws it, so importing the command that uses
|
|
357
|
+
* the view loads neither the view's own code nor React: `CliUi.lazy` for `CliUi.live`.
|
|
358
|
+
*
|
|
359
|
+
* @remarks
|
|
360
|
+
* `load` resolves to the view, `(state, frame) => ReactElement`, exactly what `render` takes, so the frame index a
|
|
361
|
+
* spinner needs reaches it: either a module whose default export is the view (`() => import("./view.js")`), or the
|
|
362
|
+
* view itself (`() => import("./views.js").then((module) => module.syncView)`, for a named export).
|
|
363
|
+
*
|
|
364
|
+
* It is optional: `render` still takes the view directly, and needs no dynamic import. A view passed directly
|
|
365
|
+
* loads with the module that imports it, so it costs React on every run that loads that module; `lazyView` is
|
|
366
|
+
* how a command keeps React off the runs that never draw. `CliUi.live` loads the module before a run mounts with Ink, or before it
|
|
367
|
+
* prints a run's final frame as a string; a run that is not interactive and has a `final` document never loads it,
|
|
368
|
+
* nor Ink, nor React. An import that fails degrades the run, as a render that throws does: one warning, and the next
|
|
369
|
+
* run tries the import again. A load that resolves to no view (neither a function nor a module whose `default` is
|
|
370
|
+
* one) is a programming error, and deterministic, so it is kept for the view's life: every run degrades without
|
|
371
|
+
* loading again, and the view warns once, saying what it received and what is expected, rather than once per run. A
|
|
372
|
+
* view function that happens to carry a `default` property is the view.
|
|
373
|
+
*
|
|
374
|
+
* The returned function is for `CliUi.live`'s `render` alone: called before its module has loaded, it throws.
|
|
375
|
+
*
|
|
376
|
+
* Keep the `LiveOptions` (the state, the events and the `lazyView` call) in a module the view does not import. The
|
|
377
|
+
* view module usually imports the state's types or its fold from somewhere; if that somewhere is the module that
|
|
378
|
+
* holds the `import("./view.js")`, the dynamic import closes a cycle, which Biome's `noImportCycles` reports even
|
|
379
|
+
* though it is lazy. A layout that stays acyclic: the model (state, events, fold) in one module, the view importing
|
|
380
|
+
* the model, and the options (with `lazyView`) in a third that imports the model and loads the view.
|
|
381
|
+
*
|
|
382
|
+
* ```ts
|
|
383
|
+
* // commands/sync.ts: no JSX, no React
|
|
384
|
+
* const view = yield* CliUi.live({
|
|
385
|
+
* events,
|
|
386
|
+
* initial,
|
|
387
|
+
* reduce,
|
|
388
|
+
* render: CliUi.lazyView(() => import("./sync-view.js")),
|
|
389
|
+
* final: (state) => [Doc.paragraph(`${state.done} synced`)],
|
|
390
|
+
* isStart,
|
|
391
|
+
* isTerminal,
|
|
392
|
+
* })
|
|
393
|
+
* ```
|
|
394
|
+
*
|
|
395
|
+
* @param load - resolves to the view, or to a module whose default export is the view
|
|
396
|
+
*/
|
|
397
|
+
static lazyView = lazyView;
|
|
398
|
+
/**
|
|
399
|
+
* A screen whose answer is `f` of `screen`'s: it mounts `screen` and resolves with `f(value)` when `screen` resolves
|
|
400
|
+
* with `value`.
|
|
401
|
+
*
|
|
402
|
+
* @remarks
|
|
403
|
+
* Only the resolve is mapped. A cancel passes through unchanged, as the same `Cancelled`, and so does everything
|
|
404
|
+
* else about the screen: what it draws, its keys, a lazy load. `f` runs when the screen resolves; what it throws is
|
|
405
|
+
* thrown from the screen's resolve, so it is a defect of the run, as any other throw in a key handler is.
|
|
406
|
+
*
|
|
407
|
+
* The mapped screen is a `Screen` like any other, so it goes wherever a screen goes: `CliUi.run`, `CliUi.prompt`,
|
|
408
|
+
* `CliUi.fallback`, or around a `CliUi.lazy` one. The commonest use is a `Confirm` behind a boolean flag, where the
|
|
409
|
+
* fallback needs a `Screen<boolean>` and `Confirm` answers a whole `ConfirmResult`:
|
|
410
|
+
*
|
|
411
|
+
* ```ts
|
|
412
|
+
* const yes = Flag.Boolean("yes").pipe(
|
|
413
|
+
* Flag.withFallbackPrompt(
|
|
414
|
+
* CliUi.fallback(
|
|
415
|
+
* CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
|
|
416
|
+
* { flag: "yes", otherwise: false },
|
|
417
|
+
* ),
|
|
418
|
+
* ),
|
|
419
|
+
* )
|
|
420
|
+
* ```
|
|
421
|
+
*
|
|
422
|
+
* @param screen - the screen to show
|
|
423
|
+
* @param f - turns its answer into the mapped screen's
|
|
424
|
+
*/
|
|
425
|
+
static map = (screen, f) => (control) => screen({
|
|
426
|
+
resolve: (value) => control.resolve(f(value)),
|
|
427
|
+
cancel: control.cancel
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
//#endregion
|
|
432
|
+
export { CliUi };
|