@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.
@@ -2,88 +2,34 @@ import { Console, Effect } from "effect";
2
2
  import { CliOutput } from "effect/cli";
3
3
 
4
4
  // src/internal/HelpRouting.ts
5
- const OTHER_METHODS = Object.keys({
6
- assert: true,
7
- clear: true,
8
- count: true,
9
- countReset: true,
10
- debug: true,
11
- dir: true,
12
- dirxml: true,
13
- group: true,
14
- groupCollapsed: true,
15
- groupEnd: true,
16
- info: true,
17
- table: true,
18
- time: true,
19
- timeEnd: true,
20
- timeLog: true,
21
- trace: true,
22
- warn: true
23
- });
5
+ /**
6
+ * Core's presenter, with an invalid invocation's help written to stderr beside
7
+ * its errors instead of stdout.
8
+ *
9
+ * @remarks
10
+ * Core's `defaultPresenter` writes an invalid invocation's help with
11
+ * `Console.log` and its errors with `Console.error`. Here it runs under a
12
+ * `Console` whose `log` is the ambient one's `error`, so the help and errors
13
+ * keep core's rendering, Formatter resolution included, and only the stream
14
+ * moves. An explicit `--help`, a bare group invocation and `--version` are
15
+ * other events, presented by core unchanged on stdout.
16
+ */
17
+ const usageErrorsToStderr = { present: (event) => event._tag !== "InvalidInvocation" ? CliOutput.defaultPresenter.present(event) : Effect.gen(function* () {
18
+ const sink = yield* Console.Console;
19
+ const toStderr = Object.assign(Object.create(sink), { log: (...args) => sink.error(...args) });
20
+ yield* CliOutput.defaultPresenter.present(event).pipe(Effect.provideService(Console.Console, toStderr));
21
+ }) };
24
22
  /**
25
23
  * Run `program` so a help document printed together with parse errors goes to
26
24
  * stderr, beside the errors, instead of stdout.
27
25
  *
28
26
  * @remarks
29
- * Core's `Command.runWith` prints a usage error as `Console.log(help)` then
30
- * `Console.error(errors)`, and an explicit `--help` or a bare group
31
- * invocation as the same `Console.log(help)` with nothing after it. The two
32
- * only differ in what follows, so the help is held: the Formatter records
33
- * every string its `formatHelpDoc` and `formatErrors` return, and a `log` of
34
- * a recorded help string waits for the next console call. An `error` of a
35
- * recorded errors string moves it to stderr; anything else, or the program
36
- * ending, releases it to stdout. At most one document is held, and output
37
- * order is kept.
38
- *
39
- * A Formatter or Console provided inside `program` is not wrapped, so help
40
- * stays on stdout there: core's own behaviour.
27
+ * Provides core's `CliOutput.Presenter`, which `Command.runWith` hands each
28
+ * help, invalid-invocation and version event to. A Presenter provided inside
29
+ * `program` replaces this one.
41
30
  *
42
31
  * @internal
43
32
  */
44
- const routeHelpOnUsageError = (program) => Effect.gen(function* () {
45
- const formatter = yield* CliOutput.Formatter;
46
- const sink = yield* Console.Console;
47
- const helps = /* @__PURE__ */ new Set();
48
- const errors = /* @__PURE__ */ new Set();
49
- let held;
50
- const release = () => {
51
- if (held === void 0) return;
52
- const help = held;
53
- held = void 0;
54
- sink.log(...help);
55
- };
56
- const recording = Object.assign(Object.create(formatter), {
57
- formatHelpDoc: (doc) => {
58
- const text = formatter.formatHelpDoc(doc);
59
- helps.add(text);
60
- return text;
61
- },
62
- formatErrors: (list) => {
63
- const text = formatter.formatErrors(list);
64
- errors.add(text);
65
- return text;
66
- }
67
- });
68
- const routing = Object.create(sink);
69
- for (const method of OTHER_METHODS) routing[method] = (...args) => {
70
- release();
71
- return sink[method](...args);
72
- };
73
- routing.log = (...args) => {
74
- release();
75
- if (args.length === 1 && typeof args[0] === "string" && helps.has(args[0])) held = args;
76
- else sink.log(...args);
77
- };
78
- routing.error = (...args) => {
79
- if (held !== void 0 && args.length === 1 && typeof args[0] === "string" && errors.has(args[0])) {
80
- const help = held;
81
- held = void 0;
82
- sink.error(...help);
83
- } else release();
84
- sink.error(...args);
85
- };
86
- return yield* program.pipe(Effect.provideService(CliOutput.Formatter, recording), Effect.provideService(Console.Console, routing), Effect.ensuring(Effect.sync(release)));
87
- });
33
+ const routeHelpOnUsageError = (program) => Effect.provideService(program, CliOutput.Presenter, usageErrorsToStderr);
88
34
 
89
35
  export { routeHelpOnUsageError };
@@ -0,0 +1,46 @@
1
+ import { Context, Effect, Layer, Option, Path } from "effect";
2
+
3
+ // src/internal/displayPath.ts
4
+ /**
5
+ * The display that shows every path as it is.
6
+ *
7
+ * @internal
8
+ */
9
+ const identity = (absolute) => absolute;
10
+ /**
11
+ * The path display in effect: the identity unless `CliEnv.layer` was given a `displayPath`.
12
+ *
13
+ * @internal
14
+ */
15
+ const CurrentDisplayPath = Context.Reference("@effected/cli/CurrentDisplayPath", { defaultValue: () => identity });
16
+ /**
17
+ * A path relative to `root` when it lies under it, and the path unchanged when it is `root` itself or lies outside it
18
+ * (a `../..` chain says less than the absolute path). With a `Path` service its `relative` decides; without one, a
19
+ * plain prefix test on `/` and `\` does.
20
+ *
21
+ * @internal
22
+ */
23
+ const relativeTo = (root, path) => Option.match(path, {
24
+ onSome: (p) => (absolute) => {
25
+ const rel = p.relative(root, absolute);
26
+ return rel === "" || rel === ".." || rel.startsWith(`..${p.sep}`) || rel.startsWith("../") || p.isAbsolute(rel) ? absolute : rel;
27
+ },
28
+ onNone: () => {
29
+ let end = root.length;
30
+ while (end > 0 && (root[end - 1] === "/" || root[end - 1] === "\\")) end -= 1;
31
+ const base = root.slice(0, end);
32
+ return (absolute) => {
33
+ const next = absolute.charAt(base.length);
34
+ return absolute.startsWith(base) && (next === "/" || next === "\\") && absolute.length > base.length + 1 ? absolute.slice(base.length + 1) : absolute;
35
+ };
36
+ }
37
+ });
38
+ /**
39
+ * Sets {@link CurrentDisplayPath} from the option, reading `Path` from the context when it is there. No option sets
40
+ * nothing.
41
+ *
42
+ * @internal
43
+ */
44
+ const displayPathLayer = (option) => option === void 0 ? Layer.empty : Layer.effect(CurrentDisplayPath)(typeof option === "function" ? Effect.succeed(option) : Effect.map(Effect.serviceOption(Path.Path), (path) => relativeTo(option.relativeTo, path)));
45
+
46
+ export { CurrentDisplayPath, displayPathLayer, identity, relativeTo };
@@ -1,16 +1,24 @@
1
1
  // src/internal/displayWidth.ts
2
2
  const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3
- const ANSI = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\u001B\[[0-?]*[ -/]*[@-~]/g;
3
+ const ANSI = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[PX^_][^\u001B]*\u001B\\|[()*+\-./][0-~]|\$[()*+\-./]?[@-~]|#[3-68]| [FGLMN]|[0-?@-Z\\^_c])/g;
4
4
  const EMOJI = /^\p{RGI_Emoji}$/v;
5
5
  const WIDE2 = /[\p{Emoji_Presentation}\u1100-\u115E\u2329\u232A\u2630-\u2637\u268A-\u268F\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u303E\u3041-\u3096\u3099-\u30FF\u3105-\u312F\u3131-\u3163\u3165-\u318E\u3190-\u31E5\u31EF-\u321E\u3220-\u3247\u3250-\u4DBF\u4DC0-\uA48C\uA490-\uA4C6\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{16FF6}\u{17000}-\u{191FF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1D300}-\u{1D376}\u{1F200}-\u{1F265}\u{20000}-\u{3FFFD}]/u;
6
6
  const ZERO2 = /^[\p{Mn}\p{Me}\p{Cf}\p{Cc}\u115F\u1160\u3164\uFFA0]+$/u;
7
7
  /**
8
- * The text without its ANSI escape sequences: CSI (including SGR colour) and OSC (including OSC-8 hyperlinks).
8
+ * The text without its escape sequences: CSI (including SGR colour), OSC (including OSC-8 hyperlinks), the DCS, SOS,
9
+ * PM and APC strings, and every other escape (`ESC c`, `ESC 7`, `ESC ( B`).
9
10
  *
10
11
  * @internal
11
12
  */
12
13
  const stripAnsi = (input) => input.replace(ANSI, "");
13
14
  /**
15
+ * Whether `input` is nothing but escape sequences, as {@link stripAnsi} reads them; empty text is not. Every sequence
16
+ * starts with ESC, so text that does not is answered without running the strip.
17
+ *
18
+ * @internal
19
+ */
20
+ const isEscapeOnly = (input) => input.charCodeAt(0) === 27 && stripAnsi(input) === "";
21
+ /**
14
22
  * The display width of `input` in terminal columns: graphemes, wide East Asian characters and emoji count two,
15
23
  * combining marks, control characters and ANSI escapes count none.
16
24
  *
@@ -31,4 +39,4 @@ const displayWidth = (input) => {
31
39
  */
32
40
  const graphemes = (input) => Array.from(segmenter.segment(input), (s) => s.segment);
33
41
 
34
- export { displayWidth, graphemes, stripAnsi };
42
+ export { displayWidth, graphemes, isEscapeOnly, stripAnsi };
@@ -3,6 +3,7 @@ import { sanitize } from "../Fmt.js";
3
3
  import { CliLinks } from "../CliLinks.js";
4
4
  import { Glyphs } from "../Glyphs.js";
5
5
  import { CliTheme } from "../CliTheme.js";
6
+ import { identity } from "./displayPath.js";
6
7
  import { Render } from "../Render.js";
7
8
  import { CliFailure } from "../CliFailure.js";
8
9
  import { Config, Context, Effect, MutableRef, Option } from "effect";
@@ -31,7 +32,7 @@ const fallbackTarget = {
31
32
  paint: (_token, text) => text,
32
33
  glyphs: Glyphs.ascii,
33
34
  link: (_target, label) => label,
34
- displayPath: (absolute) => absolute,
35
+ displayPath: identity,
35
36
  neutralizeWorkflowCommands: true
36
37
  },
37
38
  format: "plain",
@@ -1,6 +1,9 @@
1
- import { SchemaIssue } from "effect";
1
+ import { Fmt } from "../Fmt.js";
2
+ import { SchemaAST, SchemaIssue } from "effect";
2
3
 
3
4
  // src/internal/format.ts
5
+ /** The message an unknown key renders as: the leaf hook below writes it and the "did you mean" hint reads it. */
6
+ const UNKNOWN_KEY = "unknown key";
4
7
  /**
5
8
  * Core's structured formatter, with one phrasing override.
6
9
  *
@@ -16,7 +19,7 @@ import { SchemaIssue } from "effect";
16
19
  *
17
20
  * @internal
18
21
  */
19
- const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? "unknown key" : SchemaIssue.defaultLeafHook(issue) });
22
+ const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? UNKNOWN_KEY : SchemaIssue.defaultLeafHook(issue) });
20
23
  /**
21
24
  * Flatten an issue tree to one line per rejected value.
22
25
  *
@@ -39,6 +42,74 @@ const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue)
39
42
  */
40
43
  const formatIssue = (issue) => issueEntries(issue).map((entry) => entry.path.length === 0 ? entry.message : `${entry.message} at ${entry.path.join(".")}`);
41
44
  /**
45
+ * {@link formatIssue}, with a "did you mean" hint on an unknown key the schema has a near match for.
46
+ *
47
+ * @remarks
48
+ * Used by `ConfigIssueRenderer` only. The declared keys come from the issue tree itself: core's `UnexpectedKey` leaf
49
+ * carries the AST of the struct being decoded, whose property signatures are the keys the schema accepts at that
50
+ * path, so no schema is passed in and no `@effected/config-file` value is needed. The hint is `Fmt.suggest`'s list,
51
+ * each key sanitised, and a line with no near match is exactly the line {@link formatIssue} draws.
52
+ *
53
+ * @internal
54
+ */
55
+ const formatIssueWithHints = (issue) => {
56
+ const declared = declaredKeys(issue);
57
+ return issueEntries(issue).map((entry) => {
58
+ if (entry.path.length === 0) return entry.message;
59
+ const line = `${entry.message} at ${entry.path.join(".")}`;
60
+ if (entry.message !== UNKNOWN_KEY) return line;
61
+ const candidates = declared.get(pathKey(entry.path));
62
+ const input = entry.path[entry.path.length - 1];
63
+ if (candidates === void 0 || input === void 0) return line;
64
+ const near = Fmt.suggest(input, candidates);
65
+ if (near.length === 0) return line;
66
+ return `${line} (did you mean ${orList(near.map((key) => `"${Fmt.sanitize(key)}"`))}?)`;
67
+ });
68
+ };
69
+ /** A path as a map key: `JSON.stringify` keeps `["a.b"]` and `["a", "b"]` apart, which joining on `.` would not. */
70
+ const pathKey = (path) => JSON.stringify(path);
71
+ /** `"a"`, `"a" or "b"`, `"a", "b" or "c"`. */
72
+ const orList = (items) => items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
73
+ /**
74
+ * The keys each unknown key's struct declares, by the unknown key's own path.
75
+ *
76
+ * @remarks
77
+ * Walks the tree the way core's formatter does (a `Pointer` extends the path; a `Filter`, `Encoding`, `Composite` or
78
+ * `AnyOf` passes it through), so a path here is the path the formatter gives the same leaf. A union reports one
79
+ * `UnexpectedKey` per branch at one path, and every branch's keys are candidates. Only string keys are offered: a
80
+ * symbol key is never something a person typed into a config file.
81
+ */
82
+ const declaredKeys = (issue) => {
83
+ const found = /* @__PURE__ */ new Map();
84
+ if (!SchemaIssue.isIssue(issue)) return found;
85
+ const walk = (node, path) => {
86
+ switch (node._tag) {
87
+ case "Filter":
88
+ case "Encoding":
89
+ walk(node.issue, path);
90
+ return;
91
+ case "Pointer":
92
+ walk(node.issue, [...path, ...node.path.map(String)]);
93
+ return;
94
+ case "Composite":
95
+ case "AnyOf":
96
+ for (const child of node.issues) walk(child, path);
97
+ return;
98
+ case "UnexpectedKey": {
99
+ if (!SchemaAST.isObjects(node.ast)) return;
100
+ const key = pathKey(path);
101
+ const keys = found.get(key) ?? /* @__PURE__ */ new Set();
102
+ for (const signature of node.ast.propertySignatures) if (typeof signature.name === "string") keys.add(signature.name);
103
+ found.set(key, keys);
104
+ return;
105
+ }
106
+ default: return;
107
+ }
108
+ };
109
+ walk(issue, []);
110
+ return found;
111
+ };
112
+ /**
42
113
  * The rejected values of an issue tree, once each: what both the lines and the tree are built from.
43
114
  *
44
115
  * @remarks
@@ -101,4 +172,4 @@ const issueTreeChildren = (entries) => {
101
172
  return [...leaves(root), ...[...root.children].map(([name, child]) => named(name, child))];
102
173
  };
103
174
 
104
- export { formatIssue, issueEntries, issueTreeChildren };
175
+ export { formatIssue, formatIssueWithHints, issueEntries, issueTreeChildren };
@@ -0,0 +1,40 @@
1
+ // src/internal/inspect.ts
2
+ /** The value's `_tag` when it is a string; `undefined` otherwise, and when reading it throws. */
3
+ const tagOf = (value) => {
4
+ if (typeof value !== "object" || value === null) return void 0;
5
+ try {
6
+ const tag = value._tag;
7
+ return typeof tag === "string" ? tag : void 0;
8
+ } catch {
9
+ return;
10
+ }
11
+ };
12
+ /**
13
+ * A failure value as report text: `String(value)`, except that a plain object whose `String` says only
14
+ * `[object Object]` is its string `message` when it has one. `String(value)` throws for an object with no prototype or
15
+ * a hostile `toString`: that value is `unprintable`.
16
+ */
17
+ const failureText = (value, unprintable = "[unprintable value]") => {
18
+ try {
19
+ const text = String(value);
20
+ if (text !== "[object Object]") return text;
21
+ const message = value?.message;
22
+ return typeof message === "string" ? message : text;
23
+ } catch {
24
+ return unprintable;
25
+ }
26
+ };
27
+ /**
28
+ * A property of a failure value, or `undefined` when it has none or reading it throws: a report must never fail on a
29
+ * hostile getter. An `Error`'s `stack` counts, since V8 formats it on first read and the formatting reads `message`.
30
+ */
31
+ const readProperty = (value, key) => {
32
+ if (typeof value !== "object" || value === null) return void 0;
33
+ try {
34
+ return value[key];
35
+ } catch {
36
+ return;
37
+ }
38
+ };
39
+
40
+ export { failureText, readProperty, tagOf };
@@ -36,7 +36,7 @@ const sanitizeParts = (message) => {
36
36
  };
37
37
  /**
38
38
  * Neutralize the `##[` the runner's legacy parser finds anywhere in a line, inside an NDJSON record, as the JSON escape
39
- * `##[`: it decodes to the identical text, so the record loses nothing. A `##[` can only sit in a string there.
39
+ * `#\u0023[`: it decodes to the identical text, so the record loses nothing. A `##[` can only sit in a string there.
40
40
  *
41
41
  * @internal
42
42
  */
@@ -0,0 +1,23 @@
1
+ import { Config, Effect, Layer, Option } from "effect";
2
+ import { TerminalEnv } from "@effected/env";
3
+ import { TerminalCapabilities } from "effect/cli";
4
+
5
+ // src/internal/terminalCapabilities.ts
6
+ /**
7
+ * Installs core's `TerminalCapabilities` from the ambient `TerminalEnv`, through `TerminalEnv.capabilities`, so core's
8
+ * own colour decision follows the kit's rule. `TERM` is read through `Config`, and a read that fails counts as unset.
9
+ *
10
+ * @remarks
11
+ * It lives here rather than in `@effected/env` because `effect/cli` exports the reference only through its barrel,
12
+ * and a value import there would load that barrel for every env consumer. `CliInteractive.layer`, built over it,
13
+ * then holds `canPrompt` at `true`.
14
+ *
15
+ * @internal
16
+ */
17
+ const capabilitiesLayer = Layer.effect(TerminalCapabilities.TerminalCapabilities, Effect.gen(function* () {
18
+ const terminal = yield* TerminalEnv;
19
+ const term = yield* Config.option(Config.String("TERM")).pipe(Effect.orElseSucceed(() => Option.none()));
20
+ return TerminalEnv.capabilities(terminal, Option.getOrUndefined(term));
21
+ }));
22
+
23
+ export { capabilitiesLayer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/cli",
3
- "version": "0.22.2",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "description": "The presentation boundary of an effect/cli program: audience-aware output, a document IR and renderers, editor links, failure reports and logging, plus opt-in Ink screens, widgets and a live view",
6
6
  "keywords": [
@@ -57,13 +57,15 @@
57
57
  "./package.json": "./package.json"
58
58
  },
59
59
  "dependencies": {
60
- "@effected/github-commands": "^0.6.2"
60
+ "@effected/commands": "^0.18.0",
61
+ "@effected/engine": "^0.10.0",
62
+ "@effected/github-commands": "^0.7.0"
61
63
  },
62
64
  "peerDependencies": {
63
- "@effected/config-file": "^0.20.1",
64
- "@effected/env": "^0.5.0",
65
- "@effected/glob": "^0.14.0",
66
- "@effected/walker": "^0.20.1",
65
+ "@effected/config-file": "^0.22.0",
66
+ "@effected/env": "^0.7.0",
67
+ "@effected/glob": "^0.16.0",
68
+ "@effected/walker": "^0.22.0",
67
69
  "@types/react": "^19.3.0",
68
70
  "effect": "^4.0.0",
69
71
  "ink": "^8.0.0",
package/testing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Effect, FileSystem, Layer, Path, PlatformError, Scope, Terminal } from "effect";
2
- import { ChildProcessSpawner } from "effect/process";
1
+ import { Duration, Effect, FileSystem, Layer, Path, PlatformError, Scope, Stream, Terminal } from "effect";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  /**
4
4
  * A hermetic temp directory minted by {@link CliTest.sandbox}, removed when its
5
5
  * scope closes.
@@ -39,6 +39,12 @@ interface RunOptions {
39
39
  * the child gets an already-ended empty input, never an open pipe.
40
40
  */
41
41
  readonly stdin?: string | undefined;
42
+ /**
43
+ * How long a child has to exit after `SIGTERM` (or the signal `kill` sends), when its scope closes or
44
+ * {@link SpawnedBin.kill} is called, before it is sent `SIGKILL`; `2 seconds` by default. A finite duration: a
45
+ * child that ignores the signal would otherwise keep the scope from closing forever, and leave the process behind.
46
+ */
47
+ readonly forceKillAfter?: Duration.Input | undefined;
42
48
  }
43
49
  /**
44
50
  * What a spawned bin did, as data: a non-zero exit is a result, not a failure.
@@ -46,13 +52,46 @@ interface RunOptions {
46
52
  * @public
47
53
  */
48
54
  interface RunResult {
49
- /** The child's exit code. */
50
- readonly exitCode: number;
55
+ /** The child's exit code, or `null` when a signal ended it. */
56
+ readonly exitCode: number | null;
57
+ /** The signal that ended the child, or `null` when it exited with a code. */
58
+ readonly signal: ChildProcess.Signal | null;
51
59
  /** Everything the child wrote to stdout, decoded as UTF-8. */
52
60
  readonly stdout: string;
53
61
  /** Everything the child wrote to stderr, decoded as UTF-8. */
54
62
  readonly stderr: string;
55
63
  }
64
+ /**
65
+ * A bin {@link CliTest.spawn} started and has not waited for: its output as it arrives, a way to signal it, and its
66
+ * exit.
67
+ *
68
+ * @public
69
+ */
70
+ interface SpawnedBin {
71
+ /**
72
+ * The child's stdout as lines, each without its line break (`\r\n` included), from the first byte it wrote,
73
+ * ending when the child closes stdout; a last line with no line break comes when it does. Every subscription replays
74
+ * from the start, so a test may read it more than once, and output a test never reads is still drained, so the
75
+ * child never blocks on a full pipe.
76
+ */
77
+ readonly stdout: Stream.Stream<string>;
78
+ /** The child's stderr as lines, as `stdout` gives stdout. */
79
+ readonly stderr: Stream.Stream<string>;
80
+ /**
81
+ * Send `signal` (`SIGTERM` by default) and wait for the child to exit. A child still running after
82
+ * {@link RunOptions.forceKillAfter} is sent `SIGKILL`, so this always ends. A child that has already exited, on its
83
+ * own or at the same moment, is not an error: this returns normally, and `exitCode` and `result` keep reporting how
84
+ * it ended. A kill that fails on a child still running fails with that `PlatformError`.
85
+ */
86
+ readonly kill: (signal?: ChildProcess.Signal) => Effect.Effect<void, PlatformError.PlatformError>;
87
+ /**
88
+ * The child's exit code once it exits, or `null` when a signal ended it (the default `kill` included): `result`
89
+ * names the signal. Re-reading it after the exit answers at once.
90
+ */
91
+ readonly exitCode: Effect.Effect<number | null, PlatformError.PlatformError>;
92
+ /** Wait for the exit and the end of both streams, and read everything, as {@link CliTest.run} returns it. */
93
+ readonly result: Effect.Effect<RunResult, PlatformError.PlatformError>;
94
+ }
56
95
  /**
57
96
  * Spawn a built CLI bin hermetically and read its exit code and streams as data.
58
97
  *
@@ -103,6 +142,30 @@ export declare class CliTest {
103
142
  * ends it.
104
143
  */
105
144
  static readonly run: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<RunResult, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
145
+ /**
146
+ * Start `execPath bin ...args` and return at once with a handle on it: its output as lines while it runs, a signal,
147
+ * and its exit, for a long-running command (a `--follow`, a watch mode, a server) that {@link CliTest.run} would wait
148
+ * on forever.
149
+ *
150
+ * @remarks
151
+ * The child lives in the caller's scope: when the scope closes first, the child is sent `SIGTERM`, then `SIGKILL`
152
+ * after `forceKillAfter` (`2 seconds` by default), so a test that ends early leaves nothing running and never waits
153
+ * 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
154
+ * chatty child never blocks on a full pipe, and each stream replays from its first line to every reader. The
155
+ * options are {@link CliTest.run}'s, `stdin` included: the child gets that text, then an ended input.
156
+ *
157
+ * ```ts
158
+ * const child = yield* CliTest.spawn("dist/bin.js", ["logs", "--follow"], { sandbox, execPath: process.execPath })
159
+ * yield* Stream.runHead(child.stdout) // the first line: it is following
160
+ * yield* child.kill("SIGINT")
161
+ * assert.strictEqual(yield* child.exitCode, 0)
162
+ * ```
163
+ *
164
+ * @param bin - the built bin to run
165
+ * @param args - its arguments
166
+ * @param options - the sandbox, the node binary, and the working directory, environment and stdin
167
+ */
168
+ static readonly spawn: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<SpawnedBin, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope>;
106
169
  }
107
170
  /**
108
171
  * One key press for {@link TestTerminal}.
@@ -172,5 +235,5 @@ export declare class TestTerminal {
172
235
  readonly rows?: number | undefined;
173
236
  }) => Effect.Effect<TestTerminalHandle>;
174
237
  }
175
- export type { KeyInput, RunOptions, RunResult, Sandbox, TestTerminalHandle };
238
+ export type { KeyInput, RunOptions, RunResult, Sandbox, SpawnedBin, TestTerminalHandle };
176
239
  //# sourceMappingURL=testing.d.ts.map