@effected/cli 0.22.2 → 0.24.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/CliTest.js CHANGED
@@ -1,7 +1,57 @@
1
- import { Effect, FileSystem, Path, Stream } from "effect";
1
+ import { Fmt } from "./Fmt.js";
2
+ import { Duration, Effect, Fiber, FileSystem, Option, Path, Stream, SubscriptionRef } from "effect";
2
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
4
 
4
5
  // src/CliTest.ts
6
+ /** The complete lines of `captured` from `offset`, and the rest too once it has ended; with the offset after them. */
7
+ const linesFrom = (offset, captured) => {
8
+ const lines = [];
9
+ let start = offset;
10
+ let end = captured.text.indexOf("\n", start);
11
+ while (end !== -1) {
12
+ lines.push(captured.text.slice(start, captured.text[end - 1] === "\r" ? end - 1 : end));
13
+ start = end + 1;
14
+ end = captured.text.indexOf("\n", start);
15
+ }
16
+ if (captured.done && start < captured.text.length) {
17
+ lines.push(captured.text.slice(start));
18
+ start = captured.text.length;
19
+ }
20
+ return [start, lines];
21
+ };
22
+ /** The bound on a child that ignores its termination signal, when the caller gives none. */
23
+ const FORCE_KILL_AFTER = "2 seconds";
24
+ /** `options.forceKillAfter` or the default; a duration that is not finite is a wiring mistake and throws. */
25
+ const forceKillAfterOf = (options) => {
26
+ const input = options.forceKillAfter ?? FORCE_KILL_AFTER;
27
+ const duration = Duration.fromInput(input);
28
+ if (typeof input === "number" && Number.isNaN(input) || Option.isNone(duration) || !Duration.isFinite(duration.value) || Duration.toMillis(duration.value) < 0) throw new RangeError(`CliTest: forceKillAfter must be a finite, non-negative duration, received ${String(input)}`);
29
+ return duration.value;
30
+ };
31
+ /**
32
+ * Core's handle fails `exitCode` for a child a signal ended, naming the signal only in the error's text; this reads it
33
+ * back out, so a signal is an outcome as an exit code is. Any other failure (a failed stdin stream) stays a failure.
34
+ */
35
+ const SIGNALLED = /receipt of signal: '(SIG[A-Z0-9]+)'/;
36
+ const endingOf = (exitCode) => exitCode.pipe(Effect.map((code) => ({
37
+ exitCode: Number(code),
38
+ signal: null
39
+ })), Effect.catchTag("PlatformError", (error) => {
40
+ const match = SIGNALLED.exec(`${error.message} ${Fmt.causeMessage(error) ?? ""}`);
41
+ return match === null ? Effect.fail(error) : Effect.succeed({
42
+ exitCode: null,
43
+ signal: match[1]
44
+ });
45
+ }));
46
+ const commandOf = (bin, args, options, forceKillAfter) => ChildProcess.make(options.execPath, [bin, ...args], {
47
+ forceKillAfter,
48
+ cwd: options.cwd ?? options.sandbox.root,
49
+ env: {
50
+ ...options.sandbox.env,
51
+ ...options.env
52
+ },
53
+ stdin: options.stdin === void 0 || options.stdin === "" ? Stream.empty : Stream.make(new TextEncoder().encode(options.stdin))
54
+ });
5
55
  const text = (stream) => Stream.mkString(Stream.decodeText(stream));
6
56
  /**
7
57
  * Spawn a built CLI bin hermetically and read its exit code and streams as data.
@@ -67,28 +117,90 @@ var CliTest = class {
67
117
  * default `"pipe"` stdio, which stays open until something writes to and
68
118
  * ends it.
69
119
  */
70
- static run = (bin, args, options) => Effect.scoped(Effect.gen(function* () {
71
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
72
- const command = ChildProcess.make(options.execPath, [bin, ...args], {
73
- cwd: options.cwd ?? options.sandbox.root,
74
- env: {
75
- ...options.sandbox.env,
76
- ...options.env
77
- },
78
- stdin: options.stdin === void 0 || options.stdin === "" ? Stream.empty : Stream.make(new TextEncoder().encode(options.stdin))
120
+ static run = (bin, args, options) => {
121
+ const forceKillAfter = forceKillAfterOf(options);
122
+ return Effect.scoped(Effect.gen(function* () {
123
+ const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(commandOf(bin, args, options, forceKillAfter));
124
+ const [stdout, stderr, ending] = yield* Effect.all([
125
+ text(handle.stdout),
126
+ text(handle.stderr),
127
+ endingOf(handle.exitCode)
128
+ ], { concurrency: "unbounded" });
129
+ return {
130
+ ...ending,
131
+ stdout,
132
+ stderr
133
+ };
134
+ }));
135
+ };
136
+ /**
137
+ * Start `execPath bin ...args` and return at once with a handle on it: its output as lines while it runs, a signal,
138
+ * and its exit, for a long-running command (a `--follow`, a watch mode, a server) that {@link CliTest.run} would wait
139
+ * on forever.
140
+ *
141
+ * @remarks
142
+ * The child lives in the caller's scope: when the scope closes first, the child is sent `SIGTERM`, then `SIGKILL`
143
+ * after `forceKillAfter` (`2 seconds` by default), so a test that ends early leaves nothing running and never waits
144
+ * on a child that ignores the signal. A child a signal ended reports `exitCode: null` and the signal in `result`. Both streams are drained from the start whether or not the test reads them, so a
145
+ * chatty child never blocks on a full pipe, and each stream replays from its first line to every reader. The
146
+ * options are {@link CliTest.run}'s, `stdin` included: the child gets that text, then an ended input.
147
+ *
148
+ * ```ts
149
+ * const child = yield* CliTest.spawn("dist/bin.js", ["logs", "--follow"], { sandbox, execPath: process.execPath })
150
+ * yield* Stream.runHead(child.stdout) // the first line: it is following
151
+ * yield* child.kill("SIGINT")
152
+ * assert.strictEqual(yield* child.exitCode, 0)
153
+ * ```
154
+ *
155
+ * @param bin - the built bin to run
156
+ * @param args - its arguments
157
+ * @param options - the sandbox, the node binary, and the working directory, environment and stdin
158
+ */
159
+ static spawn = (bin, args, options) => {
160
+ const forceKillAfter = forceKillAfterOf(options);
161
+ return Effect.gen(function* () {
162
+ const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(commandOf(bin, args, options, forceKillAfter));
163
+ const capture = (stream) => Effect.gen(function* () {
164
+ const ref = yield* SubscriptionRef.make({
165
+ text: "",
166
+ done: false
167
+ });
168
+ return {
169
+ ref,
170
+ pump: yield* Stream.decodeText(stream).pipe(Stream.runForEach((chunk) => SubscriptionRef.update(ref, (s) => ({
171
+ text: s.text + chunk,
172
+ done: false
173
+ }))), Effect.ensuring(SubscriptionRef.update(ref, (s) => ({
174
+ text: s.text,
175
+ done: true
176
+ }))), Effect.forkScoped),
177
+ lines: SubscriptionRef.changes(ref).pipe(Stream.takeUntil((s) => s.done), Stream.mapAccum(() => 0, linesFrom))
178
+ };
179
+ });
180
+ const out = yield* capture(handle.stdout);
181
+ const err = yield* capture(handle.stderr);
182
+ const ending = endingOf(handle.exitCode);
183
+ return {
184
+ stdout: out.lines,
185
+ stderr: err.lines,
186
+ kill: (signal) => handle.kill({
187
+ killSignal: signal ?? "SIGTERM",
188
+ forceKillAfter
189
+ }).pipe(Effect.catchTag("PlatformError", (error) => Effect.flatMap(handle.isRunning, (running) => running ? Effect.fail(error) : Effect.void))),
190
+ exitCode: Effect.map(ending, (end) => end.exitCode),
191
+ result: Effect.gen(function* () {
192
+ const end = yield* ending;
193
+ yield* Fiber.join(out.pump);
194
+ yield* Fiber.join(err.pump);
195
+ return {
196
+ ...end,
197
+ stdout: (yield* SubscriptionRef.get(out.ref)).text,
198
+ stderr: (yield* SubscriptionRef.get(err.ref)).text
199
+ };
200
+ })
201
+ };
79
202
  });
80
- const handle = yield* spawner.spawn(command);
81
- const [stdout, stderr, exitCode] = yield* Effect.all([
82
- text(handle.stdout),
83
- text(handle.stderr),
84
- handle.exitCode
85
- ], { concurrency: "unbounded" });
86
- return {
87
- exitCode: Number(exitCode),
88
- stdout,
89
- stderr
90
- };
91
- }));
203
+ };
92
204
  };
93
205
 
94
206
  export { CliTest };
@@ -1,4 +1,4 @@
1
- import { formatIssue } from "./internal/format.js";
1
+ import { formatIssueWithHints } from "./internal/format.js";
2
2
 
3
3
  // src/ConfigIssueRenderer.ts
4
4
  /**
@@ -47,11 +47,17 @@ var ConfigIssueRenderer = class {
47
47
  * `Effect.catchTag("ConfigValidationError", …)` the error is already this
48
48
  * type.
49
49
  *
50
+ * An unknown key the schema has a near match for at the same path ends in a
51
+ * hint, `unknown key at theme.colour (did you mean "color"?)`, drawn from the
52
+ * keys the struct declares there (every branch's, under a union) with
53
+ * {@link Fmt.suggest}'s threshold. A key with no near match is drawn exactly
54
+ * as {@link SchemaIssueRenderer} draws it.
55
+ *
50
56
  * It cannot throw on a malformed value: the issue tree is validated by
51
57
  * a guard before it is read, so a renderer on an error path never becomes the
52
58
  * reason a program dies.
53
59
  */
54
- static render = (error) => formatIssue(error?.issue);
60
+ static render = (error) => formatIssueWithHints(error?.issue);
55
61
  };
56
62
 
57
63
  export { ConfigIssueRenderer };
package/Fmt.js CHANGED
@@ -1,7 +1,40 @@
1
- import { displayWidth, graphemes, stripAnsi } from "./internal/displayWidth.js";
1
+ import { displayWidth, graphemes, isEscapeOnly, stripAnsi } from "./internal/displayWidth.js";
2
2
  import { Duration, Option } from "effect";
3
+ import { ErrorText } from "@effected/engine";
3
4
 
4
5
  // src/Fmt.ts
6
+ /**
7
+ * The Levenshtein distance between `a` and `b` when it is at most `limit`, and `limit + 1` otherwise.
8
+ *
9
+ * Only the diagonal band of the matrix within `limit` of it is computed (a cell outside it is past `limit` already),
10
+ * over two rows reused in turn, and a row whose every cell is past `limit` ends the walk, since no later row can come
11
+ * back under it: the cost is the length of `a` times `2 * limit + 1`.
12
+ */
13
+ const boundedDistance = (a, b, limit) => {
14
+ const over = limit + 1;
15
+ if (Math.abs(a.length - b.length) > limit) return over;
16
+ const n = b.length;
17
+ const at = (row, j) => row[j] ?? over;
18
+ let previous = Array.from({ length: n + 1 }, (_, j) => j <= limit ? j : over);
19
+ let current = new Array(n + 1).fill(over);
20
+ for (let i = 1; i <= a.length; i++) {
21
+ const lo = Math.max(1, i - limit);
22
+ const hi = Math.min(n, i + limit);
23
+ const edge = lo === 1 && i <= limit ? i : over;
24
+ current[lo - 1] = edge;
25
+ let rowMin = edge;
26
+ for (let j = lo; j <= hi; j++) {
27
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
28
+ const cell = Math.min(at(previous, j) + 1, at(current, j - 1) + 1, at(previous, j - 1) + cost, over);
29
+ current[j] = cell;
30
+ if (cell < rowMin) rowMin = cell;
31
+ }
32
+ if (hi < n) current[hi + 1] = over;
33
+ if (rowMin > limit) return over;
34
+ [previous, current] = [current, previous];
35
+ }
36
+ return Math.min(at(previous, n), over);
37
+ };
5
38
  const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
6
39
  /**
7
40
  * Text made safe to lay out and print: complete ANSI and OSC sequences removed, every remaining control character
@@ -137,6 +170,166 @@ var Fmt = class {
137
170
  * @param plural - the noun otherwise; `singular` with an `s` appended by default
138
171
  */
139
172
  static plural = (n, singular, plural) => `${n} ${n === 1 ? singular : plural ?? `${singular}s`}`;
173
+ /**
174
+ * Pad `text` to `width` terminal columns, measured as {@link Fmt.width} measures: a wide character counts two and
175
+ * an escape sequence none, so coloured text pads to the same column as plain text.
176
+ *
177
+ * @remarks
178
+ * Text already `width` columns or wider is returned unchanged: compose with {@link Fmt.truncate} to cut it. A
179
+ * `width` that is not a finite number pads nothing. Never throws.
180
+ *
181
+ * @param text - the text to pad
182
+ * @param width - the columns the result should take
183
+ * @param options - the alignment and the fill
184
+ */
185
+ static pad = (text, width, options) => {
186
+ const missing = Number.isFinite(width) ? Math.floor(width) - displayWidth(text) : 0;
187
+ if (missing <= 0) return text;
188
+ const fill = options?.fill !== void 0 && displayWidth(options.fill) === 1 && stripAnsi(options.fill) === options.fill ? options.fill : " ";
189
+ switch (options?.align) {
190
+ case "right": return `${fill.repeat(missing)}${text}`;
191
+ case "center": {
192
+ const before = Math.floor(missing / 2);
193
+ return `${fill.repeat(before)}${text}${fill.repeat(missing - before)}`;
194
+ }
195
+ default: return `${text}${fill.repeat(missing)}`;
196
+ }
197
+ };
198
+ /**
199
+ * `text` without its escape sequences: CSI (colour, cursor moves, erases), OSC (titles, OSC 8 hyperlinks) ending in
200
+ * BEL or ST, the DCS, SOS, PM and APC strings, and every other escape, the nF sequences (`ESC ( B`) and the
201
+ * two-byte ones (`ESC 7`, `ESC 8`, `ESC =`, `ESC c`).
202
+ *
203
+ * @remarks
204
+ * The same strip {@link Fmt.width} and {@link Fmt.sanitize} start from. Only complete sequences go: a lone ESC, or
205
+ * any other control character, is kept, and so is an ESC in front of a lowercase letter other than `c`, which a
206
+ * stray ESC before a word would otherwise cost the word its first letter. An unterminated CSI or OSC keeps its `[`
207
+ * or `]`. Use {@link Fmt.sanitize} for text that will be laid out and printed.
208
+ *
209
+ * @param text - the text
210
+ */
211
+ static stripAnsi = (text) => stripAnsi(text);
212
+ /**
213
+ * Whether `text` is nothing but escape sequences, as {@link Fmt.stripAnsi} reads them: a cursor hide or show, a
214
+ * clear-screen, an erase-scrollback. Empty text is not escape-only, and neither is a bare control character
215
+ * (`\r`, BEL), which is not an escape sequence.
216
+ *
217
+ * @param text - the text
218
+ */
219
+ static isEscapeOnly = (text) => isEscapeOnly(text);
220
+ /**
221
+ * Integers as compact ranges: `[1, 2, 3, 5, 10, 11]` is `"1-3,5,10-11"`, a run of consecutive numbers written
222
+ * `start-end` and the rest one by one, joined with commas.
223
+ *
224
+ * @remarks
225
+ * The input is sorted and its duplicates dropped first, so order does not matter. Anything that is not a finite
226
+ * integer is left out, and no input is `""`. Never throws.
227
+ *
228
+ * @param numbers - the integers, such as line numbers
229
+ */
230
+ static ranges = (numbers) => {
231
+ const sorted = [...new Set(Array.from(numbers).filter((n) => Number.isInteger(n)))].sort((a, b) => a - b);
232
+ const parts = [];
233
+ let index = 0;
234
+ while (index < sorted.length) {
235
+ const start = sorted[index];
236
+ let end = start;
237
+ while (index + 1 < sorted.length && sorted[index + 1] === end + 1) end = sorted[++index];
238
+ parts.push(start === end ? `${start}` : `${start}-${end}`);
239
+ index++;
240
+ }
241
+ return parts.join(",");
242
+ };
243
+ /**
244
+ * Any value as text, without ever throwing.
245
+ *
246
+ * @remarks
247
+ * A string is itself; a number, boolean, bigint or symbol is `String(value)`; `undefined` and `null` are
248
+ * `"undefined"` and `"null"`. A value with a string `message` (an `Error`, one from another realm, a plain
249
+ * `{ message }`) is that message. Anything else is its JSON, or `String(value)` when it has none (a cycle, a
250
+ * function), or `"[unprintable value]"` when that throws too. A getter that throws is a value with no message.
251
+ *
252
+ * The text is not sanitised: pass it through {@link Fmt.sanitize} before printing data you do not control. For
253
+ * the messages along an error's `cause` chain, use {@link Fmt.causeMessage}; for the two combined, use
254
+ * {@link Fmt.errorText}.
255
+ *
256
+ * It is `@effected/engine`'s `ErrorText.describe`, the same function, so a front end that cannot depend on this
257
+ * package (an MCP server) describes a value with the same words.
258
+ *
259
+ * @param value - the value
260
+ */
261
+ static describe = ErrorText.describe;
262
+ /**
263
+ * Every distinct non-empty message along a value's `cause` chain, nearest first and joined with `: `, or
264
+ * `undefined` when nothing along it has one.
265
+ *
266
+ * @remarks
267
+ * The kit's one guarded cause-message read, the one its own errors render a preserved cause with. A node's message
268
+ * is a thrown string or a non-empty string `message` property, read without `instanceof Error`, so an error from
269
+ * another realm or a plain `{ message }` object counts. A deeper message the joined text already contains is not
270
+ * repeated, and one that contains the joined text replaces it, so a `SqlError` wrapping SQLite's own text reads
271
+ * `Failed to execute statement: SQLITE_BUSY: database is locked`. It is the whole chain, not the innermost
272
+ * message alone. The walk stops after eight nodes, so a cyclic chain terminates.
273
+ *
274
+ * Never throws: a `message` getter that throws leaves that node without text, and a `cause` getter that throws ends
275
+ * the walk with the text found so far, `undefined` when there is none, so a caller's own fallback
276
+ * (`?? Fmt.describe(value)`, which {@link Fmt.errorText} spells once) applies.
277
+ *
278
+ * It is `@effected/engine`'s `ErrorText.causeMessage`, the same function.
279
+ *
280
+ * @param value - the error, or whatever was thrown
281
+ */
282
+ static causeMessage = ErrorText.causeMessage;
283
+ /**
284
+ * The text of an unknown failure: {@link Fmt.causeMessage} when the chain carries a message, otherwise
285
+ * {@link Fmt.describe}.
286
+ *
287
+ * @remarks
288
+ * Exactly `Fmt.causeMessage(value) ?? Fmt.describe(value)`, the fallback an I/O site turning a caught value into
289
+ * text would otherwise spell out by hand at every call. An error whose message is empty and whose chain carries none
290
+ * is `""`, as `describe` reads it. Never throws, and not sanitised: pass it through {@link Fmt.sanitize} before
291
+ * printing data you do not control. It is `@effected/engine`'s `ErrorText.of`, the same function.
292
+ *
293
+ * @param value - the error, or whatever was thrown
294
+ */
295
+ static errorText = ErrorText.of;
296
+ /**
297
+ * The candidates nearest to `input`, for a "did you mean" hint: those at the smallest edit distance found, up to
298
+ * `maxDistance` edits (two by default), in the order given, each once. No candidate that close is `[]`.
299
+ *
300
+ * @remarks
301
+ * The distance is Levenshtein's (one insertion, deletion or substitution per edit), over UTF-16 code units and
302
+ * case-sensitive, with the same two-edit threshold and the same ties-all-kept rule `effect/cli` uses for an unknown
303
+ * subcommand or flag, so a program's own suggestions agree with the parser's. A candidate equal to `input` is at
304
+ * distance zero and is the only one returned.
305
+ *
306
+ * The cost is linear in the candidates' total length: a candidate whose length differs from `input`'s by more than
307
+ * `maxDistance` is skipped unmeasured, and only the band of the edit matrix within `maxDistance` of its diagonal is
308
+ * measured, so a long typed value cannot make it quadratic. A `maxDistance` that is not a non-negative integer is a programming mistake and
309
+ * throws a `RangeError`.
310
+ *
311
+ * ```ts
312
+ * Fmt.suggest("stauts", ["status", "stack", "build"]) // => ["status"]
313
+ * ```
314
+ *
315
+ * @param input - what was typed: an unknown key, a profile name, a phase name
316
+ * @param candidates - the names it could have meant
317
+ * @param options - the most edits a suggestion may be away
318
+ */
319
+ static suggest = (input, candidates, options) => {
320
+ const max = options?.maxDistance ?? 2;
321
+ if (!Number.isInteger(max) || max < 0) throw new RangeError(`Fmt.suggest: maxDistance must be a non-negative integer, received ${max}`);
322
+ let best = max + 1;
323
+ let found = [];
324
+ for (const candidate of new Set(candidates)) {
325
+ const distance = boundedDistance(input, candidate, Math.min(max, best));
326
+ if (distance < best) {
327
+ best = distance;
328
+ found = [candidate];
329
+ } else if (distance === best) found.push(candidate);
330
+ }
331
+ return best <= max ? found : [];
332
+ };
140
333
  };
141
334
 
142
335
  export { Fmt, sanitize };
package/Render.js CHANGED
@@ -4,6 +4,7 @@ import { Glyphs } from "./Glyphs.js";
4
4
  import { paintStyle } from "./internal/ansi.js";
5
5
  import { Token } from "./Token.js";
6
6
  import { CliTheme } from "./CliTheme.js";
7
+ import { CurrentDisplayPath, identity } from "./internal/displayPath.js";
7
8
  import { renderAnsi } from "./internal/renderAnsi.js";
8
9
  import { renderPlain } from "./internal/renderPlain.js";
9
10
  import { renderGithubLog } from "./internal/renderGithubLog.js";
@@ -62,6 +63,8 @@ var Render = class {
62
63
  * - `groupDepth` is the fiber's count of open Actions log groups (the fiber-local keyed by
63
64
  * `WorkflowCommand.groupDepthKey`, which `ActionLogger.group` provides), set when it is above `0`, so a
64
65
  * document printed inside a group opens no second one; see {@link RenderContext.groupDepth}.
66
+ * - `displayPath` is the option, else the run's own path display (`CliEnvOptions.displayPath`, which
67
+ * `CliEnv.layer` sets), else the identity.
65
68
  *
66
69
  * @param stream - the stream the output is for
67
70
  * @param options - an explicit width and a path display function
@@ -87,7 +90,7 @@ var Render = class {
87
90
  hyperlinks: terminal[stream].hyperlinks,
88
91
  audience: kind
89
92
  }),
90
- displayPath: options?.displayPath ?? ((absolute) => absolute)
93
+ displayPath: options?.displayPath ?? (yield* CurrentDisplayPath)
91
94
  };
92
95
  });
93
96
  /**
@@ -117,7 +120,7 @@ var Render = class {
117
120
  hyperlinks: true,
118
121
  audience: options.audience
119
122
  }),
120
- displayPath: options.displayPath ?? ((absolute) => absolute),
123
+ displayPath: options.displayPath ?? identity,
121
124
  ...options.neutralizeWorkflowCommands ?? options.audience === "ci" ? { neutralizeWorkflowCommands: true } : options.neutralizeWorkflowCommands === false ? { neutralizeWorkflowCommands: false } : {},
122
125
  ...options.linkBase === void 0 ? {} : { linkBase: options.linkBase },
123
126
  ...options.groupDepth === void 0 ? {} : { groupDepth: options.groupDepth }