@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/index.d.ts
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { Context, Duration, Effect, FileSystem, Option, Path, Schema, Scope } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* The carrier package a tool's bins were installed through.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* A carrier (`@scope/plugin`) re-exposes its front ends' bins and passes its
|
|
7
|
+
* own identity down to each front end's `main`. A plain struct rather than a
|
|
8
|
+
* `Schema.Class`, because it travels in JSON envelopes as a plain object and
|
|
9
|
+
* is compared structurally.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export declare const Distribution: Schema.Struct<{
|
|
14
|
+
readonly name: Schema.String;
|
|
15
|
+
readonly version: Schema.String;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* A decoded {@link (Distribution:variable)}.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export type Distribution = typeof Distribution.Type;
|
|
23
|
+
/**
|
|
24
|
+
* The `distribution` field of a machine-readable envelope: `null` when the
|
|
25
|
+
* front end was installed directly rather than through a carrier.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
export declare const DistributionField: Schema.NullOr<Schema.Struct<{
|
|
30
|
+
readonly name: Schema.String;
|
|
31
|
+
readonly version: Schema.String;
|
|
32
|
+
}>>;
|
|
33
|
+
/**
|
|
34
|
+
* The carrier this run was installed through, read anywhere without
|
|
35
|
+
* appearing in `R`.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* A `Context.Reference`, not a `Context.Service`: it carries its own default
|
|
39
|
+
* (`Option.none()`), so a direct install needs no provision at all. A front
|
|
40
|
+
* end's `main` provides it once, at the top of the program, with
|
|
41
|
+
* `Effect.provideService(CurrentDistribution, Option.fromNullishOr(options.distribution))`.
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
export declare const CurrentDistribution: Context.Reference<Option.Option<Distribution>>;
|
|
46
|
+
/**
|
|
47
|
+
* The `via <name> <version>` suffix (prefixed with a space) a `--version`
|
|
48
|
+
* line or a startup log line appends, or `""` for a direct install.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
export declare const distributionSuffix: (distribution: Option.Option<Distribution>) => string;
|
|
53
|
+
/**
|
|
54
|
+
* Decorates a `--version` line formatter so its line ends with
|
|
55
|
+
* {@link distributionSuffix}: `formatVersion(name, version)` followed by
|
|
56
|
+
* a space and `via <carrier> <version>` when installed through a carrier,
|
|
57
|
+
* unchanged for a direct install.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* Shaped around `effect/cli`'s `CliOutput.Formatter["formatVersion"]`: it
|
|
61
|
+
* wraps whatever formatter is in effect (a coloured default or a custom one)
|
|
62
|
+
* rather than replacing it. The suffix is computed once, when the decorator is
|
|
63
|
+
* applied, because the distribution is known at `main`. Data-last, for `pipe`.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* import { withDistributionVersion } from "@effected/kit";
|
|
68
|
+
* import { Option } from "effect";
|
|
69
|
+
*
|
|
70
|
+
* const formatVersion = withDistributionVersion(Option.some({ name: "@scope/plugin", version: "2.0.0" }))(
|
|
71
|
+
* (name, version) => `${name} ${version}`,
|
|
72
|
+
* );
|
|
73
|
+
* formatVersion("tool", "1.0.0"); // "tool 1.0.0 via @scope/plugin 2.0.0"
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
export declare const withDistributionVersion: (distribution: Option.Option<Distribution>) => ((formatVersion: (name: string, version: string) => string) => (name: string, version: string) => string);
|
|
79
|
+
/**
|
|
80
|
+
* Provides {@link CurrentDistribution} to `self`: `Option.some` of a carrier,
|
|
81
|
+
* `Option.none()` for `null` or `undefined` (a direct install).
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* The one-line form of what a front end's `main` does once at the top of its
|
|
85
|
+
* program. Data-last, for `pipe`.
|
|
86
|
+
*
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
export declare const withDistribution: (distribution: Distribution | null | undefined) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
90
|
+
/**
|
|
91
|
+
* Any value as error text, without ever throwing: the one implementation every front end of a tool uses, so a CLI and
|
|
92
|
+
* an MCP server describe the same failure with the same words.
|
|
93
|
+
*
|
|
94
|
+
* @remarks
|
|
95
|
+
* Every member is pure and total. None sanitises its result: text read from a value you do not control may carry
|
|
96
|
+
* escape sequences, so sanitise it before printing it to a terminal. `@effected/cli-kit` publishes the same functions as
|
|
97
|
+
* `Fmt.describe`, `Fmt.causeMessage` and `Fmt.errorText`, which delegate here.
|
|
98
|
+
*
|
|
99
|
+
* The anti-pattern this replaces is the per-site fallback `error instanceof Error ? error.message : String(error)`:
|
|
100
|
+
* it misses an error from another realm and a plain `{ message }` object, reads nothing along the `cause` chain, and
|
|
101
|
+
* throws on a value whose `toString` throws or which has no prototype.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { ErrorText } from "@effected/kit"
|
|
106
|
+
*
|
|
107
|
+
* const failure = new Error("Failed to execute statement", { cause: new Error("SQLITE_BUSY: database is locked") })
|
|
108
|
+
* console.log(ErrorText.of(failure))
|
|
109
|
+
* // => "Failed to execute statement: SQLITE_BUSY: database is locked"
|
|
110
|
+
* console.log(ErrorText.of({ code: 7 }))
|
|
111
|
+
* // => "{\"code\":7}"
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* @public
|
|
115
|
+
*/
|
|
116
|
+
export declare class ErrorText {
|
|
117
|
+
private constructor();
|
|
118
|
+
/**
|
|
119
|
+
* Any value as text, without ever throwing.
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* A string is itself; a number, boolean, bigint or symbol is `String(value)`; `undefined` and `null` are
|
|
123
|
+
* `"undefined"` and `"null"`. A value with a string `message` (an `Error`, one from another realm, a plain
|
|
124
|
+
* `{ message }`) is that message, even an empty one. Anything else is its JSON, or `String(value)` when it has none
|
|
125
|
+
* (a cycle, a function), or `"[unprintable value]"` when that throws too. A getter that throws is a value with no
|
|
126
|
+
* message.
|
|
127
|
+
*
|
|
128
|
+
* For the messages along an error's `cause` chain, use {@link ErrorText.causeMessage}; for the two combined, use
|
|
129
|
+
* {@link ErrorText.of}.
|
|
130
|
+
*
|
|
131
|
+
* @param value - the value
|
|
132
|
+
*/
|
|
133
|
+
static readonly describe: (value: unknown) => string;
|
|
134
|
+
/**
|
|
135
|
+
* Every distinct non-empty message along a value's `cause` chain, nearest first and joined with `: `, or
|
|
136
|
+
* `undefined` when nothing along it has one.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* The kit's one guarded cause-message read, the one its own errors render a preserved cause with. A node's message
|
|
140
|
+
* is a thrown string or a non-empty string `message` property, read without `instanceof Error`, so an error from
|
|
141
|
+
* another realm or a plain `{ message }` object counts. A deeper message the joined text already contains is not
|
|
142
|
+
* repeated, and one that contains the joined text replaces it, so a `SqlError` wrapping SQLite's own text reads
|
|
143
|
+
* `Failed to execute statement: SQLITE_BUSY: database is locked`. It is the whole chain, not the innermost message
|
|
144
|
+
* alone. The walk stops after eight nodes, so a cyclic chain terminates.
|
|
145
|
+
*
|
|
146
|
+
* Never throws: a `message` getter that throws leaves that node without text, and a `cause` getter that throws ends
|
|
147
|
+
* the walk with the text found so far, `undefined` when there is none.
|
|
148
|
+
*
|
|
149
|
+
* @param value - the error, or whatever was thrown
|
|
150
|
+
*/
|
|
151
|
+
static readonly causeMessage: (value: unknown) => string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* The text of an unknown failure: {@link ErrorText.causeMessage} when the chain carries a message, otherwise
|
|
154
|
+
* {@link ErrorText.describe}.
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* Exactly `ErrorText.causeMessage(value) ?? ErrorText.describe(value)`, the fallback every I/O site would otherwise
|
|
158
|
+
* spell out by hand. An error whose message is empty and whose chain carries none is `""`, as `describe` reads it.
|
|
159
|
+
* Never throws.
|
|
160
|
+
*
|
|
161
|
+
* @param value - the error, or whatever was thrown
|
|
162
|
+
*/
|
|
163
|
+
static readonly of: (value: unknown) => string;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* {@link Exclusive.run}'s required options.
|
|
167
|
+
*
|
|
168
|
+
* @public
|
|
169
|
+
*/
|
|
170
|
+
interface ExclusiveRunOptions {
|
|
171
|
+
/**
|
|
172
|
+
* How long the body may run once it holds the permit. Required: a lane
|
|
173
|
+
* that mutates process-global state must not be held forever by a body
|
|
174
|
+
* that never settles. The timeout interrupts the body; it never releases
|
|
175
|
+
* the permit before the body's finalizers have run.
|
|
176
|
+
*/
|
|
177
|
+
readonly timeout: Duration.Input;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* {@link Exclusive.promise}'s options.
|
|
181
|
+
*
|
|
182
|
+
* @public
|
|
183
|
+
*/
|
|
184
|
+
interface ExclusivePromiseOptions<A, E> {
|
|
185
|
+
/** Starts the promise. `signal` aborts when the effect is interrupted. */
|
|
186
|
+
readonly try: (signal: AbortSignal) => PromiseLike<A>;
|
|
187
|
+
/** Maps a rejection, or a synchronous throw from `try`, to the typed failure. */
|
|
188
|
+
readonly catch: (cause: unknown) => E;
|
|
189
|
+
/**
|
|
190
|
+
* On interruption, abort `signal` and resume at once instead of waiting for
|
|
191
|
+
* the promise to settle. Default `false` (abort, then wait). Set it for a
|
|
192
|
+
* promise that ignores its signal, which would otherwise hold the interrupt
|
|
193
|
+
* forever; see {@link Exclusive.promise}.
|
|
194
|
+
*/
|
|
195
|
+
readonly abandon?: boolean;
|
|
196
|
+
}
|
|
197
|
+
declare const ExclusiveTimeout_base: Schema.Class<ExclusiveTimeout, Schema.TaggedStruct<"ExclusiveTimeout", {
|
|
198
|
+
readonly timeout: Schema.String;
|
|
199
|
+
readonly message: Schema.String;
|
|
200
|
+
}>, import("effect/Cause").YieldableError>;
|
|
201
|
+
/**
|
|
202
|
+
* The body of an {@link Exclusive.run} call ran past its timeout and was
|
|
203
|
+
* interrupted. Its finalizers had run, and the permit was released, by the
|
|
204
|
+
* time the caller sees this.
|
|
205
|
+
*
|
|
206
|
+
* @remarks
|
|
207
|
+
* Audience: calling code, which usually folds it into its own outcome (a tool
|
|
208
|
+
* result's `{ kind: "timeout" }`). `timeout` is the formatted duration.
|
|
209
|
+
*
|
|
210
|
+
* @public
|
|
211
|
+
*/
|
|
212
|
+
export declare class ExclusiveTimeout extends ExclusiveTimeout_base {}
|
|
213
|
+
/**
|
|
214
|
+
* A one-permit lane for work that mutates process-global state (the working
|
|
215
|
+
* directory, the environment, a patched stream), so two requests never
|
|
216
|
+
* interleave it and the state is restored before the next one starts.
|
|
217
|
+
*
|
|
218
|
+
* @remarks
|
|
219
|
+
* Ordering, on every exit path:
|
|
220
|
+
*
|
|
221
|
+
* 1. Waiting for the permit is interruptible: a request cancelled while it is
|
|
222
|
+
* queued leaves the queue and never runs.
|
|
223
|
+
* 2. Once the permit is held, the caller's interruption is masked until the
|
|
224
|
+
* body ends. A transport that maps a client cancel to an interrupt (an MCP
|
|
225
|
+
* server does) only drops the response; the next request still waits for
|
|
226
|
+
* the real work to finish.
|
|
227
|
+
* 3. The required timeout interrupts the body, and only the body.
|
|
228
|
+
* 4. The body runs in its own `Scope`: its finalizers (restoring the working
|
|
229
|
+
* directory or the environment with {@link ProcessScope}, removing a temp
|
|
230
|
+
* directory) run, then the permit is released, then the caller sees the
|
|
231
|
+
* result, an {@link ExclusiveTimeout}, or its own deferred interruption.
|
|
232
|
+
*
|
|
233
|
+
* A body that ignores interruption delays its timeout and the permit release
|
|
234
|
+
* until it settles, as an uninterruptible region does. That is
|
|
235
|
+
* deliberate, because releasing the permit while process state is still being
|
|
236
|
+
* changed is the bug this lane exists to prevent. Run promise work through
|
|
237
|
+
* {@link Exclusive.promise}, whose interruption aborts the signal and then
|
|
238
|
+
* waits for the promise to settle; `Effect.promise` would abandon it. A
|
|
239
|
+
* promise that ignores its signal and never settles therefore hangs the
|
|
240
|
+
* timeout and the permit forever: give it `abandon: true`.
|
|
241
|
+
*
|
|
242
|
+
* **Not reentrant.** A `run` on the same lane from inside a body waits for the
|
|
243
|
+
* permit its own caller holds. It never gets it: the nested call waits until
|
|
244
|
+
* the outer timeout fires, which interrupts the body (and the nested wait with
|
|
245
|
+
* it), so the outer `run` fails with {@link ExclusiveTimeout}. Call the inner
|
|
246
|
+
* work directly instead of through the lane again.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```ts
|
|
250
|
+
* import { Exclusive, ProcessScope } from "@effected/kit"
|
|
251
|
+
* import { Effect } from "effect"
|
|
252
|
+
*
|
|
253
|
+
* const lane = yield* Exclusive.make
|
|
254
|
+
* const result = yield* lane.run(
|
|
255
|
+
* Effect.gen(function* () {
|
|
256
|
+
* yield* ProcessScope.chdir(process, root)
|
|
257
|
+
* return yield* Exclusive.promise({ try: (signal) => runTool(signal), catch: (cause) => new ToolFailed({ cause }) })
|
|
258
|
+
* }),
|
|
259
|
+
* { timeout: "2 minutes" },
|
|
260
|
+
* )
|
|
261
|
+
* ```
|
|
262
|
+
*
|
|
263
|
+
* @public
|
|
264
|
+
*/
|
|
265
|
+
export declare class Exclusive {
|
|
266
|
+
private readonly semaphore;
|
|
267
|
+
private constructor();
|
|
268
|
+
/** A new lane with one permit. Bind it once (a module-level or layer-built value); every call shares it. */
|
|
269
|
+
static readonly make: Effect.Effect<Exclusive>;
|
|
270
|
+
/**
|
|
271
|
+
* A promise whose interruption aborts `signal` and then waits for the
|
|
272
|
+
* promise to settle, rather than abandoning it as `Effect.promise` and
|
|
273
|
+
* `Effect.tryPromise` do. A rejection, or a synchronous throw from `try`,
|
|
274
|
+
* fails through `catch`; a `catch` that itself throws dies with what it
|
|
275
|
+
* threw.
|
|
276
|
+
*
|
|
277
|
+
* @remarks
|
|
278
|
+
* **The default hangs on a promise that ignores its signal.** Waiting is
|
|
279
|
+
* the point: the interrupt (a lane timeout, a cancel) returns only once the
|
|
280
|
+
* promise has settled, so no work is still changing process state when the
|
|
281
|
+
* permit is released. But a promise that never observes `signal` and never
|
|
282
|
+
* settles (a test runner's `start`, a third-party call with no
|
|
283
|
+
* cancellation) holds the interrupt, the lane's timeout and the permit
|
|
284
|
+
* forever.
|
|
285
|
+
*
|
|
286
|
+
* Pass `abandon: true` for such a promise. Interruption still aborts
|
|
287
|
+
* `signal`, then resumes at once without waiting; the promise keeps running
|
|
288
|
+
* unobserved and its eventual result is dropped. Inside {@link Exclusive.run}
|
|
289
|
+
* the body's scope still closes, and its finalizers still run, before the
|
|
290
|
+
* permit is released. What abandoning gives up is the guarantee that the
|
|
291
|
+
* promise itself has stopped: choose it only when the promise cannot be
|
|
292
|
+
* stopped, and restore any state it touches in a finalizer.
|
|
293
|
+
*/
|
|
294
|
+
static readonly promise: <A, E>(options: ExclusivePromiseOptions<A, E>) => Effect.Effect<A, E>;
|
|
295
|
+
/**
|
|
296
|
+
* Runs `body` holding this lane's permit, under the ordering in the class
|
|
297
|
+
* remarks. `body` gets its own `Scope`, closed before the permit is released.
|
|
298
|
+
*/
|
|
299
|
+
readonly run: <A, E, R>(body: Effect.Effect<A, E, R>, options: ExclusiveRunOptions) => Effect.Effect<A, E | ExclusiveTimeout, Exclude<R, Scope.Scope>>;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The parameter shape {@link LaunchContext.resolve} takes: the process-derived
|
|
303
|
+
* facts a front end resolves once, passed in as values.
|
|
304
|
+
*
|
|
305
|
+
* @remarks
|
|
306
|
+
* Nothing here reads `process`: `argv` and `env` come from the front end's own
|
|
307
|
+
* `main.ts`. That keeps the resolution rule shared and testable while the
|
|
308
|
+
* process read stays at the one place allowed to make it.
|
|
309
|
+
*
|
|
310
|
+
* @public
|
|
311
|
+
*/
|
|
312
|
+
interface ResolveInput {
|
|
313
|
+
/**
|
|
314
|
+
* Positional candidates, checked in order. Pass positional arguments only —
|
|
315
|
+
* the parsed positionals of your command, never raw `process.argv.slice(2)`:
|
|
316
|
+
* every non-empty value counts as a candidate, so a `--flag` would become the
|
|
317
|
+
* project directory.
|
|
318
|
+
*/
|
|
319
|
+
readonly argv?: ReadonlyArray<string> | undefined;
|
|
320
|
+
/** The environment, usually `process.env`. */
|
|
321
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
322
|
+
/** Env keys to try in order, for example `["OKFIT_PROJECT_DIR", "CLAUDE_PROJECT_DIR"]`. */
|
|
323
|
+
readonly keys: ReadonlyArray<string>;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The parameter shape {@link LaunchContext.projectDir} takes: {@link ResolveInput}
|
|
327
|
+
* plus the `cwd` fallback.
|
|
328
|
+
*
|
|
329
|
+
* @public
|
|
330
|
+
*/
|
|
331
|
+
interface ProjectDirInput extends ResolveInput {
|
|
332
|
+
/** The fallback, usually `process.cwd()`. */
|
|
333
|
+
readonly cwd: string;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Resolves where a tool launched by an agent host should treat as its project.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* import { LaunchContext } from "@effected/kit"
|
|
341
|
+
*
|
|
342
|
+
* const dir = LaunchContext.projectDir({
|
|
343
|
+
* argv: [],
|
|
344
|
+
* env: { CLAUDE_PROJECT_DIR: "/work/app" },
|
|
345
|
+
* keys: ["MYTOOL_PROJECT_DIR", "CLAUDE_PROJECT_DIR"],
|
|
346
|
+
* cwd: "/home/me",
|
|
347
|
+
* })
|
|
348
|
+
* // => "/work/app"
|
|
349
|
+
* ```
|
|
350
|
+
*
|
|
351
|
+
* @public
|
|
352
|
+
*/
|
|
353
|
+
export declare class LaunchContext {
|
|
354
|
+
private constructor();
|
|
355
|
+
/**
|
|
356
|
+
* Whether a value still carries a literal `${VAR}` placeholder.
|
|
357
|
+
*
|
|
358
|
+
* @remarks
|
|
359
|
+
* An agent host can pass `${CLAUDE_PROJECT_DIR}` through unsubstituted in
|
|
360
|
+
* some launch paths; a path containing a placeholder is never what was meant.
|
|
361
|
+
*/
|
|
362
|
+
static readonly isUnsubstituted: (value: string) => boolean;
|
|
363
|
+
/**
|
|
364
|
+
* `value` trimmed, when it names something: `Option.none()` for `undefined`,
|
|
365
|
+
* an empty or whitespace-only string, or a string still carrying a literal
|
|
366
|
+
* `${VAR}` placeholder ({@link LaunchContext.isUnsubstituted}).
|
|
367
|
+
*
|
|
368
|
+
* @remarks
|
|
369
|
+
* The guard every resolver here applies; use it directly for one positional
|
|
370
|
+
* argument, as in `LaunchContext.usable(argv[2])`. `??` would accept an empty
|
|
371
|
+
* string or a literal `${VAR}`, neither of which names a directory or an id.
|
|
372
|
+
*/
|
|
373
|
+
static readonly usable: (value: string | undefined) => Option.Option<string>;
|
|
374
|
+
/**
|
|
375
|
+
* The first {@link LaunchContext.usable} `argv` value, then the first usable
|
|
376
|
+
* `env` value in `keys` order; `Option.none()` when none is usable.
|
|
377
|
+
*
|
|
378
|
+
* @remarks
|
|
379
|
+
* The resolver under {@link LaunchContext.projectDir}, without its `cwd`
|
|
380
|
+
* fallback, for any launch-supplied location: a host's plugin data
|
|
381
|
+
* directory, then an app override, with the caller's own fallback after
|
|
382
|
+
* it. Values are trimmed, so a value with surrounding whitespace resolves to
|
|
383
|
+
* the trimmed path.
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```ts
|
|
387
|
+
* import { LaunchContext } from "@effected/kit"
|
|
388
|
+
* import { Option } from "effect"
|
|
389
|
+
*
|
|
390
|
+
* const dataDir = LaunchContext.resolve({ env, keys: ["CLAUDE_PLUGIN_DATA", "MYTOOL_DATA_DIR"] }).pipe(
|
|
391
|
+
* Option.getOrElse(() => `${home}/.mytool`),
|
|
392
|
+
* )
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
static readonly resolve: (input: ResolveInput) => Option.Option<string>;
|
|
396
|
+
/**
|
|
397
|
+
* The first usable argv value, then the first usable env value in `keys`
|
|
398
|
+
* order, then `cwd`: {@link LaunchContext.resolve} with `cwd` as the
|
|
399
|
+
* fallback.
|
|
400
|
+
*/
|
|
401
|
+
static readonly projectDir: (input: ProjectDirInput) => string;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Decides whether a caller-supplied path stays inside a root.
|
|
405
|
+
*
|
|
406
|
+
* @remarks
|
|
407
|
+
* Audience: a handler that must refuse a path outside its workspace before
|
|
408
|
+
* reading or writing it. Neither form fails: "outside" is the answer `false`,
|
|
409
|
+
* and the filesystem is required in `R` from core, never owned here. A
|
|
410
|
+
* relative `candidate` is resolved against `root`, never the process working
|
|
411
|
+
* directory.
|
|
412
|
+
*
|
|
413
|
+
* {@link PathContainment.isWithinLexical} is pure and compares spellings, so a
|
|
414
|
+
* link inside the root that points out is invisible to it. Use
|
|
415
|
+
* {@link PathContainment.isWithin} for anything that will touch the disk.
|
|
416
|
+
*
|
|
417
|
+
* @public
|
|
418
|
+
*/
|
|
419
|
+
export declare class PathContainment {
|
|
420
|
+
private constructor();
|
|
421
|
+
/**
|
|
422
|
+
* Whether `candidate` is `root` or lies under it, comparing resolved
|
|
423
|
+
* spellings only (`..` segments and trailing separators are normalised;
|
|
424
|
+
* links are not followed). A sibling sharing a prefix (`/repo-other` against
|
|
425
|
+
* `/repo`) is outside. A relative `candidate` is resolved against `root`.
|
|
426
|
+
*/
|
|
427
|
+
static isWithinLexical(path: Path.Path, root: string, candidate: string): boolean;
|
|
428
|
+
/**
|
|
429
|
+
* Whether `candidate` is `root` or lies under it once links are resolved.
|
|
430
|
+
*
|
|
431
|
+
* @remarks
|
|
432
|
+
* The candidate need not exist: its nearest existing ancestor is resolved
|
|
433
|
+
* with `realPath` and the missing tail appended, so a path about to be
|
|
434
|
+
* created is judged by where its parent really is. A component that is a
|
|
435
|
+
* dangling link is followed to where it points (a link out of the root is
|
|
436
|
+
* outside even when its target is absent), up to a fixed number of hops;
|
|
437
|
+
* a loop is outside.
|
|
438
|
+
*
|
|
439
|
+
* Fails closed: only a `NotFound` counts as absent. Any other failure on a
|
|
440
|
+
* candidate component (permission denied, a link loop, a non-directory
|
|
441
|
+
* parent) answers `false`. The lexical answer stands in only when the root's
|
|
442
|
+
* own real path is `NotFound`; any other failure reading it is `false`.
|
|
443
|
+
*
|
|
444
|
+
* A point-in-time check: the filesystem can change between this answer and
|
|
445
|
+
* the read or write it guards.
|
|
446
|
+
*/
|
|
447
|
+
static readonly isWithin: (root: string, candidate: string) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The working-directory half of a process, structurally. Node's `process`
|
|
451
|
+
* satisfies it; pass it in, so nothing here reads `process` itself.
|
|
452
|
+
*
|
|
453
|
+
* @public
|
|
454
|
+
*/
|
|
455
|
+
interface ProcessCwdHost {
|
|
456
|
+
/** The current working directory. May throw when it was deleted (`ENOENT`). */
|
|
457
|
+
cwd(): string;
|
|
458
|
+
/** Changes the working directory. Throws when `directory` cannot be entered. */
|
|
459
|
+
chdir(directory: string): void;
|
|
460
|
+
}
|
|
461
|
+
declare const ProcessScopeError_base: Schema.Class<ProcessScopeError, Schema.TaggedStruct<"ProcessScopeError", {
|
|
462
|
+
readonly reason: Schema.Literal<"ChdirFailed">;
|
|
463
|
+
readonly path: Schema.String;
|
|
464
|
+
readonly message: Schema.String;
|
|
465
|
+
readonly cause: Schema.Defect;
|
|
466
|
+
}>, import("effect/Cause").YieldableError>;
|
|
467
|
+
/**
|
|
468
|
+
* A {@link ProcessScope} change could not be made.
|
|
469
|
+
*
|
|
470
|
+
* @remarks
|
|
471
|
+
* Audience: calling code (`reason`, `path`) and an end user (`message`).
|
|
472
|
+
* `"ChdirFailed"` means `chdir` threw for `path`; the original throw is
|
|
473
|
+
* `cause`. Nothing was changed, so nothing is restored.
|
|
474
|
+
*
|
|
475
|
+
* @public
|
|
476
|
+
*/
|
|
477
|
+
export declare class ProcessScopeError extends ProcessScopeError_base {}
|
|
478
|
+
/**
|
|
479
|
+
* Scoped changes to process-global state, each restored when the enclosing
|
|
480
|
+
* `Scope` closes, on every exit path.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* Pair them with {@link Exclusive}: a scoped restore protects the next request
|
|
484
|
+
* only if no other request can run while the change is live.
|
|
485
|
+
*
|
|
486
|
+
* Both take the state they change as an argument (a {@link ProcessCwdHost},
|
|
487
|
+
* an environment record), so they read no `process` and test against plain
|
|
488
|
+
* doubles. For a temporary directory, use core's
|
|
489
|
+
* `FileSystem.makeTempDirectory` in an `Effect.acquireRelease` whose release
|
|
490
|
+
* ignores a failed removal; `makeTempDirectoryScoped`'s release dies on one,
|
|
491
|
+
* which turns a successful body into a defect.
|
|
492
|
+
*
|
|
493
|
+
* @public
|
|
494
|
+
*/
|
|
495
|
+
export declare class ProcessScope {
|
|
496
|
+
private constructor();
|
|
497
|
+
/**
|
|
498
|
+
* Enters `directory` and returns to the previous working directory when the
|
|
499
|
+
* scope closes.
|
|
500
|
+
*
|
|
501
|
+
* @remarks
|
|
502
|
+
* When the previous directory cannot be read (it was deleted, so `cwd()`
|
|
503
|
+
* throws), the change still happens and nothing is restored: there is no
|
|
504
|
+
* valid directory to go back to. A restore that throws is ignored for the
|
|
505
|
+
* same reason.
|
|
506
|
+
*/
|
|
507
|
+
static readonly chdir: (host: ProcessCwdHost, directory: string) => Effect.Effect<void, ProcessScopeError, Scope.Scope>;
|
|
508
|
+
/**
|
|
509
|
+
* Applies `patch` to `env` and restores every patched key when the scope
|
|
510
|
+
* closes.
|
|
511
|
+
*
|
|
512
|
+
* @remarks
|
|
513
|
+
* An `undefined` value in `patch` deletes the key, and a key that was absent
|
|
514
|
+
* before is deleted again on restore. Assigning `undefined` to Node's
|
|
515
|
+
* `process.env` would store the string `"undefined"`, so neither direction
|
|
516
|
+
* ever assigns it.
|
|
517
|
+
*/
|
|
518
|
+
static readonly env: (env: Record<string, string | undefined>, patch: Readonly<Record<string, string | undefined>>) => Effect.Effect<void, never, Scope.Scope>;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* What a caller — usually an agent — should do after a failure.
|
|
522
|
+
*
|
|
523
|
+
* @remarks
|
|
524
|
+
* `hint` is the human-readable instruction. `suggestedTool` and `suggestedArgs`
|
|
525
|
+
* optionally name the tool to call next and the arguments to call it with, so
|
|
526
|
+
* an agent can act without parsing the hint. The optional keys are
|
|
527
|
+
* `optionalKey`: omit them rather than passing an explicit `undefined`, which
|
|
528
|
+
* is rejected instead of silently encoded.
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* ```ts
|
|
532
|
+
* import { Remediation } from "@effected/kit"
|
|
533
|
+
*
|
|
534
|
+
* const remediation: Remediation = {
|
|
535
|
+
* hint: "Run the validator on the whole bundle first.",
|
|
536
|
+
* suggestedTool: "validate_bundle",
|
|
537
|
+
* suggestedArgs: { strict: true },
|
|
538
|
+
* }
|
|
539
|
+
* ```
|
|
540
|
+
*
|
|
541
|
+
* @public
|
|
542
|
+
*/
|
|
543
|
+
export declare const Remediation: Schema.Struct<{
|
|
544
|
+
readonly hint: Schema.String;
|
|
545
|
+
readonly suggestedTool: Schema.optionalKey<Schema.String>;
|
|
546
|
+
readonly suggestedArgs: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
|
|
547
|
+
}>;
|
|
548
|
+
/**
|
|
549
|
+
* A decoded {@link (Remediation:variable)}.
|
|
550
|
+
*
|
|
551
|
+
* @public
|
|
552
|
+
*/
|
|
553
|
+
export type Remediation = typeof Remediation.Type;
|
|
554
|
+
export type { ExclusivePromiseOptions, ExclusiveRunOptions, ProcessCwdHost, ProjectDirInput, ResolveInput };
|
|
555
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CurrentDistribution, Distribution, DistributionField, distributionSuffix, withDistribution, withDistributionVersion } from "./Distribution.js";
|
|
2
|
+
import { ErrorText } from "./ErrorText.js";
|
|
3
|
+
import { Exclusive, ExclusiveTimeout } from "./Exclusive.js";
|
|
4
|
+
import { LaunchContext } from "./LaunchContext.js";
|
|
5
|
+
import { PathContainment } from "./PathContainment.js";
|
|
6
|
+
import { ProcessScope, ProcessScopeError } from "./ProcessScope.js";
|
|
7
|
+
import { Remediation } from "./Remediation.js";
|
|
8
|
+
|
|
9
|
+
export { CurrentDistribution, Distribution, DistributionField, ErrorText, Exclusive, ExclusiveTimeout, LaunchContext, PathContainment, ProcessScope, ProcessScopeError, Remediation, distributionSuffix, withDistribution, withDistributionVersion };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Predicate } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/internal/causeMessage.ts
|
|
4
|
+
/** How many nodes of a cause chain the walk reads before stopping, so a cyclic chain cannot spin. */
|
|
5
|
+
const CAUSE_DEPTH = 8;
|
|
6
|
+
/**
|
|
7
|
+
* The message a single node carries: a thrown string, or a non-empty string `message` property. A getter or
|
|
8
|
+
* proxy trap that throws is a node with no message.
|
|
9
|
+
*/
|
|
10
|
+
const messageAt = (node) => {
|
|
11
|
+
try {
|
|
12
|
+
const text = Predicate.isString(node) ? node : Predicate.hasProperty(node, "message") && Predicate.isString(node.message) ? node.message : void 0;
|
|
13
|
+
return text === void 0 || text.trim() === "" ? void 0 : text;
|
|
14
|
+
} catch {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** The next link of the chain: a node's `cause`, or `undefined` when it has none or reading it throws. */
|
|
19
|
+
const causeAt = (node) => {
|
|
20
|
+
try {
|
|
21
|
+
return Predicate.hasProperty(node, "cause") ? node.cause : void 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Every distinct non-empty message a preserved cause chain carries, nearest
|
|
28
|
+
* first and joined with `: `, or `undefined` when nothing along it has one.
|
|
29
|
+
*
|
|
30
|
+
* A `Schema.Defect()` field holds whatever was thrown, so the read is guarded
|
|
31
|
+
* rather than assumed: `Predicate.hasProperty` + `Predicate.isString` accepts
|
|
32
|
+
* an `Error`, a cross-realm `Error` that fails `instanceof`, a plain
|
|
33
|
+
* `{ message }` object and a thrown string alike. An empty or whitespace-only
|
|
34
|
+
* message counts as none. The walk follows `cause` links because a wrapper's
|
|
35
|
+
* text is often the generic summary and the driver's text underneath is the
|
|
36
|
+
* actionable detail, yet a wrapper that composes its own context must not
|
|
37
|
+
* lose it either; so nothing is dropped, and a deeper text the nearer one
|
|
38
|
+
* already contains is not repeated. The walk is bounded, so a cycle terminates.
|
|
39
|
+
*
|
|
40
|
+
* It never throws: every property read is guarded, so a `message` getter that throws leaves that link
|
|
41
|
+
* without text, and a `cause` getter that throws ends the walk with what was found so far.
|
|
42
|
+
*/
|
|
43
|
+
const causeMessage = (cause) => {
|
|
44
|
+
let message;
|
|
45
|
+
let node = cause;
|
|
46
|
+
for (let depth = 0; depth < CAUSE_DEPTH && node !== void 0 && node !== null; depth++) {
|
|
47
|
+
const text = messageAt(node);
|
|
48
|
+
if (text !== void 0) {
|
|
49
|
+
if (message === void 0 || text.includes(message)) message = text;
|
|
50
|
+
else if (!message.includes(text)) message = `${message}: ${text}`;
|
|
51
|
+
}
|
|
52
|
+
node = causeAt(node);
|
|
53
|
+
}
|
|
54
|
+
return message;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export { causeMessage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@effected/kit",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Platform-free primitives shared by every front end of an Effect v4 tool: distribution identity, remediation, launch context, and transport-neutral process crash guards",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"effect",
|
|
8
|
+
"cli",
|
|
9
|
+
"mcp",
|
|
10
|
+
"carrier",
|
|
11
|
+
"distribution",
|
|
12
|
+
"effected"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/kit#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/spencerbeggs/effected/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/spencerbeggs/effected.git",
|
|
21
|
+
"directory": "packages/kit"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "C. Spencer Beggs",
|
|
26
|
+
"email": "spencer@beggs.codes",
|
|
27
|
+
"url": "https://spencerbeg.gs"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./index.d.ts",
|
|
34
|
+
"import": "./index.js",
|
|
35
|
+
"default": "./index.js"
|
|
36
|
+
},
|
|
37
|
+
"./guard": {
|
|
38
|
+
"types": "./guard.d.ts",
|
|
39
|
+
"import": "./guard.js",
|
|
40
|
+
"default": "./guard.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"effect": "~4.1.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24.11.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.60.1"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|