@effected/cli-kit 0.0.0-stage → 0.26.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 (103) hide show
  1. package/Cancelled.js +43 -0
  2. package/CliAudience.js +177 -0
  3. package/CliColor.js +59 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +64 -0
  6. package/CliFailure.js +287 -0
  7. package/CliFormat.js +124 -0
  8. package/CliInteractive.js +94 -0
  9. package/CliLinks.js +153 -0
  10. package/CliLog.js +367 -0
  11. package/CliLogger.js +91 -0
  12. package/CliMessage.js +79 -0
  13. package/CliPrompt.js +110 -0
  14. package/CliRuntime.js +254 -0
  15. package/CliTest.js +206 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +63 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +335 -0
  20. package/GithubAnnotation.js +39 -0
  21. package/Glyphs.js +82 -0
  22. package/LICENSE +21 -0
  23. package/NotInteractive.js +41 -0
  24. package/README.md +215 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +101 -0
  29. package/Token.js +68 -0
  30. package/index.d.ts +3951 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +35 -0
  34. package/internal/ansi.js +229 -0
  35. package/internal/autoFormat.js +33 -0
  36. package/internal/canPrompt.js +14 -0
  37. package/internal/counts.js +83 -0
  38. package/internal/diagnostics.js +31 -0
  39. package/internal/displayPath.js +46 -0
  40. package/internal/displayWidth.js +42 -0
  41. package/internal/failureTarget.js +195 -0
  42. package/internal/fallbackAnswer.js +17 -0
  43. package/internal/fileSink.js +61 -0
  44. package/internal/format.js +175 -0
  45. package/internal/inspect.js +40 -0
  46. package/internal/isExitCode.js +11 -0
  47. package/internal/layout.js +249 -0
  48. package/internal/linkScheme.js +29 -0
  49. package/internal/linkTarget.js +49 -0
  50. package/internal/logSafety.js +45 -0
  51. package/internal/renderAnsi.js +51 -0
  52. package/internal/renderDoc.js +322 -0
  53. package/internal/renderGithubLog.js +59 -0
  54. package/internal/renderMarkdown.js +367 -0
  55. package/internal/renderPlain.js +49 -0
  56. package/internal/scanAudience.js +105 -0
  57. package/internal/splitFrame.js +55 -0
  58. package/internal/terminalCapabilities.js +23 -0
  59. package/internal/wizardGate.js +17 -0
  60. package/package.json +90 -5
  61. package/testing.d.ts +246 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +507 -0
  65. package/ui/CliUiDetached.js +36 -0
  66. package/ui/CliUiLive.js +436 -0
  67. package/ui/CliUiRouteStream.js +72 -0
  68. package/ui/Confirm.js +244 -0
  69. package/ui/DocView.js +76 -0
  70. package/ui/KeyHelp.js +61 -0
  71. package/ui/KeyTable.js +198 -0
  72. package/ui/MultiSelect.js +259 -0
  73. package/ui/Select.js +229 -0
  74. package/ui/Tabs.js +201 -0
  75. package/ui/TextInput.js +289 -0
  76. package/ui/Toggle.js +31 -0
  77. package/ui/UiKey.js +48 -0
  78. package/ui/UiProvider.js +62 -0
  79. package/ui/UiStreams.js +17 -0
  80. package/ui/UiTheme.js +147 -0
  81. package/ui/Viewport.js +203 -0
  82. package/ui/internal/ErrorBoundary.js +29 -0
  83. package/ui/internal/Holder.js +73 -0
  84. package/ui/internal/ScreenContext.js +51 -0
  85. package/ui/internal/UiProviders.js +38 -0
  86. package/ui/internal/ink.js +121 -0
  87. package/ui/internal/inkChalk.js +47 -0
  88. package/ui/internal/inkConsole.js +150 -0
  89. package/ui/internal/lazyView.js +73 -0
  90. package/ui/internal/lineRouter.js +70 -0
  91. package/ui/internal/lineText.js +18 -0
  92. package/ui/internal/mountPermit.js +15 -0
  93. package/ui/internal/processStreams.js +18 -0
  94. package/ui/internal/renderOptions.js +12 -0
  95. package/ui/testing/CliUiTest.js +792 -0
  96. package/ui/testing/fakeStreams.js +104 -0
  97. package/ui/testing/terminalModel.js +67 -0
  98. package/ui-testing-serializer.d.ts +12 -0
  99. package/ui-testing-serializer.js +32 -0
  100. package/ui-testing.d.ts +558 -0
  101. package/ui-testing.js +3 -0
  102. package/ui.d.ts +1990 -0
  103. package/ui.js +17 -0
package/CliFailure.js ADDED
@@ -0,0 +1,287 @@
1
+ import { Cancelled } from "./Cancelled.js";
2
+ import { Doc } from "./Doc.js";
3
+ import { Fmt } from "./Fmt.js";
4
+ import { identity } from "./internal/displayPath.js";
5
+ import { issueEntries, issueTreeChildren } from "./internal/format.js";
6
+ import { failureText, readProperty, tagOf } from "./internal/inspect.js";
7
+ import { splitFrame } from "./internal/splitFrame.js";
8
+ import { NotInteractive } from "./NotInteractive.js";
9
+ import { Status } from "./Status.js";
10
+ import { Cause, Context, SchemaIssue } from "effect";
11
+
12
+ // src/CliFailure.ts
13
+ /**
14
+ * The protocol an error class implements to say how a failure is shown: a method under this key that returns the
15
+ * document.
16
+ *
17
+ * @remarks
18
+ * `CliFailure.toDoc` calls it for a typed failure that has one, so an application error draws itself (a heading, a
19
+ * table of what went wrong) and the default report uses it with no registration. A `Symbol.for` key, so two copies
20
+ * of this package agree on it.
21
+ *
22
+ * @public
23
+ */
24
+ const CliDoc = Symbol.for("@effected/cli-kit/CliDoc");
25
+ /** The deepest an `Error.cause` chain, or a stack, is followed. */
26
+ const MAX_DEPTH = 8;
27
+ const MAX_SPANS = 32;
28
+ const hasCliDoc = (value) => typeof value === "object" && value !== null && typeof value[CliDoc] === "function";
29
+ const firstLine = (text) => text.split(/\r\n|\r|\n/, 1)[0] ?? "";
30
+ /** The failure status and the text of a message: one block per line, the status on the first. */
31
+ const failureBlocks = (message) => {
32
+ const [first = "", ...rest] = message.split(/\r\n|\r|\n/);
33
+ return [Doc.paragraph(Doc.status(Status.core, "failure"), " ", first), ...rest.map((line) => Doc.paragraph(line))];
34
+ };
35
+ /** The issue of a schema failure: the value itself, or the `issue` an error carries. */
36
+ const issueOf = (error) => {
37
+ if (SchemaIssue.isIssue(error)) return error;
38
+ const issue = readProperty(error, "issue");
39
+ return SchemaIssue.isIssue(issue) ? issue : void 0;
40
+ };
41
+ const schemaBlocks = (error) => {
42
+ const entries = issueEntries(issueOf(error));
43
+ if (entries.length === 0) return void 0;
44
+ const header = typeof error === "object" && error !== null && !SchemaIssue.isIssue(error) ? firstLine(failureText(readProperty(error, "message") ?? "")) : "";
45
+ const root = {
46
+ label: [
47
+ Doc.status(Status.core, "failure"),
48
+ " ",
49
+ header === "" ? `invalid value (${Fmt.plural(entries.length, "problem")})` : header
50
+ ],
51
+ children: issueTreeChildren(entries)
52
+ };
53
+ return [Doc.tree(root)];
54
+ };
55
+ /** The location of a frame as a path: a `file:` URL decoded, or an absolute POSIX or drive path; else `undefined`. */
56
+ const asPath = (location) => {
57
+ if (location.startsWith("file:")) try {
58
+ const path = decodeURIComponent(new URL(location).pathname);
59
+ return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
60
+ } catch {
61
+ return;
62
+ }
63
+ return location.startsWith("/") || /^[A-Za-z]:[\\/]/.test(location) ? location : void 0;
64
+ };
65
+ /** A location without its `:line:col`, and the position when there is one. */
66
+ const splitPosition = (location) => {
67
+ const position = /^(.*):(\d+):(\d+)$/.exec(location);
68
+ return position === null ? { where: location } : {
69
+ where: position[1] ?? "",
70
+ line: Number(position[2]),
71
+ col: Number(position[3])
72
+ };
73
+ };
74
+ const parseFrame = (raw) => {
75
+ const { text, fn, location } = splitFrame(raw);
76
+ const { where, line, col } = splitPosition(location);
77
+ const file = asPath(where);
78
+ return {
79
+ raw: text,
80
+ ...fn === void 0 ? {} : { fn },
81
+ ...file === void 0 ? {} : { file },
82
+ ...file === void 0 || line === void 0 || col === void 0 ? {} : {
83
+ line,
84
+ col
85
+ }
86
+ };
87
+ };
88
+ /** The file a frame ran in, as a path, or `undefined` for a frame with none: `node:`, `<anonymous>`, `native`, `eval`. */
89
+ const frameFile = (raw) => asPath(splitPosition(splitFrame(raw).location).where);
90
+ /** A frame under `node_modules`: a dependency's, or an installed program's own. Decided by its file alone. */
91
+ const isDependency = (raw) => /[\\/]node_modules[\\/]/.test(frameFile(raw) ?? "");
92
+ /**
93
+ * A frame that is the runtime's or Effect's, never the program's, wherever the program is installed: one with no file
94
+ * (every `node:` frame, `<anonymous>`, native), or one in Effect's own files. Decided by the file alone, never by the
95
+ * function name: V8 names a program's own thunk by Effect's method alias (`boom [as ~effect/Effect/args]`).
96
+ */
97
+ const isRuntime = (raw) => {
98
+ const file = frameFile(raw);
99
+ return file === void 0 || /[\\/]node_modules[\\/]effect[\\/]/.test(file) || /[\\/]packages[\\/]effect[\\/]src[\\/]/.test(file);
100
+ };
101
+ /** The frames of a stack that belong to the program, and how many were left out. */
102
+ const cleanStack = (stack, mode) => {
103
+ if (typeof stack !== "string") return {
104
+ frames: [],
105
+ hidden: 0
106
+ };
107
+ const lines = stack.split(/\r\n|\r|\n/).filter((line) => /^\s*at\s/.test(line));
108
+ const app = lines.filter((line) => !isRuntime(line) && !isDependency(line));
109
+ const kept = mode === "all" ? lines : app.length > 0 ? app : lines.filter((line) => !isRuntime(line));
110
+ return {
111
+ frames: kept.map(parseFrame),
112
+ hidden: lines.length - kept.length
113
+ };
114
+ };
115
+ const frameBlock = (frame, displayPath) => {
116
+ if (frame.file === void 0) return Doc.paragraph(Doc.text("at ", "muted"), frame.raw);
117
+ const where = `${displayPath(frame.file)}${frame.line === void 0 ? "" : `:${frame.line}:${frame.col}`}`;
118
+ const target = {
119
+ file: frame.file,
120
+ ...frame.line === void 0 ? {} : { line: frame.line },
121
+ ...frame.col === void 0 ? {} : { col: frame.col }
122
+ };
123
+ return Doc.paragraph(Doc.text("at ", "muted"), ...frame.fn === void 0 ? [] : [`${frame.fn} `], Doc.link(target, where));
124
+ };
125
+ const stackBlock = (defect, displayPath, mode) => {
126
+ const { frames, hidden } = cleanStack(readProperty(defect, "stack"), mode);
127
+ if (frames.length > 0) {
128
+ const shown = frames.map((frame) => frameBlock(frame, displayPath));
129
+ const note = hidden === 0 ? [] : [Doc.paragraph(Doc.text(`(+${hidden} internal frames hidden)`, "muted"))];
130
+ return Doc.collapsible("stack", [...shown, ...note], { open: true });
131
+ }
132
+ const note = hidden === 0 ? "no stack" : `no user frames (${hidden} internal frames hidden)`;
133
+ return Doc.collapsible("stack", [Doc.paragraph(Doc.text(note, "muted"))], { open: true });
134
+ };
135
+ /** The `Error.cause` chain below a defect as a tree of one-line messages, or none. */
136
+ const causeTree = (defect) => {
137
+ const chain = [];
138
+ const seen = /* @__PURE__ */ new Set([defect]);
139
+ for (let current = readProperty(defect, "cause"); current !== void 0 && chain.length < MAX_DEPTH;) {
140
+ if (seen.has(current)) break;
141
+ seen.add(current);
142
+ chain.push(firstLine(failureText(current)));
143
+ current = current instanceof Error ? readProperty(current, "cause") : void 0;
144
+ }
145
+ if (chain.length === 0) return void 0;
146
+ const nest = (index) => index === chain.length - 1 ? { label: chain[index] } : {
147
+ label: chain[index],
148
+ children: [nest(index + 1)]
149
+ };
150
+ return Doc.tree({
151
+ label: firstLine(failureText(defect)),
152
+ children: [nest(0)]
153
+ });
154
+ };
155
+ const dieBlocks = (defect, spans, displayPath, mode) => {
156
+ if (defect instanceof Cancelled || defect instanceof NotInteractive) return [Doc.paragraph(defect.message)];
157
+ const header = failureBlocks(failureText(defect));
158
+ if (!(defect instanceof Error)) return [...header, ...spans];
159
+ const chain = causeTree(defect);
160
+ return [
161
+ ...header,
162
+ ...spans,
163
+ stackBlock(defect, displayPath, mode),
164
+ ...chain === void 0 ? [] : [chain]
165
+ ];
166
+ };
167
+ const failBlocks = (error, spans, options) => {
168
+ if (hasCliDoc(error)) try {
169
+ return [...error[CliDoc](), ...spans];
170
+ } catch {}
171
+ const tag = tagOf(error);
172
+ const custom = tag === void 0 || options?.render === void 0 || !Object.hasOwn(options.render, tag) ? void 0 : options.render[tag];
173
+ if (custom !== void 0) try {
174
+ return [...custom(error), ...spans];
175
+ } catch {}
176
+ if (error instanceof Cancelled || error instanceof NotInteractive) return [Doc.paragraph(error.message)];
177
+ const schema = schemaBlocks(error);
178
+ if (schema !== void 0) return [...schema, ...spans];
179
+ return [...failureBlocks(failureText(error)), ...spans];
180
+ };
181
+ /** A file of the kit's own packages or of Effect, as installed: a span defined there is not the program's. */
182
+ const isKitFile = (file) => /[\\/]node_modules[\\/](?:@effected[\\/]|effect[\\/])/.test(file);
183
+ /**
184
+ * The installed package directory that holds `module`: the path through the last `node_modules/<name>` or
185
+ * `node_modules/@scope/name` in it, or `undefined` when the module is not under `node_modules` (or not a path at all).
186
+ * Read from the path alone, with no file system.
187
+ */
188
+ const packageDirOf = (module) => {
189
+ if (module === void 0) return void 0;
190
+ const path = asPath(module);
191
+ if (path === void 0) return void 0;
192
+ return /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(comparable(path))?.[1];
193
+ };
194
+ /**
195
+ * A path in the one spelling both sides of a comparison use: `/` separators, and a Windows drive letter lower-cased. A
196
+ * CommonJS frame on Windows reads `C:\…` where an `import.meta.url` reads `file:///C:/…`, and the drive's case is not
197
+ * fixed (`c:` and `C:` are one drive). Lexical only: no file system, no realpath.
198
+ */
199
+ const comparable = (path) => path.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `${drive.toLowerCase()}:`);
200
+ /** Whether `file` is in the program's own package `appDir`, and not in a dependency nested in its `node_modules`. */
201
+ const isAppFile = (file, appDir) => {
202
+ if (appDir === void 0) return false;
203
+ const path = comparable(file);
204
+ if (!path.startsWith(appDir)) return false;
205
+ const rest = path.slice(appDir.length);
206
+ return rest.startsWith("/") && !rest.includes("/node_modules/");
207
+ };
208
+ /** The file a span frame's captured stack points at, or `undefined` when it captured none. */
209
+ const spanFile = (frame) => {
210
+ try {
211
+ const stack = frame.stack();
212
+ if (stack === void 0) return void 0;
213
+ const line = stack.split(/\r\n|\r|\n/).find((candidate) => /^\s*at\s/.test(candidate)) ?? stack;
214
+ return frameFile(line.trim().startsWith("at ") ? line : `at ${line.trim()}`);
215
+ } catch {
216
+ return;
217
+ }
218
+ };
219
+ /**
220
+ * `in: outer › inner`, from the span stack the runtime annotates a reason with, or none. With `app`, a span the kit or
221
+ * Effect defined is left out. An `Effect.fn` call carries its definition as its parent (`name (definition)`): the two
222
+ * are one entry, `name`, judged by the definition's site, so a kit function the program calls goes with its
223
+ * definition.
224
+ */
225
+ const spanBlocks = (reason, mode, appDir) => {
226
+ if (mode === "off") return [];
227
+ const names = [];
228
+ let frame = Context.getOrUndefined(Cause.reasonAnnotations(reason), Cause.StackTrace);
229
+ for (let seen = 0; frame !== void 0 && seen < MAX_SPANS; seen++) {
230
+ const parent = frame.parent;
231
+ const definition = parent !== void 0 && parent.name === `${frame.name} (definition)` ? parent : void 0;
232
+ const file = (definition === void 0 ? void 0 : spanFile(definition)) ?? spanFile(frame);
233
+ if (mode === "all" || file === void 0 || isAppFile(file, appDir) || !isKitFile(file)) names.push(frame.name);
234
+ frame = definition === void 0 ? parent : definition.parent;
235
+ }
236
+ if (names.length === 0) return [];
237
+ return [Doc.paragraph(Doc.text("in: ", "muted"), Doc.path(...names.reverse()))];
238
+ };
239
+ /**
240
+ * A failure as a document: what the default report prints, and a building block for a custom one.
241
+ *
242
+ * @remarks
243
+ * One run of blocks per `Cause` reason. A typed failure is, in order of preference: the document of an error that
244
+ * implements {@link CliDoc}; the document `options.render` holds for its `_tag`; for `Cancelled` and
245
+ * `NotInteractive`, their one fixed line; a `Tree` of the rejected values, for a schema error or issue; else a failure
246
+ * status line with its message. A defect is its message followed by a collapsible `stack` of the program's own frames,
247
+ * each a file link (so a terminal can open it in an editor) shown through `displayPath`, with the runtime's frames (every
248
+ * `node:` frame and every frame with no file) and every `node_modules` frame (Effect's and any other dependency's)
249
+ * left out unless `stackFrames` is `all`. A frame is classified by its file alone, never by its function name, so a
250
+ * program's own thunk that V8 names by Effect's method alias is still shown. When that would
251
+ * leave no frame at all, as for a program run from its own install under `node_modules`, only the runtime's and
252
+ * Effect's are left out. Then an
253
+ * `Error.cause` chain as a tree. When cleaning leaves no frame the stack says
254
+ * `no user frames (N internal frames hidden)`, never an empty block; when frames survive and some were left out, the
255
+ * count follows them as `(+N internal frames hidden)`. A reason that ran under spans is followed by
256
+ * `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
257
+ * one line `interrupted`.
258
+ *
259
+ * All text goes through the document, so a control character in a message or a stack frame never reaches the terminal.
260
+ * Building it never throws: an error whose `_tag`, `message`, `stack`, `cause` or `issue` getter throws is drawn without
261
+ * that part (an unreadable `stack` as `no stack`, an unreadable `cause` ending the chain there). Render it with `Render.context` and `Render.plain`, `ansi`, `markdown` or `githubLog`, or `Doc.print` it.
262
+ *
263
+ * @public
264
+ */
265
+ var CliFailure = class {
266
+ constructor() {}
267
+ /**
268
+ * Build the document of a cause.
269
+ *
270
+ * @param cause - the failure
271
+ * @param options - per-tag documents and a path display function
272
+ */
273
+ static toDoc = (cause, options) => {
274
+ const reasons = cause.reasons;
275
+ if (reasons.length > 0 && reasons.every(Cause.isInterruptReason)) return [Doc.paragraph("interrupted")];
276
+ const displayPath = options?.displayPath ?? identity;
277
+ return reasons.flatMap((reason) => {
278
+ const spans = options?.spans ?? "app";
279
+ const appDir = packageDirOf(options?.appModule);
280
+ if (Cause.isFailReason(reason)) return failBlocks(reason.error, spanBlocks(reason, spans, appDir), options);
281
+ if (Cause.isDieReason(reason)) return dieBlocks(reason.defect, spanBlocks(reason, spans, appDir), displayPath, options?.stackFrames ?? "app");
282
+ return [];
283
+ });
284
+ };
285
+ };
286
+
287
+ export { CliDoc, CliFailure };
package/CliFormat.js ADDED
@@ -0,0 +1,124 @@
1
+ import { Doc } from "./Doc.js";
2
+ import { underGithubActions } from "./internal/autoFormat.js";
3
+ import { neutralizeJson } from "./internal/logSafety.js";
4
+ import { Console, Effect } from "effect";
5
+ import { Flag } from "effect/cli";
6
+
7
+ // src/CliFormat.ts
8
+ const BUILT_IN = /* @__PURE__ */ new Set([
9
+ "auto",
10
+ "plain",
11
+ "ansi",
12
+ "markdown",
13
+ "githubLog",
14
+ "json"
15
+ ]);
16
+ /**
17
+ * A `--format` flag and the one print that honours it, so a command builds its report once as a `Doc` and its data
18
+ * once as a value, and never writes per-format output by hand.
19
+ *
20
+ * @remarks
21
+ * The flag is core's `Flag.ChoiceWithValue` with the kit's names, default and description; the print hands every
22
+ * document format to `Doc.print` and writes `json` itself.
23
+ *
24
+ * ```ts
25
+ * const format = CliFormat.flag(["auto", "markdown", "json"])
26
+ * const doctor = Command.make("doctor", { format }, ({ format }) =>
27
+ * CliFormat.print(format, { doc: () => report(results), json: () => results }),
28
+ * )
29
+ * ```
30
+ *
31
+ * @public
32
+ */
33
+ var CliFormat = class CliFormat {
34
+ constructor() {}
35
+ /**
36
+ * A `--format` flag offering `formats`, and the caller's own `aliases` and `custom` formats.
37
+ *
38
+ * @remarks
39
+ * Its default is `options.default`, else `auto` when it is offered, else the first format. Any other value is a
40
+ * usage error from core's parser, naming the accepted ones. The value is the built-in format a name stands for
41
+ * (`human` under `{ human: "auto" }` is `auto`), or a custom name as itself, so the value of a flag with no custom
42
+ * formats goes straight to {@link CliFormat.print}.
43
+ *
44
+ * Two offered names that clash (an alias or custom name reusing a built-in name, or any name offered twice) and a
45
+ * default that is not offered are programming mistakes: the call throws. A CLI that still accepts a `--json` of its
46
+ * own declares and resolves that flag itself; {@link CliFormat.printJson} prints the JSON the same way.
47
+ *
48
+ * ```ts
49
+ * // okfit's contract: --format human|json, and a graph command that also draws mermaid and dot
50
+ * const format = CliFormat.flag(["json"], { aliases: { human: "auto" }, default: "human" })
51
+ * const graph = CliFormat.flag(["json"], { custom: ["mermaid", "dot"], default: "mermaid" })
52
+ * ```
53
+ *
54
+ * @param formats - the built-in formats the command offers under their own names, at least one
55
+ * @param options - the default, the flag's name and description, aliases and custom formats
56
+ */
57
+ static flag = (formats, options) => {
58
+ const name = options?.name ?? "format";
59
+ const choices = [
60
+ ...formats.map((format) => [format, format]),
61
+ ...Object.entries(options?.aliases ?? {}).map(([alias, target]) => [alias, target]),
62
+ ...(options?.custom ?? []).map((custom) => [custom, custom])
63
+ ];
64
+ const offered = choices.map(([spelling]) => spelling);
65
+ for (const [index, spelling] of offered.entries()) {
66
+ if (offered.indexOf(spelling) !== index) throw new Error(`CliFormat.flag: the format name "${spelling}" is offered twice`);
67
+ if (index >= formats.length && BUILT_IN.has(spelling)) throw new Error(`CliFormat.flag: "${spelling}" is a built-in format, not an alias or a custom one`);
68
+ }
69
+ const fallback = options?.default ?? (offered.includes("auto") ? "auto" : formats[0]);
70
+ const fallbackChoice = choices.find(([spelling]) => spelling === fallback);
71
+ if (fallbackChoice === void 0) throw new Error(`CliFormat.flag: the default "${fallback}" is not one of ${offered.join(", ")}`);
72
+ const flag = Flag.ChoiceWithValue(name, choices).pipe(Flag.withDescription(options?.description ?? `Output format: ${offered.join(", ")} (default ${fallback})`));
73
+ return Flag.withDefault(flag, fallbackChoice[1]);
74
+ };
75
+ /**
76
+ * Print a command's output in `format`.
77
+ *
78
+ * @remarks
79
+ * `json` writes `output.json()` exactly as {@link CliFormat.printJson} writes a value, which it delegates to. Every
80
+ * other format prints `output.doc()` with `Doc.print`, which renders it as `auto` or the named renderer would.
81
+ *
82
+ * @param format - the chosen format, usually the value of {@link CliFormat.flag}
83
+ * @param output - the document and the data
84
+ * @param options - the stream and the JSON indent
85
+ */
86
+ static print = (format, output, options) => Effect.gen(function* () {
87
+ const stream = options?.stream ?? "stdout";
88
+ if (format !== "json") return yield* Doc.print(output.doc(), {
89
+ stream,
90
+ format
91
+ });
92
+ const data = output.json;
93
+ if (data === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("CliFormat.print: format json needs output.json"));
94
+ return yield* CliFormat.printJson(data(), options);
95
+ });
96
+ /**
97
+ * Print `value` as JSON, the way {@link CliFormat.print} prints the `json` format, for a CLI that keeps a JSON flag
98
+ * of its own.
99
+ *
100
+ * @remarks
101
+ * One `Console.log` (or `Console.error` for stderr), indented by `indent`: one document, never one line per item.
102
+ * Under GitHub Actions a `##[` inside it is written as the JSON escape `#\u0023[`, which decodes to the same
103
+ * text, so the runner cannot read it as a command. A value JSON cannot hold (a cycle, a bigint, `undefined`) is a
104
+ * defect. It needs no theme, terminal or audience: whether this is GitHub Actions is read from `CurrentRuntimeEnv`
105
+ * when the environment has it, and is "no" otherwise.
106
+ *
107
+ * ```ts
108
+ * const status = Command.make("status", { json: Flag.Boolean("json").pipe(Flag.withDefault(false)) }, ({ json }) =>
109
+ * json ? CliFormat.printJson(results) : Doc.print(report(results)),
110
+ * )
111
+ * ```
112
+ *
113
+ * @param value - the data to print
114
+ * @param options - the stream and the JSON indent
115
+ */
116
+ static printJson = (value, options) => Effect.gen(function* () {
117
+ const text = yield* Effect.sync(() => JSON.stringify(value, null, options?.indent ?? 2));
118
+ if (text === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("CliFormat.printJson: the value is not JSON"));
119
+ const safe = (yield* underGithubActions) ? neutralizeJson(text) : text;
120
+ yield* (options?.stream ?? "stdout") === "stdout" ? Console.log(safe) : Console.error(safe);
121
+ });
122
+ };
123
+
124
+ export { CliFormat };
@@ -0,0 +1,94 @@
1
+ import { canPrompt } from "./internal/canPrompt.js";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import { Audience, TerminalEnv } from "@effected/env";
4
+ import { TerminalCapabilities } from "effect/cli";
5
+
6
+ // src/CliInteractive.ts
7
+ /**
8
+ * Whether this run may prompt a person: a human audience, with a terminal on
9
+ * both standard input and standard output, and a `TERM` that is not `dumb`.
10
+ *
11
+ * @remarks
12
+ * A `Context.Reference`, not a `Context.Service`, for three reasons. It is one
13
+ * boolean with a safe default, which is what a reference is for. A scoped
14
+ * override, {@link CliInteractive.unless}, is a plain
15
+ * `Effect.provideService`, so a command can switch prompting off for one
16
+ * subtree without a layer. And forgetting to provide it is not a type error
17
+ * but a harmless answer, since it reads `false` when no layer is provided: a
18
+ * program that never wired it refuses to prompt rather than hanging on a
19
+ * terminal that is not there. Read it with `yield* CliInteractive`.
20
+ *
21
+ * Because a reference's key type is `never`, {@link CliInteractive.layer} and
22
+ * {@link CliInteractive.layerTest} are typed `Layer<never>`: they set the
23
+ * reference rather than provide a service.
24
+ *
25
+ * Both layers also hold core's `TerminalCapabilities.canPrompt` at `true`,
26
+ * keeping its `canColor` and `canAnimate` as they find them. Core skips a
27
+ * `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` fallback entirely
28
+ * when `canPrompt` is `false`, and decides that from the process's own
29
+ * terminal, so a kit fallback (`CliPrompt.fallback`, `CliUi.fallback`) would
30
+ * never be called in a piped run and its `otherwise` would be lost. With the
31
+ * gate held open, this reference makes the prompt decision instead: the kit's
32
+ * fallbacks answer without a person when it is `false`, and
33
+ * `CliPrompt.gateTerminal` quits any other prompt at once. A
34
+ * `TerminalCapabilities` with `canPrompt: false` provided inside the program,
35
+ * under these layers, bypasses the kit's fallbacks again: the parameter fails
36
+ * as missing even when an `otherwise` is given.
37
+ *
38
+ * @public
39
+ */
40
+ var CliInteractive = class CliInteractive extends Context.Reference("@effected/cli-kit/CliInteractive", { defaultValue: () => false }) {
41
+ /**
42
+ * Decide from the audience and the terminal: `true` only for a human audience with a terminal on both
43
+ * standard input and standard output, and a `TERM` that is not `dumb`.
44
+ *
45
+ * @remarks
46
+ * A dumb terminal is a terminal, but it cannot move the cursor or take synchronized output, which a prompt or a
47
+ * screen redrawing in place needs: it gets what a pipe gets. `TERM` is read through the ambient `ConfigProvider`,
48
+ * as `@effected/env` reads the environment, so it adds no requirement; a test fixes it with
49
+ * `Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ TERM: "dumb" }))`.
50
+ *
51
+ * It also holds core's `TerminalCapabilities.canPrompt` at `true`; see {@link CliInteractive}.
52
+ *
53
+ * Bind the layer to a constant and provide it once; `Audience` and `TerminalEnv` come from `@effected/env`.
54
+ */
55
+ static layer = Layer.effectContext(Effect.gen(function* () {
56
+ const audience = yield* Audience;
57
+ const terminal = yield* TerminalEnv;
58
+ return yield* decided(audience.kind === "human" && (yield* canPrompt(terminal)));
59
+ }));
60
+ /**
61
+ * A fixed answer that needs nothing. It holds core's `canPrompt` open too, as {@link CliInteractive.layer} does.
62
+ *
63
+ * @param value - whether the run is interactive
64
+ */
65
+ static layerTest = (value) => Layer.effectContext(decided(value));
66
+ /**
67
+ * Run `self` with interactivity switched off when `condition` holds.
68
+ *
69
+ * @remarks
70
+ * It only narrows: `unless(false)` leaves the current value alone and never turns interactivity on, so a
71
+ * non-interactive scope stays non-interactive. The outer value is restored when `self` ends, whether it
72
+ * succeeds, fails or is interrupted.
73
+ *
74
+ * A flag that resolves the audience (`--human`, under `CliAudience.runWith` or `provide`) recomputes interactivity
75
+ * from the terminal facts, so it can override an outer `unless` or a `layerTest(false)`: those narrow the
76
+ * environment's answer, and the flag is a later, explicit one.
77
+ *
78
+ * @param condition - `true` to switch prompting off for `self`
79
+ */
80
+ static unless = (condition) => (self) => Effect.gen(function* () {
81
+ const current = yield* CliInteractive;
82
+ return yield* Effect.provideService(self, CliInteractive, current && !condition);
83
+ });
84
+ };
85
+ /**
86
+ * The context the two layers set: the decision, and core's capabilities as they are found with `canPrompt` held at
87
+ * `true`, so core always hands a missing parameter to its fallback and the kit's decision governs.
88
+ */
89
+ const decided = (interactive) => Effect.map(TerminalCapabilities.TerminalCapabilities, (capabilities) => Context.make(CliInteractive, interactive).pipe(Context.add(TerminalCapabilities.TerminalCapabilities, {
90
+ ...capabilities,
91
+ canPrompt: true
92
+ })));
93
+
94
+ export { CliInteractive };
package/CliLinks.js ADDED
@@ -0,0 +1,153 @@
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
+ // 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-kit/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
+ export { CliLinks, ambientLinksLayer };