@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.
- package/Distribution.js +90 -0
- package/ErrorText.js +98 -0
- package/Exclusive.js +141 -0
- package/LICENSE +21 -0
- package/LaunchContext.js +90 -0
- package/PathContainment.js +90 -0
- package/ProcessGuard.js +144 -0
- package/ProcessScope.js +95 -0
- package/README.md +98 -2
- package/Remediation.js +33 -0
- package/guard.d.ts +205 -0
- package/guard.js +3 -0
- package/index.d.ts +555 -0
- package/index.js +9 -0
- package/internal/causeMessage.js +57 -0
- package/package.json +49 -5
- package/tsdoc-metadata.json +11 -0
package/Distribution.js
ADDED
|
@@ -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.
|
package/LaunchContext.js
ADDED
|
@@ -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 };
|
package/ProcessGuard.js
ADDED
|
@@ -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 };
|