@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.
Files changed (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -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 +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -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 +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. 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 };