@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/CliFailure.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Cancelled } from "./Cancelled.js";
|
|
2
|
+
import { Fmt } from "./Fmt.js";
|
|
3
|
+
import { Doc } from "./Doc.js";
|
|
4
|
+
import { issueEntries, issueTreeChildren } from "./internal/format.js";
|
|
5
|
+
import { splitFrame } from "./internal/splitFrame.js";
|
|
6
|
+
import { NotInteractive } from "./NotInteractive.js";
|
|
7
|
+
import { Status } from "./Status.js";
|
|
8
|
+
import { Cause, Context, SchemaIssue } from "effect";
|
|
9
|
+
|
|
10
|
+
//#region src/CliFailure.ts
|
|
11
|
+
/**
|
|
12
|
+
* The protocol an error class implements to say how a failure is shown: a method under this key that returns the
|
|
13
|
+
* document.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* `CliFailure.toDoc` calls it for a typed failure that has one, so an application error draws itself (a heading, a
|
|
17
|
+
* table of what went wrong) and the default report uses it with no registration. A `Symbol.for` key, so two copies
|
|
18
|
+
* of this package agree on it.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const CliDoc = Symbol.for("@effected/cli/CliDoc");
|
|
23
|
+
/** The deepest an `Error.cause` chain, or a stack, is followed. */
|
|
24
|
+
const MAX_DEPTH = 8;
|
|
25
|
+
const MAX_SPANS = 32;
|
|
26
|
+
const hasCliDoc = (value) => typeof value === "object" && value !== null && typeof value[CliDoc] === "function";
|
|
27
|
+
const describe = (value) => {
|
|
28
|
+
try {
|
|
29
|
+
const text = String(value);
|
|
30
|
+
if (text !== "[object Object]") return text;
|
|
31
|
+
const message = value?.message;
|
|
32
|
+
return typeof message === "string" ? message : text;
|
|
33
|
+
} catch {
|
|
34
|
+
return "[unprintable value]";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const firstLine = (text) => text.split(/\r\n|\r|\n/, 1)[0] ?? "";
|
|
38
|
+
const tagOf = (value) => {
|
|
39
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
40
|
+
const tag = value._tag;
|
|
41
|
+
return typeof tag === "string" ? tag : void 0;
|
|
42
|
+
};
|
|
43
|
+
/** The failure status and the text of a message: one block per line, the status on the first. */
|
|
44
|
+
const failureBlocks = (message) => {
|
|
45
|
+
const [first = "", ...rest] = message.split(/\r\n|\r|\n/);
|
|
46
|
+
return [Doc.paragraph(Doc.status(Status.core, "failure"), " ", first), ...rest.map((line) => Doc.paragraph(line))];
|
|
47
|
+
};
|
|
48
|
+
/** The issue of a schema failure: the value itself, or the `issue` an error carries. */
|
|
49
|
+
const issueOf = (error) => {
|
|
50
|
+
if (SchemaIssue.isIssue(error)) return error;
|
|
51
|
+
if (typeof error === "object" && error !== null) {
|
|
52
|
+
const issue = error.issue;
|
|
53
|
+
if (SchemaIssue.isIssue(issue)) return issue;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const schemaBlocks = (error) => {
|
|
57
|
+
const entries = issueEntries(issueOf(error));
|
|
58
|
+
if (entries.length === 0) return void 0;
|
|
59
|
+
const header = typeof error === "object" && error !== null && !SchemaIssue.isIssue(error) ? firstLine(describe(error.message ?? "")) : "";
|
|
60
|
+
const root = {
|
|
61
|
+
label: [
|
|
62
|
+
Doc.status(Status.core, "failure"),
|
|
63
|
+
" ",
|
|
64
|
+
header === "" ? `invalid value (${Fmt.plural(entries.length, "problem")})` : header
|
|
65
|
+
],
|
|
66
|
+
children: issueTreeChildren(entries)
|
|
67
|
+
};
|
|
68
|
+
return [Doc.tree(root)];
|
|
69
|
+
};
|
|
70
|
+
/** The location of a frame as a path: a `file:` URL decoded, or an absolute POSIX or drive path; else `undefined`. */
|
|
71
|
+
const asPath = (location) => {
|
|
72
|
+
if (location.startsWith("file:")) try {
|
|
73
|
+
const path = decodeURIComponent(new URL(location).pathname);
|
|
74
|
+
return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
return location.startsWith("/") || /^[A-Za-z]:[\\/]/.test(location) ? location : void 0;
|
|
79
|
+
};
|
|
80
|
+
/** A location without its `:line:col`, and the position when there is one. */
|
|
81
|
+
const splitPosition = (location) => {
|
|
82
|
+
const position = /^(.*):(\d+):(\d+)$/.exec(location);
|
|
83
|
+
return position === null ? { where: location } : {
|
|
84
|
+
where: position[1] ?? "",
|
|
85
|
+
line: Number(position[2]),
|
|
86
|
+
col: Number(position[3])
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const parseFrame = (raw) => {
|
|
90
|
+
const { text, fn, location } = splitFrame(raw);
|
|
91
|
+
const { where, line, col } = splitPosition(location);
|
|
92
|
+
const file = asPath(where);
|
|
93
|
+
return {
|
|
94
|
+
raw: text,
|
|
95
|
+
...fn === void 0 ? {} : { fn },
|
|
96
|
+
...file === void 0 ? {} : { file },
|
|
97
|
+
...file === void 0 || line === void 0 || col === void 0 ? {} : {
|
|
98
|
+
line,
|
|
99
|
+
col
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/** The file a frame ran in, as a path, or `undefined` for a frame with none: `node:`, `<anonymous>`, `native`, `eval`. */
|
|
104
|
+
const frameFile = (raw) => asPath(splitPosition(splitFrame(raw).location).where);
|
|
105
|
+
/** A frame under `node_modules`: a dependency's, or an installed program's own. Decided by its file alone. */
|
|
106
|
+
const isDependency = (raw) => /[\\/]node_modules[\\/]/.test(frameFile(raw) ?? "");
|
|
107
|
+
/**
|
|
108
|
+
* A frame that is the runtime's or Effect's, never the program's, wherever the program is installed: one with no file
|
|
109
|
+
* (every `node:` frame, `<anonymous>`, native), or one in Effect's own files. Decided by the file alone, never by the
|
|
110
|
+
* function name: V8 names a program's own thunk by Effect's method alias (`boom [as ~effect/Effect/args]`).
|
|
111
|
+
*/
|
|
112
|
+
const isRuntime = (raw) => {
|
|
113
|
+
const file = frameFile(raw);
|
|
114
|
+
return file === void 0 || /[\\/]node_modules[\\/]effect[\\/]/.test(file) || /[\\/]packages[\\/]effect[\\/]src[\\/]/.test(file);
|
|
115
|
+
};
|
|
116
|
+
/** The frames of a stack that belong to the program, and how many were left out. */
|
|
117
|
+
const cleanStack = (stack, mode) => {
|
|
118
|
+
if (typeof stack !== "string") return {
|
|
119
|
+
frames: [],
|
|
120
|
+
hidden: 0
|
|
121
|
+
};
|
|
122
|
+
const lines = stack.split(/\r\n|\r|\n/).filter((line) => /^\s*at\s/.test(line));
|
|
123
|
+
const app = lines.filter((line) => !isRuntime(line) && !isDependency(line));
|
|
124
|
+
const kept = mode === "all" ? lines : app.length > 0 ? app : lines.filter((line) => !isRuntime(line));
|
|
125
|
+
return {
|
|
126
|
+
frames: kept.map(parseFrame),
|
|
127
|
+
hidden: lines.length - kept.length
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
const frameBlock = (frame, displayPath) => {
|
|
131
|
+
if (frame.file === void 0) return Doc.paragraph(Doc.text("at ", "muted"), frame.raw);
|
|
132
|
+
const where = `${displayPath(frame.file)}${frame.line === void 0 ? "" : `:${frame.line}:${frame.col}`}`;
|
|
133
|
+
const target = {
|
|
134
|
+
file: frame.file,
|
|
135
|
+
...frame.line === void 0 ? {} : { line: frame.line },
|
|
136
|
+
...frame.col === void 0 ? {} : { col: frame.col }
|
|
137
|
+
};
|
|
138
|
+
return Doc.paragraph(Doc.text("at ", "muted"), ...frame.fn === void 0 ? [] : [`${frame.fn} `], Doc.link(target, where));
|
|
139
|
+
};
|
|
140
|
+
const stackBlock = (defect, displayPath, mode) => {
|
|
141
|
+
const { frames, hidden } = cleanStack(defect.stack, mode);
|
|
142
|
+
if (frames.length > 0) {
|
|
143
|
+
const shown = frames.map((frame) => frameBlock(frame, displayPath));
|
|
144
|
+
const note = hidden === 0 ? [] : [Doc.paragraph(Doc.text(`(+${hidden} internal frames hidden)`, "muted"))];
|
|
145
|
+
return Doc.collapsible("stack", [...shown, ...note], { open: true });
|
|
146
|
+
}
|
|
147
|
+
const note = hidden === 0 ? "no stack" : `no user frames (${hidden} internal frames hidden)`;
|
|
148
|
+
return Doc.collapsible("stack", [Doc.paragraph(Doc.text(note, "muted"))], { open: true });
|
|
149
|
+
};
|
|
150
|
+
/** The `Error.cause` chain below a defect as a tree of one-line messages, or none. */
|
|
151
|
+
const causeTree = (defect) => {
|
|
152
|
+
const chain = [];
|
|
153
|
+
const seen = /* @__PURE__ */ new Set([defect]);
|
|
154
|
+
for (let current = defect.cause; current !== void 0 && chain.length < MAX_DEPTH;) {
|
|
155
|
+
if (seen.has(current)) break;
|
|
156
|
+
seen.add(current);
|
|
157
|
+
chain.push(firstLine(describe(current)));
|
|
158
|
+
current = current instanceof Error ? current.cause : void 0;
|
|
159
|
+
}
|
|
160
|
+
if (chain.length === 0) return void 0;
|
|
161
|
+
const nest = (index) => index === chain.length - 1 ? { label: chain[index] } : {
|
|
162
|
+
label: chain[index],
|
|
163
|
+
children: [nest(index + 1)]
|
|
164
|
+
};
|
|
165
|
+
return Doc.tree({
|
|
166
|
+
label: firstLine(describe(defect)),
|
|
167
|
+
children: [nest(0)]
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const dieBlocks = (defect, spans, displayPath, mode) => {
|
|
171
|
+
if (defect instanceof Cancelled || defect instanceof NotInteractive) return [Doc.paragraph(defect.message)];
|
|
172
|
+
const header = failureBlocks(describe(defect));
|
|
173
|
+
if (!(defect instanceof Error)) return [...header, ...spans];
|
|
174
|
+
const chain = causeTree(defect);
|
|
175
|
+
return [
|
|
176
|
+
...header,
|
|
177
|
+
...spans,
|
|
178
|
+
stackBlock(defect, displayPath, mode),
|
|
179
|
+
...chain === void 0 ? [] : [chain]
|
|
180
|
+
];
|
|
181
|
+
};
|
|
182
|
+
const failBlocks = (error, spans, options) => {
|
|
183
|
+
if (hasCliDoc(error)) try {
|
|
184
|
+
return [...error[CliDoc](), ...spans];
|
|
185
|
+
} catch {}
|
|
186
|
+
const tag = tagOf(error);
|
|
187
|
+
const custom = tag === void 0 || options?.render === void 0 || !Object.hasOwn(options.render, tag) ? void 0 : options.render[tag];
|
|
188
|
+
if (custom !== void 0) try {
|
|
189
|
+
return [...custom(error), ...spans];
|
|
190
|
+
} catch {}
|
|
191
|
+
if (error instanceof Cancelled || error instanceof NotInteractive) return [Doc.paragraph(error.message)];
|
|
192
|
+
const schema = schemaBlocks(error);
|
|
193
|
+
if (schema !== void 0) return [...schema, ...spans];
|
|
194
|
+
return [...failureBlocks(describe(error)), ...spans];
|
|
195
|
+
};
|
|
196
|
+
/** A file of the kit's own packages or of Effect, as installed: a span defined there is not the program's. */
|
|
197
|
+
const isKitFile = (file) => /[\\/]node_modules[\\/](?:@effected[\\/]|effect[\\/])/.test(file);
|
|
198
|
+
/**
|
|
199
|
+
* The installed package directory that holds `module`: the path through the last `node_modules/<name>` or
|
|
200
|
+
* `node_modules/@scope/name` in it, or `undefined` when the module is not under `node_modules` (or not a path at all).
|
|
201
|
+
* Read from the path alone, with no file system.
|
|
202
|
+
*/
|
|
203
|
+
const packageDirOf = (module) => {
|
|
204
|
+
if (module === void 0) return void 0;
|
|
205
|
+
const path = asPath(module);
|
|
206
|
+
if (path === void 0) return void 0;
|
|
207
|
+
return /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(comparable(path))?.[1];
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* A path in the one spelling both sides of a comparison use: `/` separators, and a Windows drive letter lower-cased. A
|
|
211
|
+
* CommonJS frame on Windows reads `C:\…` where an `import.meta.url` reads `file:///C:/…`, and the drive's case is not
|
|
212
|
+
* fixed (`c:` and `C:` are one drive). Lexical only: no file system, no realpath.
|
|
213
|
+
*/
|
|
214
|
+
const comparable = (path) => path.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `${drive.toLowerCase()}:`);
|
|
215
|
+
/** Whether `file` is in the program's own package `appDir`, and not in a dependency nested in its `node_modules`. */
|
|
216
|
+
const isAppFile = (file, appDir) => {
|
|
217
|
+
if (appDir === void 0) return false;
|
|
218
|
+
const path = comparable(file);
|
|
219
|
+
if (!path.startsWith(appDir)) return false;
|
|
220
|
+
const rest = path.slice(appDir.length);
|
|
221
|
+
return rest.startsWith("/") && !rest.includes("/node_modules/");
|
|
222
|
+
};
|
|
223
|
+
/** The file a span frame's captured stack points at, or `undefined` when it captured none. */
|
|
224
|
+
const spanFile = (frame) => {
|
|
225
|
+
try {
|
|
226
|
+
const stack = frame.stack();
|
|
227
|
+
if (stack === void 0) return void 0;
|
|
228
|
+
const line = stack.split(/\r\n|\r|\n/).find((candidate) => /^\s*at\s/.test(candidate)) ?? stack;
|
|
229
|
+
return frameFile(line.trim().startsWith("at ") ? line : `at ${line.trim()}`);
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* `in: outer › inner`, from the span stack the runtime annotates a reason with, or none. With `app`, a span the kit or
|
|
236
|
+
* Effect defined is left out. An `Effect.fn` call carries its definition as its parent (`name (definition)`): the two
|
|
237
|
+
* are one entry, `name`, judged by the definition's site, so a kit function the program calls goes with its
|
|
238
|
+
* definition.
|
|
239
|
+
*/
|
|
240
|
+
const spanBlocks = (reason, mode, appDir) => {
|
|
241
|
+
if (mode === "off") return [];
|
|
242
|
+
const names = [];
|
|
243
|
+
let frame = Context.getOrUndefined(Cause.reasonAnnotations(reason), Cause.StackTrace);
|
|
244
|
+
for (let seen = 0; frame !== void 0 && seen < MAX_SPANS; seen++) {
|
|
245
|
+
const parent = frame.parent;
|
|
246
|
+
const definition = parent !== void 0 && parent.name === `${frame.name} (definition)` ? parent : void 0;
|
|
247
|
+
const file = (definition === void 0 ? void 0 : spanFile(definition)) ?? spanFile(frame);
|
|
248
|
+
if (mode === "all" || file === void 0 || isAppFile(file, appDir) || !isKitFile(file)) names.push(frame.name);
|
|
249
|
+
frame = definition === void 0 ? parent : definition.parent;
|
|
250
|
+
}
|
|
251
|
+
if (names.length === 0) return [];
|
|
252
|
+
return [Doc.paragraph(Doc.text("in: ", "muted"), Doc.path(...names.reverse()))];
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* A failure as a document: what the default report prints, and a building block for a custom one.
|
|
256
|
+
*
|
|
257
|
+
* @remarks
|
|
258
|
+
* One run of blocks per `Cause` reason. A typed failure is, in order of preference: the document of an error that
|
|
259
|
+
* implements {@link CliDoc}; the document `options.render` holds for its `_tag`; for `Cancelled` and
|
|
260
|
+
* `NotInteractive`, their one fixed line; a `Tree` of the rejected values, for a schema error or issue; else a failure
|
|
261
|
+
* status line with its message. A defect is its message followed by a collapsible `stack` of the program's own frames,
|
|
262
|
+
* each a file link (so a terminal can open it in an editor) shown through `displayPath`, with the runtime's frames (every
|
|
263
|
+
* `node:` frame and every frame with no file) and every `node_modules` frame (Effect's and any other dependency's)
|
|
264
|
+
* left out unless `stackFrames` is `all`. A frame is classified by its file alone, never by its function name, so a
|
|
265
|
+
* program's own thunk that V8 names by Effect's method alias is still shown. When that would
|
|
266
|
+
* leave no frame at all, as for a program run from its own install under `node_modules`, only the runtime's and
|
|
267
|
+
* Effect's are left out. Then an
|
|
268
|
+
* `Error.cause` chain as a tree. When cleaning leaves no frame the stack says
|
|
269
|
+
* `no user frames (N internal frames hidden)`, never an empty block; when frames survive and some were left out, the
|
|
270
|
+
* count follows them as `(+N internal frames hidden)`. A reason that ran under spans is followed by
|
|
271
|
+
* `in: outer › inner`: by default only the program's own spans, the kit's and Effect's left out (`spans`). Interrupts are not rendered beside a real failure, and a cause with only interrupts is the
|
|
272
|
+
* one line `interrupted`.
|
|
273
|
+
*
|
|
274
|
+
* All text goes through the document, so a control character in a message or a stack frame never reaches the terminal.
|
|
275
|
+
* Render it with `Render.context` and `Render.plain`, `ansi`, `markdown` or `githubLog`, or `Doc.print` it.
|
|
276
|
+
*
|
|
277
|
+
* @public
|
|
278
|
+
*/
|
|
279
|
+
var CliFailure = class {
|
|
280
|
+
constructor() {}
|
|
281
|
+
/**
|
|
282
|
+
* Build the document of a cause.
|
|
283
|
+
*
|
|
284
|
+
* @param cause - the failure
|
|
285
|
+
* @param options - per-tag documents and a path display function
|
|
286
|
+
*/
|
|
287
|
+
static toDoc = (cause, options) => {
|
|
288
|
+
const reasons = cause.reasons;
|
|
289
|
+
if (reasons.length > 0 && reasons.every(Cause.isInterruptReason)) return [Doc.paragraph("interrupted")];
|
|
290
|
+
const displayPath = options?.displayPath ?? ((absolute) => absolute);
|
|
291
|
+
return reasons.flatMap((reason) => {
|
|
292
|
+
const spans = options?.spans ?? "app";
|
|
293
|
+
const appDir = packageDirOf(options?.appModule);
|
|
294
|
+
if (Cause.isFailReason(reason)) return failBlocks(reason.error, spanBlocks(reason, spans, appDir), options);
|
|
295
|
+
if (Cause.isDieReason(reason)) return dieBlocks(reason.defect, spanBlocks(reason, spans, appDir), displayPath, options?.stackFrames ?? "app");
|
|
296
|
+
return [];
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
//#endregion
|
|
302
|
+
export { CliDoc, CliFailure };
|
|
@@ -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 };
|