@effected/kit 0.0.0-stage → 0.13.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.
@@ -0,0 +1,90 @@
1
+ import { Context, Effect, Option, Schema } from "effect";
2
+
3
+ // src/Distribution.ts
4
+ /**
5
+ * The carrier package a tool's bins were installed through.
6
+ *
7
+ * @remarks
8
+ * A carrier (`@scope/plugin`) re-exposes its front ends' bins and passes its
9
+ * own identity down to each front end's `main`. A plain struct rather than a
10
+ * `Schema.Class`, because it travels in JSON envelopes as a plain object and
11
+ * is compared structurally.
12
+ *
13
+ * @public
14
+ */
15
+ const Distribution = Schema.Struct({
16
+ name: Schema.String,
17
+ version: Schema.String
18
+ });
19
+ /**
20
+ * The `distribution` field of a machine-readable envelope: `null` when the
21
+ * front end was installed directly rather than through a carrier.
22
+ *
23
+ * @public
24
+ */
25
+ const DistributionField = Schema.NullOr(Distribution);
26
+ /**
27
+ * The carrier this run was installed through, read anywhere without
28
+ * appearing in `R`.
29
+ *
30
+ * @remarks
31
+ * A `Context.Reference`, not a `Context.Service`: it carries its own default
32
+ * (`Option.none()`), so a direct install needs no provision at all. A front
33
+ * end's `main` provides it once, at the top of the program, with
34
+ * `Effect.provideService(CurrentDistribution, Option.fromNullishOr(options.distribution))`.
35
+ *
36
+ * @public
37
+ */
38
+ const CurrentDistribution = Context.Reference("@effected/kit/CurrentDistribution", { defaultValue: () => Option.none() });
39
+ /**
40
+ * The `via <name> <version>` suffix (prefixed with a space) a `--version`
41
+ * line or a startup log line appends, or `""` for a direct install.
42
+ *
43
+ * @public
44
+ */
45
+ const distributionSuffix = (distribution) => Option.match(distribution, {
46
+ onNone: () => "",
47
+ onSome: ({ name, version }) => ` via ${name} ${version}`
48
+ });
49
+ /**
50
+ * Decorates a `--version` line formatter so its line ends with
51
+ * {@link distributionSuffix}: `formatVersion(name, version)` followed by
52
+ * a space and `via <carrier> <version>` when installed through a carrier,
53
+ * unchanged for a direct install.
54
+ *
55
+ * @remarks
56
+ * Shaped around `effect/cli`'s `CliOutput.Formatter["formatVersion"]`: it
57
+ * wraps whatever formatter is in effect (a coloured default or a custom one)
58
+ * rather than replacing it. The suffix is computed once, when the decorator is
59
+ * applied, because the distribution is known at `main`. Data-last, for `pipe`.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { withDistributionVersion } from "@effected/kit";
64
+ * import { Option } from "effect";
65
+ *
66
+ * const formatVersion = withDistributionVersion(Option.some({ name: "@scope/plugin", version: "2.0.0" }))(
67
+ * (name, version) => `${name} ${version}`,
68
+ * );
69
+ * formatVersion("tool", "1.0.0"); // "tool 1.0.0 via @scope/plugin 2.0.0"
70
+ * ```
71
+ *
72
+ * @public
73
+ */
74
+ const withDistributionVersion = (distribution) => {
75
+ const suffix = distributionSuffix(distribution);
76
+ return (formatVersion) => (name, version) => `${formatVersion(name, version)}${suffix}`;
77
+ };
78
+ /**
79
+ * Provides {@link CurrentDistribution} to `self`: `Option.some` of a carrier,
80
+ * `Option.none()` for `null` or `undefined` (a direct install).
81
+ *
82
+ * @remarks
83
+ * The one-line form of what a front end's `main` does once at the top of its
84
+ * program. Data-last, for `pipe`.
85
+ *
86
+ * @public
87
+ */
88
+ const withDistribution = (distribution) => (self) => Effect.provideService(self, CurrentDistribution, Option.fromNullishOr(distribution));
89
+
90
+ export { CurrentDistribution, Distribution, DistributionField, distributionSuffix, withDistribution, withDistributionVersion };
package/ErrorText.js ADDED
@@ -0,0 +1,98 @@
1
+ import { causeMessage } from "./internal/causeMessage.js";
2
+ import { Predicate } from "effect";
3
+
4
+ // src/ErrorText.ts
5
+ const describe = (value) => {
6
+ if (Predicate.isString(value)) return value;
7
+ if (value === void 0 || value === null) return String(value);
8
+ if (typeof value !== "object" && typeof value !== "function") return String(value);
9
+ try {
10
+ if (Predicate.hasProperty(value, "message") && Predicate.isString(value.message)) return value.message;
11
+ } catch {}
12
+ try {
13
+ const json = JSON.stringify(value);
14
+ if (json !== void 0) return json;
15
+ } catch {}
16
+ try {
17
+ return String(value);
18
+ } catch {
19
+ return "[unprintable value]";
20
+ }
21
+ };
22
+ /**
23
+ * Any value as error text, without ever throwing: the one implementation every front end of a tool uses, so a CLI and
24
+ * an MCP server describe the same failure with the same words.
25
+ *
26
+ * @remarks
27
+ * Every member is pure and total. None sanitises its result: text read from a value you do not control may carry
28
+ * escape sequences, so sanitise it before printing it to a terminal. `@effected/cli-kit` publishes the same functions as
29
+ * `Fmt.describe`, `Fmt.causeMessage` and `Fmt.errorText`, which delegate here.
30
+ *
31
+ * The anti-pattern this replaces is the per-site fallback `error instanceof Error ? error.message : String(error)`:
32
+ * it misses an error from another realm and a plain `{ message }` object, reads nothing along the `cause` chain, and
33
+ * throws on a value whose `toString` throws or which has no prototype.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { ErrorText } from "@effected/kit"
38
+ *
39
+ * const failure = new Error("Failed to execute statement", { cause: new Error("SQLITE_BUSY: database is locked") })
40
+ * console.log(ErrorText.of(failure))
41
+ * // => "Failed to execute statement: SQLITE_BUSY: database is locked"
42
+ * console.log(ErrorText.of({ code: 7 }))
43
+ * // => "{\"code\":7}"
44
+ * ```
45
+ *
46
+ * @public
47
+ */
48
+ var ErrorText = class {
49
+ constructor() {}
50
+ /**
51
+ * Any value as text, without ever throwing.
52
+ *
53
+ * @remarks
54
+ * A string is itself; a number, boolean, bigint or symbol is `String(value)`; `undefined` and `null` are
55
+ * `"undefined"` and `"null"`. A value with a string `message` (an `Error`, one from another realm, a plain
56
+ * `{ message }`) is that message, even an empty one. Anything else is its JSON, or `String(value)` when it has none
57
+ * (a cycle, a function), or `"[unprintable value]"` when that throws too. A getter that throws is a value with no
58
+ * message.
59
+ *
60
+ * For the messages along an error's `cause` chain, use {@link ErrorText.causeMessage}; for the two combined, use
61
+ * {@link ErrorText.of}.
62
+ *
63
+ * @param value - the value
64
+ */
65
+ static describe = describe;
66
+ /**
67
+ * Every distinct non-empty message along a value's `cause` chain, nearest first and joined with `: `, or
68
+ * `undefined` when nothing along it has one.
69
+ *
70
+ * @remarks
71
+ * The kit's one guarded cause-message read, the one its own errors render a preserved cause with. A node's message
72
+ * is a thrown string or a non-empty string `message` property, read without `instanceof Error`, so an error from
73
+ * another realm or a plain `{ message }` object counts. A deeper message the joined text already contains is not
74
+ * repeated, and one that contains the joined text replaces it, so a `SqlError` wrapping SQLite's own text reads
75
+ * `Failed to execute statement: SQLITE_BUSY: database is locked`. It is the whole chain, not the innermost message
76
+ * alone. The walk stops after eight nodes, so a cyclic chain terminates.
77
+ *
78
+ * Never throws: a `message` getter that throws leaves that node without text, and a `cause` getter that throws ends
79
+ * the walk with the text found so far, `undefined` when there is none.
80
+ *
81
+ * @param value - the error, or whatever was thrown
82
+ */
83
+ static causeMessage = causeMessage;
84
+ /**
85
+ * The text of an unknown failure: {@link ErrorText.causeMessage} when the chain carries a message, otherwise
86
+ * {@link ErrorText.describe}.
87
+ *
88
+ * @remarks
89
+ * Exactly `ErrorText.causeMessage(value) ?? ErrorText.describe(value)`, the fallback every I/O site would otherwise
90
+ * spell out by hand. An error whose message is empty and whose chain carries none is `""`, as `describe` reads it.
91
+ * Never throws.
92
+ *
93
+ * @param value - the error, or whatever was thrown
94
+ */
95
+ static of = (value) => causeMessage(value) ?? describe(value);
96
+ };
97
+
98
+ export { ErrorText };
package/Exclusive.js ADDED
@@ -0,0 +1,141 @@
1
+ import { Duration, Effect, Schema, Semaphore } from "effect";
2
+
3
+ // src/Exclusive.ts
4
+ /**
5
+ * The body of an {@link Exclusive.run} call ran past its timeout and was
6
+ * interrupted. Its finalizers had run, and the permit was released, by the
7
+ * time the caller sees this.
8
+ *
9
+ * @remarks
10
+ * Audience: calling code, which usually folds it into its own outcome (a tool
11
+ * result's `{ kind: "timeout" }`). `timeout` is the formatted duration.
12
+ *
13
+ * @public
14
+ */
15
+ var ExclusiveTimeout = class extends Schema.TaggedError()("ExclusiveTimeout", {
16
+ timeout: Schema.String,
17
+ message: Schema.String
18
+ }) {};
19
+ /**
20
+ * A one-permit lane for work that mutates process-global state (the working
21
+ * directory, the environment, a patched stream), so two requests never
22
+ * interleave it and the state is restored before the next one starts.
23
+ *
24
+ * @remarks
25
+ * Ordering, on every exit path:
26
+ *
27
+ * 1. Waiting for the permit is interruptible: a request cancelled while it is
28
+ * queued leaves the queue and never runs.
29
+ * 2. Once the permit is held, the caller's interruption is masked until the
30
+ * body ends. A transport that maps a client cancel to an interrupt (an MCP
31
+ * server does) only drops the response; the next request still waits for
32
+ * the real work to finish.
33
+ * 3. The required timeout interrupts the body, and only the body.
34
+ * 4. The body runs in its own `Scope`: its finalizers (restoring the working
35
+ * directory or the environment with {@link ProcessScope}, removing a temp
36
+ * directory) run, then the permit is released, then the caller sees the
37
+ * result, an {@link ExclusiveTimeout}, or its own deferred interruption.
38
+ *
39
+ * A body that ignores interruption delays its timeout and the permit release
40
+ * until it settles, as an uninterruptible region does. That is
41
+ * deliberate, because releasing the permit while process state is still being
42
+ * changed is the bug this lane exists to prevent. Run promise work through
43
+ * {@link Exclusive.promise}, whose interruption aborts the signal and then
44
+ * waits for the promise to settle; `Effect.promise` would abandon it. A
45
+ * promise that ignores its signal and never settles therefore hangs the
46
+ * timeout and the permit forever: give it `abandon: true`.
47
+ *
48
+ * **Not reentrant.** A `run` on the same lane from inside a body waits for the
49
+ * permit its own caller holds. It never gets it: the nested call waits until
50
+ * the outer timeout fires, which interrupts the body (and the nested wait with
51
+ * it), so the outer `run` fails with {@link ExclusiveTimeout}. Call the inner
52
+ * work directly instead of through the lane again.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { Exclusive, ProcessScope } from "@effected/kit"
57
+ * import { Effect } from "effect"
58
+ *
59
+ * const lane = yield* Exclusive.make
60
+ * const result = yield* lane.run(
61
+ * Effect.gen(function* () {
62
+ * yield* ProcessScope.chdir(process, root)
63
+ * return yield* Exclusive.promise({ try: (signal) => runTool(signal), catch: (cause) => new ToolFailed({ cause }) })
64
+ * }),
65
+ * { timeout: "2 minutes" },
66
+ * )
67
+ * ```
68
+ *
69
+ * @public
70
+ */
71
+ var Exclusive = class Exclusive {
72
+ semaphore;
73
+ constructor(semaphore) {
74
+ this.semaphore = semaphore;
75
+ }
76
+ /** A new lane with one permit. Bind it once (a module-level or layer-built value); every call shares it. */
77
+ static make = Effect.map(Semaphore.make(1), (semaphore) => new Exclusive(semaphore));
78
+ /**
79
+ * A promise whose interruption aborts `signal` and then waits for the
80
+ * promise to settle, rather than abandoning it as `Effect.promise` and
81
+ * `Effect.tryPromise` do. A rejection, or a synchronous throw from `try`,
82
+ * fails through `catch`; a `catch` that itself throws dies with what it
83
+ * threw.
84
+ *
85
+ * @remarks
86
+ * **The default hangs on a promise that ignores its signal.** Waiting is
87
+ * the point: the interrupt (a lane timeout, a cancel) returns only once the
88
+ * promise has settled, so no work is still changing process state when the
89
+ * permit is released. But a promise that never observes `signal` and never
90
+ * settles (a test runner's `start`, a third-party call with no
91
+ * cancellation) holds the interrupt, the lane's timeout and the permit
92
+ * forever.
93
+ *
94
+ * Pass `abandon: true` for such a promise. Interruption still aborts
95
+ * `signal`, then resumes at once without waiting; the promise keeps running
96
+ * unobserved and its eventual result is dropped. Inside {@link Exclusive.run}
97
+ * the body's scope still closes, and its finalizers still run, before the
98
+ * permit is released. What abandoning gives up is the guarantee that the
99
+ * promise itself has stopped: choose it only when the promise cannot be
100
+ * stopped, and restore any state it touches in a finalizer.
101
+ */
102
+ static promise = (options) => Effect.callback((resume, signal) => {
103
+ const failure = (cause) => {
104
+ try {
105
+ return Effect.fail(options.catch(cause));
106
+ } catch (error) {
107
+ return Effect.die(error);
108
+ }
109
+ };
110
+ let settled;
111
+ try {
112
+ settled = Promise.resolve(options.try(signal));
113
+ } catch (cause) {
114
+ resume(failure(cause));
115
+ return;
116
+ }
117
+ settled.then((value) => resume(Effect.succeed(value)), (cause) => resume(failure(cause)));
118
+ if (options.abandon === true) return;
119
+ return Effect.promise(() => settled.then(() => void 0, () => void 0));
120
+ });
121
+ /**
122
+ * Runs `body` holding this lane's permit, under the ordering in the class
123
+ * remarks. `body` gets its own `Scope`, closed before the permit is released.
124
+ */
125
+ run = (body, options) => {
126
+ const timeout = Duration.fromInputUnsafe(options.timeout);
127
+ const lane = Effect.interruptible(Effect.scoped(body)).pipe(Effect.timeoutOrElse({
128
+ duration: timeout,
129
+ orElse: () => {
130
+ const formatted = Duration.format(timeout);
131
+ return Effect.fail(new ExclusiveTimeout({
132
+ timeout: formatted,
133
+ message: `Timed out after ${formatted}.`
134
+ }));
135
+ }
136
+ }));
137
+ return this.semaphore.withPermit(Effect.uninterruptible(lane)).pipe(Effect.withSpan("Exclusive.run"));
138
+ };
139
+ };
140
+
141
+ export { Exclusive, ExclusiveTimeout };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ import { Option } from "effect";
2
+
3
+ // src/LaunchContext.ts
4
+ /**
5
+ * Resolves where a tool launched by an agent host should treat as its project.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { LaunchContext } from "@effected/kit"
10
+ *
11
+ * const dir = LaunchContext.projectDir({
12
+ * argv: [],
13
+ * env: { CLAUDE_PROJECT_DIR: "/work/app" },
14
+ * keys: ["MYTOOL_PROJECT_DIR", "CLAUDE_PROJECT_DIR"],
15
+ * cwd: "/home/me",
16
+ * })
17
+ * // => "/work/app"
18
+ * ```
19
+ *
20
+ * @public
21
+ */
22
+ var LaunchContext = class LaunchContext {
23
+ constructor() {}
24
+ /**
25
+ * Whether a value still carries a literal `${VAR}` placeholder.
26
+ *
27
+ * @remarks
28
+ * An agent host can pass `${CLAUDE_PROJECT_DIR}` through unsubstituted in
29
+ * some launch paths; a path containing a placeholder is never what was meant.
30
+ */
31
+ static isUnsubstituted = (value) => {
32
+ const open = value.indexOf("${");
33
+ return open !== -1 && value.indexOf("}", open + 2) !== -1;
34
+ };
35
+ /**
36
+ * `value` trimmed, when it names something: `Option.none()` for `undefined`,
37
+ * an empty or whitespace-only string, or a string still carrying a literal
38
+ * `${VAR}` placeholder ({@link LaunchContext.isUnsubstituted}).
39
+ *
40
+ * @remarks
41
+ * The guard every resolver here applies; use it directly for one positional
42
+ * argument, as in `LaunchContext.usable(argv[2])`. `??` would accept an empty
43
+ * string or a literal `${VAR}`, neither of which names a directory or an id.
44
+ */
45
+ static usable = (value) => {
46
+ if (value === void 0) return Option.none();
47
+ const trimmed = value.trim();
48
+ return trimmed === "" || LaunchContext.isUnsubstituted(trimmed) ? Option.none() : Option.some(trimmed);
49
+ };
50
+ /**
51
+ * The first {@link LaunchContext.usable} `argv` value, then the first usable
52
+ * `env` value in `keys` order; `Option.none()` when none is usable.
53
+ *
54
+ * @remarks
55
+ * The resolver under {@link LaunchContext.projectDir}, without its `cwd`
56
+ * fallback, for any launch-supplied location: a host's plugin data
57
+ * directory, then an app override, with the caller's own fallback after
58
+ * it. Values are trimmed, so a value with surrounding whitespace resolves to
59
+ * the trimmed path.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { LaunchContext } from "@effected/kit"
64
+ * import { Option } from "effect"
65
+ *
66
+ * const dataDir = LaunchContext.resolve({ env, keys: ["CLAUDE_PLUGIN_DATA", "MYTOOL_DATA_DIR"] }).pipe(
67
+ * Option.getOrElse(() => `${home}/.mytool`),
68
+ * )
69
+ * ```
70
+ */
71
+ static resolve = (input) => {
72
+ for (const value of input.argv ?? []) {
73
+ const found = LaunchContext.usable(value);
74
+ if (Option.isSome(found)) return found;
75
+ }
76
+ for (const key of input.keys) {
77
+ const found = LaunchContext.usable(input.env[key]);
78
+ if (Option.isSome(found)) return found;
79
+ }
80
+ return Option.none();
81
+ };
82
+ /**
83
+ * The first usable argv value, then the first usable env value in `keys`
84
+ * order, then `cwd`: {@link LaunchContext.resolve} with `cwd` as the
85
+ * fallback.
86
+ */
87
+ static projectDir = (input) => Option.getOrElse(LaunchContext.resolve(input), () => input.cwd);
88
+ };
89
+
90
+ export { LaunchContext };
@@ -0,0 +1,90 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+
3
+ // src/PathContainment.ts
4
+ /** Link hops followed before a candidate is judged outside (a loop never settles). */
5
+ const MAX_LINK_HOPS = 40;
6
+ /** Whether a platform failure means "nothing is there", the only failure read as absent. */
7
+ const isNotFound = (error) => error.reason?._tag === "NotFound";
8
+ /**
9
+ * Decides whether a caller-supplied path stays inside a root.
10
+ *
11
+ * @remarks
12
+ * Audience: a handler that must refuse a path outside its workspace before
13
+ * reading or writing it. Neither form fails: "outside" is the answer `false`,
14
+ * and the filesystem is required in `R` from core, never owned here. A
15
+ * relative `candidate` is resolved against `root`, never the process working
16
+ * directory.
17
+ *
18
+ * {@link PathContainment.isWithinLexical} is pure and compares spellings, so a
19
+ * link inside the root that points out is invisible to it. Use
20
+ * {@link PathContainment.isWithin} for anything that will touch the disk.
21
+ *
22
+ * @public
23
+ */
24
+ var PathContainment = class PathContainment {
25
+ constructor() {}
26
+ /**
27
+ * Whether `candidate` is `root` or lies under it, comparing resolved
28
+ * spellings only (`..` segments and trailing separators are normalised;
29
+ * links are not followed). A sibling sharing a prefix (`/repo-other` against
30
+ * `/repo`) is outside. A relative `candidate` is resolved against `root`.
31
+ */
32
+ static isWithinLexical(path, root, candidate) {
33
+ const base = path.resolve(root);
34
+ const target = path.resolve(base, candidate);
35
+ if (target === base) return true;
36
+ const relative = path.relative(base, target);
37
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
38
+ }
39
+ /**
40
+ * Whether `candidate` is `root` or lies under it once links are resolved.
41
+ *
42
+ * @remarks
43
+ * The candidate need not exist: its nearest existing ancestor is resolved
44
+ * with `realPath` and the missing tail appended, so a path about to be
45
+ * created is judged by where its parent really is. A component that is a
46
+ * dangling link is followed to where it points (a link out of the root is
47
+ * outside even when its target is absent), up to a fixed number of hops;
48
+ * a loop is outside.
49
+ *
50
+ * Fails closed: only a `NotFound` counts as absent. Any other failure on a
51
+ * candidate component (permission denied, a link loop, a non-directory
52
+ * parent) answers `false`. The lexical answer stands in only when the root's
53
+ * own real path is `NotFound`; any other failure reading it is `false`.
54
+ *
55
+ * A point-in-time check: the filesystem can change between this answer and
56
+ * the read or write it guards.
57
+ */
58
+ static isWithin = (root, candidate) => Effect.gen(function* () {
59
+ const fs = yield* FileSystem.FileSystem;
60
+ const path = yield* Path.Path;
61
+ const base = path.resolve(root);
62
+ const realRoot = yield* Effect.result(fs.realPath(base));
63
+ if (realRoot._tag === "Failure") return isNotFound(realRoot.failure) && PathContainment.isWithinLexical(path, root, candidate);
64
+ let existing = path.resolve(base, candidate);
65
+ let tail = [];
66
+ let hops = 0;
67
+ for (;;) {
68
+ const real = yield* Effect.result(fs.realPath(existing));
69
+ if (real._tag === "Success") return PathContainment.isWithinLexical(path, realRoot.success, path.join(real.success, ...tail));
70
+ if (!isNotFound(real.failure)) return false;
71
+ const link = yield* Effect.result(fs.readLink(existing));
72
+ if (link._tag === "Success") {
73
+ hops += 1;
74
+ if (hops > MAX_LINK_HOPS) return false;
75
+ const parent = yield* Effect.result(fs.realPath(path.dirname(existing)));
76
+ if (parent._tag === "Failure") return false;
77
+ existing = path.resolve(parent.success, link.success, ...tail);
78
+ tail = [];
79
+ continue;
80
+ }
81
+ if (!isNotFound(link.failure)) return false;
82
+ const parent = path.dirname(existing);
83
+ if (parent === existing) return false;
84
+ tail = [path.basename(existing), ...tail];
85
+ existing = parent;
86
+ }
87
+ });
88
+ };
89
+
90
+ export { PathContainment };
@@ -0,0 +1,144 @@
1
+ // src/ProcessGuard.ts
2
+ const isInjectedKind = (kind) => kind === "uncaughtException" || kind === "unhandledRejection";
3
+ const INJECT_AT = ["load", "connected"];
4
+ const INJECT_KIND = ["uncaughtException", "unhandledRejection"];
5
+ /** Emit one injected crash through the host, to the listeners the guard installed there. */
6
+ const emitInjected = (host, kind) => {
7
+ const error = /* @__PURE__ */ new Error(`[injected] ${kind}`);
8
+ if (kind === "uncaughtException") {
9
+ host.emit("uncaughtException", error, "uncaughtException");
10
+ return;
11
+ }
12
+ const promise = Promise.reject(error);
13
+ promise.catch(() => void 0);
14
+ host.emit("unhandledRejection", error, promise);
15
+ };
16
+ const fallbackFormat = (error) => error instanceof Error ? error.stack ?? error.message : String(error);
17
+ /**
18
+ * Transport-neutral crash guards for a server process, installed before the
19
+ * server's module graph loads. Imported from `@effected/kit/guard`.
20
+ *
21
+ * @remarks
22
+ * {@link ProcessGuard.run} registers `uncaughtException` and
23
+ * `unhandledRejection` listeners on `host`, then awaits `load`. It launches
24
+ * nothing itself: `load` starts the server, over whatever transport, and
25
+ * calls `markConnected` once it is serving. `@effected/mcp-kit/guard`'s
26
+ * `McpGuard.run` is this guard with an MCP stdio launch in its `load`.
27
+ *
28
+ * - This entrypoint has no runtime import at all, so a throw while the
29
+ * server graph evaluates is still reported on stderr.
30
+ * - A `load` that rejects is reported as `startup failed` and exits 1
31
+ * whatever the policy. Left to a log-only rejection listener, it would let
32
+ * the event loop drain and exit 0 with no server.
33
+ * - Installing an `unhandledRejection` listener switches off Node's default
34
+ * of throwing on one, so `"log"` really does keep the process running.
35
+ * - An exit from a guard skips every finalizer the server registered.
36
+ * - Every report is one line: `<label>: uncaughtException (<origin>): …`,
37
+ * `<label>: unhandledRejection: …` or `<label>: startup failed: …`.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { ProcessGuard } from "@effected/kit/guard";
42
+ *
43
+ * await ProcessGuard.run({
44
+ * label: "my-lsp",
45
+ * host: process,
46
+ * policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
47
+ * load: async (guard) => {
48
+ * const { startServer } = await import("./server.js");
49
+ * await startServer();
50
+ * guard.markConnected();
51
+ * },
52
+ * });
53
+ * ```
54
+ *
55
+ * @public
56
+ */
57
+ var ProcessGuard = class {
58
+ constructor() {}
59
+ /**
60
+ * Parse a test-only crash-injection setting into {@link ProcessGuardOptions.injectCrash}: `<at>:<kind>`, where `at`
61
+ * is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
62
+ * value, is `undefined` (no injection), so a launcher can pass an environment variable straight through and every
63
+ * launcher shares one grammar.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * ProcessGuard.parseInjectCrash("connected:unhandledRejection"); // => { at: "connected", kind: "unhandledRejection" }
68
+ * ProcessGuard.parseInjectCrash("later:boom"); // => undefined
69
+ * ```
70
+ */
71
+ static parseInjectCrash = (value) => {
72
+ if (value === void 0) return void 0;
73
+ const [at, kind, ...rest] = value.split(":");
74
+ if (rest.length > 0) return void 0;
75
+ const validAt = INJECT_AT.find((candidate) => candidate === at);
76
+ const validKind = INJECT_KIND.find((candidate) => candidate === kind);
77
+ return validAt === void 0 || validKind === void 0 ? void 0 : {
78
+ at: validAt,
79
+ kind: validKind
80
+ };
81
+ };
82
+ /** Install the guards, then run `load`. Resolves once `load` has resolved. */
83
+ static run = async (options) => {
84
+ const { host, label } = options;
85
+ const onUncaught = options.policy?.onUncaught ?? "exit";
86
+ const onRejection = options.policy?.onRejection ?? "exit";
87
+ let connected = false;
88
+ let format = fallbackFormat;
89
+ const describe = (error) => {
90
+ try {
91
+ return format(error);
92
+ } catch {
93
+ try {
94
+ return fallbackFormat(error);
95
+ } catch {
96
+ return "<unformattable error value>";
97
+ }
98
+ }
99
+ };
100
+ const exits = (mode) => mode === "exit" || mode === "exitBeforeConnect" && !connected;
101
+ host.on("uncaughtException", (error, origin) => {
102
+ host.stderr.write(`${label}: uncaughtException (${origin}): ${describe(error)}\n`);
103
+ if (exits(onUncaught)) host.exit(1);
104
+ });
105
+ host.on("unhandledRejection", (reason) => {
106
+ host.stderr.write(`${label}: unhandledRejection: ${describe(reason)}\n`);
107
+ if (exits(onRejection)) host.exit(1);
108
+ });
109
+ const inject = options.injectCrash;
110
+ const injectKind = isInjectedKind(inject?.kind) ? inject.kind : void 0;
111
+ if (inject?.at === "load" && injectKind !== void 0) await new Promise((resolve, reject) => {
112
+ setTimeout(() => {
113
+ try {
114
+ emitInjected(host, injectKind);
115
+ resolve();
116
+ } catch (error) {
117
+ reject(error);
118
+ }
119
+ }, 0);
120
+ });
121
+ const control = {
122
+ markConnected: () => {
123
+ if (connected) return;
124
+ connected = true;
125
+ if (inject?.at === "connected" && injectKind !== void 0) setTimeout(() => {
126
+ try {
127
+ emitInjected(host, injectKind);
128
+ } catch {}
129
+ }, 0);
130
+ },
131
+ useFormat: (next) => {
132
+ format = next;
133
+ }
134
+ };
135
+ try {
136
+ await options.load(control);
137
+ } catch (error) {
138
+ host.stderr.write(`${label}: startup failed: ${describe(error)}\n`);
139
+ host.exit(1);
140
+ }
141
+ };
142
+ };
143
+
144
+ export { ProcessGuard };