@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,95 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+
3
+ // src/ProcessScope.ts
4
+ /**
5
+ * A {@link ProcessScope} change could not be made.
6
+ *
7
+ * @remarks
8
+ * Audience: calling code (`reason`, `path`) and an end user (`message`).
9
+ * `"ChdirFailed"` means `chdir` threw for `path`; the original throw is
10
+ * `cause`. Nothing was changed, so nothing is restored.
11
+ *
12
+ * @public
13
+ */
14
+ var ProcessScopeError = class extends Schema.TaggedError()("ProcessScopeError", {
15
+ reason: Schema.Literal("ChdirFailed"),
16
+ path: Schema.String,
17
+ message: Schema.String,
18
+ cause: Schema.Defect()
19
+ }) {};
20
+ /**
21
+ * Scoped changes to process-global state, each restored when the enclosing
22
+ * `Scope` closes, on every exit path.
23
+ *
24
+ * @remarks
25
+ * Pair them with {@link Exclusive}: a scoped restore protects the next request
26
+ * only if no other request can run while the change is live.
27
+ *
28
+ * Both take the state they change as an argument (a {@link ProcessCwdHost},
29
+ * an environment record), so they read no `process` and test against plain
30
+ * doubles. For a temporary directory, use core's
31
+ * `FileSystem.makeTempDirectory` in an `Effect.acquireRelease` whose release
32
+ * ignores a failed removal; `makeTempDirectoryScoped`'s release dies on one,
33
+ * which turns a successful body into a defect.
34
+ *
35
+ * @public
36
+ */
37
+ var ProcessScope = class {
38
+ constructor() {}
39
+ /**
40
+ * Enters `directory` and returns to the previous working directory when the
41
+ * scope closes.
42
+ *
43
+ * @remarks
44
+ * When the previous directory cannot be read (it was deleted, so `cwd()`
45
+ * throws), the change still happens and nothing is restored: there is no
46
+ * valid directory to go back to. A restore that throws is ignored for the
47
+ * same reason.
48
+ */
49
+ static chdir = (host, directory) => Effect.asVoid(Effect.acquireRelease(Effect.try({
50
+ try: () => {
51
+ let previous;
52
+ try {
53
+ previous = Option.some(host.cwd());
54
+ } catch {
55
+ previous = Option.none();
56
+ }
57
+ host.chdir(directory);
58
+ return previous;
59
+ },
60
+ catch: (cause) => new ProcessScopeError({
61
+ reason: "ChdirFailed",
62
+ path: directory,
63
+ message: `Cannot change the working directory to ${directory}.`,
64
+ cause
65
+ })
66
+ }), (previous) => Effect.sync(() => {
67
+ if (Option.isNone(previous)) return;
68
+ try {
69
+ host.chdir(previous.value);
70
+ } catch {}
71
+ })));
72
+ /**
73
+ * Applies `patch` to `env` and restores every patched key when the scope
74
+ * closes.
75
+ *
76
+ * @remarks
77
+ * An `undefined` value in `patch` deletes the key, and a key that was absent
78
+ * before is deleted again on restore. Assigning `undefined` to Node's
79
+ * `process.env` would store the string `"undefined"`, so neither direction
80
+ * ever assigns it.
81
+ */
82
+ static env = (env, patch) => Effect.asVoid(Effect.acquireRelease(Effect.sync(() => {
83
+ const saved = Object.keys(patch).map((key) => [key, Object.hasOwn(env, key) ? Option.fromUndefinedOr(env[key]) : Option.none()]);
84
+ for (const [key, value] of Object.entries(patch)) assign(env, key, value);
85
+ return saved;
86
+ }), (saved) => Effect.sync(() => {
87
+ for (const [key, value] of saved) assign(env, key, Option.getOrUndefined(value));
88
+ })));
89
+ };
90
+ const assign = (env, key, value) => {
91
+ if (value === void 0) delete env[key];
92
+ else env[key] = value;
93
+ };
94
+
95
+ export { ProcessScope, ProcessScopeError };
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # @effected/kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fengine?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/kit)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ Platform-free primitives shared by every front end of an Effect v4 tool. A carrier-pattern tool (a CLI, an MCP server, an LSP server) ships more than one bin behind one meta-package, and every one of those front ends needs to answer the same handful of questions the same way: which carrier was I installed through, and what version line does that make me. This package holds those primitives once, so a consumer's own `engine` package can depend on them without also depending on `@effected/cli-kit` or any other front end.
9
+
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `~4.1.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
+ > [`@effected/pnpm-plugin-effected`](https://www.npmjs.com/package/@effected/pnpm-plugin-effected).
15
+ >
16
+ > **Stability: unstable.** This package's API surface is not yet considered
17
+ > complete and may change across `0.x` releases. Pin an exact version — even a
18
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
19
+ > accident, and an exact pin turns that into a type-check error rather than a
20
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @effected/kit effect
26
+ ```
27
+
28
+ ```bash
29
+ pnpm add @effected/kit effect
30
+ ```
31
+
32
+ Requires Node.js >=24.11.0.
33
+
34
+ All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
35
+
36
+ `effect` v4 is the only peer dependency, and it is the only dependency of any kind — no parser, no polyfill, no platform package rides in behind it. There is no IO here: every export is a schema, a reference with a default, or a pure function.
37
+
38
+ ## Distribution
39
+
40
+ `Distribution` is the carrier package a tool's bins were installed through — its `name` and `version`. A plain `Schema.Struct` rather than a `Schema.Class`, because it travels as a plain object inside a machine-readable envelope and is compared structurally, never constructed on its own.
41
+
42
+ ```ts
43
+ import { Distribution } from "@effected/kit";
44
+ import { Schema } from "effect";
45
+
46
+ const decoded = Schema.decodeUnknownSync(Distribution)({ name: "@okfit/plugin", version: "0.5.1" });
47
+ console.log(decoded);
48
+ // => { name: "@okfit/plugin", version: "0.5.1" }
49
+ ```
50
+
51
+ `DistributionField` is the `distribution` field of an envelope: `Schema.NullOr(Distribution)`, where `null` means the front end was installed directly rather than through a carrier — not "not yet known."
52
+
53
+ ## CurrentDistribution
54
+
55
+ `CurrentDistribution` is a `Context.Reference<Option<Distribution>>`, not a `Context.Service`: it carries its own default (`Option.none()`), so reading it never appears in an effect's `R` and a direct install needs no provision at all. A front end's `main` provides it once, at the top of the program, once it has resolved what installed it.
56
+
57
+ ```ts
58
+ import { CurrentDistribution } from "@effected/kit";
59
+ import { Effect, Option } from "effect";
60
+
61
+ const program = Effect.gen(function* () {
62
+ const current = yield* CurrentDistribution;
63
+ return Option.isSome(current) ? current.value.name : "direct install";
64
+ });
65
+
66
+ console.log(Effect.runSync(program));
67
+ // => "direct install"
68
+ ```
69
+
70
+ Providing a value scopes it to the sub-effect, the same way any `Context.Reference` scopes:
71
+
72
+ ```ts
73
+ program.pipe(
74
+ Effect.provideService(CurrentDistribution, Option.some({ name: "@okfit/plugin", version: "0.5.1" })),
75
+ );
76
+ ```
77
+
78
+ ## distributionSuffix
79
+
80
+ `distributionSuffix` turns a `CurrentDistribution` read into the trailing text a `--version` line or a startup log line appends — `""` for a direct install, `" via <name> <version>"` for a carrier. A free function, not a static on `Distribution`, because a `Schema.Struct` value carries no statics to hang it from.
81
+
82
+ ```ts
83
+ import { distributionSuffix } from "@effected/kit";
84
+ import { Option } from "effect";
85
+
86
+ distributionSuffix(Option.none());
87
+ // => ""
88
+
89
+ distributionSuffix(Option.some({ name: "@okfit/plugin", version: "0.5.1" }));
90
+ // => " via @okfit/plugin 0.5.1"
91
+ ```
92
+
93
+ ## Tier
94
+
95
+ Pure tier: `effect` is the only peer, no `process`, no `node:` import, no platform package. Nothing in the kit depends on `@effected/kit` except `@effected/mcp-kit`; `@effected/cli-kit` never does — the two sit at the same layer, both consumed by a front end, never by each other.
96
+
97
+ ## License
98
+
99
+ [MIT](LICENSE)
package/Remediation.js ADDED
@@ -0,0 +1,33 @@
1
+ import { Schema } from "effect";
2
+
3
+ // src/Remediation.ts
4
+ /**
5
+ * What a caller — usually an agent — should do after a failure.
6
+ *
7
+ * @remarks
8
+ * `hint` is the human-readable instruction. `suggestedTool` and `suggestedArgs`
9
+ * optionally name the tool to call next and the arguments to call it with, so
10
+ * an agent can act without parsing the hint. The optional keys are
11
+ * `optionalKey`: omit them rather than passing an explicit `undefined`, which
12
+ * is rejected instead of silently encoded.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { Remediation } from "@effected/kit"
17
+ *
18
+ * const remediation: Remediation = {
19
+ * hint: "Run the validator on the whole bundle first.",
20
+ * suggestedTool: "validate_bundle",
21
+ * suggestedArgs: { strict: true },
22
+ * }
23
+ * ```
24
+ *
25
+ * @public
26
+ */
27
+ const Remediation = Schema.Struct({
28
+ hint: Schema.String,
29
+ suggestedTool: Schema.optionalKey(Schema.String),
30
+ suggestedArgs: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown))
31
+ });
32
+
33
+ export { Remediation };
package/guard.d.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The slice of the host process {@link ProcessGuard.run} uses. Node's
3
+ * `process` satisfies it.
4
+ *
5
+ * @remarks
6
+ * Structural on purpose: the guard never reads a global, so a test passes a
7
+ * double and a front end passes `process` from its own `main`.
8
+ *
9
+ * @public
10
+ */
11
+ interface ProcessGuardHost {
12
+ /** Register the `uncaughtException` listener. */
13
+ on(event: "uncaughtException", listener: (error: Error, origin: string) => void): unknown;
14
+ /** Register the `unhandledRejection` listener. */
15
+ on(event: "unhandledRejection", listener: (reason: unknown) => void): unknown;
16
+ /** Raise an `uncaughtException` to the registered listeners. Used only by {@link ProcessGuardOptions.injectCrash}. */
17
+ emit(event: "uncaughtException", error: Error, origin: "uncaughtException" | "unhandledRejection"): unknown;
18
+ /** Raise an `unhandledRejection` to the registered listeners. Used only by {@link ProcessGuardOptions.injectCrash}. */
19
+ emit(event: "unhandledRejection", reason: unknown, promise: Promise<unknown>): unknown;
20
+ /** Where every guard report is written. For a stdio server, never stdout: that is the protocol wire. */
21
+ readonly stderr: {
22
+ write(chunk: string): unknown;
23
+ };
24
+ /** End the process at once. */
25
+ exit(code?: number): never;
26
+ }
27
+ /**
28
+ * When a stray exception or rejection ends the process.
29
+ *
30
+ * @remarks
31
+ * `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until
32
+ * the caller reports the server connected with
33
+ * {@link ProcessGuardControl.markConnected}, then logs and keeps serving: a
34
+ * server that dies mid-session drops every client attached to it. `"log"`
35
+ * never exits.
36
+ *
37
+ * @public
38
+ */
39
+ interface ProcessGuardPolicy {
40
+ /** For `uncaughtException`. */
41
+ readonly onUncaught: "exit" | "exitBeforeConnect";
42
+ /** For `unhandledRejection`. Defaults to `"exit"`. */
43
+ readonly onRejection?: "exit" | "exitBeforeConnect" | "log" | undefined;
44
+ }
45
+ /**
46
+ * One crash {@link ProcessGuardOptions.injectCrash} raises: which event, and
47
+ * when.
48
+ *
49
+ * @public
50
+ */
51
+ interface ProcessGuardInjection {
52
+ /** `"load"`: before `load` is called. `"connected"`: after the first {@link ProcessGuardControl.markConnected}. */
53
+ readonly at: "load" | "connected";
54
+ /** Which event to raise. */
55
+ readonly kind: "uncaughtException" | "unhandledRejection";
56
+ }
57
+ /**
58
+ * What {@link ProcessGuardOptions.load} is handed: the two moments only the
59
+ * caller can see.
60
+ *
61
+ * @remarks
62
+ * Plain callbacks, so a caller with no Effect runtime (a
63
+ * `vscode-languageserver` server, a hand-written socket loop) uses them the
64
+ * same way an Effect launcher does: an MCP launcher passes `markConnected`
65
+ * to `McpStdio.launch`'s `onReady`; an LSP calls it once its connection is
66
+ * listening.
67
+ *
68
+ * @public
69
+ */
70
+ interface ProcessGuardControl {
71
+ /**
72
+ * Report the server connected: from here on `"exitBeforeConnect"` logs and
73
+ * keeps going. Call it once the server is serving. Later calls do nothing.
74
+ * Safe to call after `load` has resolved, which is the usual case.
75
+ *
76
+ * @remarks
77
+ * For a server framed over stdio (MCP, LSP), "serving" is the moment its
78
+ * transport is built and reading stdin, before the first request arrives:
79
+ * from then on a client is attached, and a stray error should be logged,
80
+ * not end the session.
81
+ */
82
+ readonly markConnected: () => void;
83
+ /**
84
+ * Format every report from here on with `format`, in place of the guard's
85
+ * own dependency-free formatter. A `format` that throws falls back to the
86
+ * guard's own, so a broken formatter never hides a crash.
87
+ */
88
+ readonly useFormat: (format: (error: unknown) => string) => void;
89
+ }
90
+ /**
91
+ * Options for {@link ProcessGuard.run}.
92
+ *
93
+ * @public
94
+ */
95
+ interface ProcessGuardOptions {
96
+ /** Prefixes every report the guard writes, as in `my-server: uncaughtException (…): …`. */
97
+ readonly label: string;
98
+ /** The process: pass `process`. */
99
+ readonly host: ProcessGuardHost;
100
+ /** Defaults to `{ onUncaught: "exit", onRejection: "exit" }`. */
101
+ readonly policy?: ProcessGuardPolicy | undefined;
102
+ /**
103
+ * Import, assemble and start the server. Everything that can throw while
104
+ * loading belongs here, behind a dynamic `import()`, so the guards are
105
+ * already listening when it runs. A rejection is reported as
106
+ * `startup failed` and exits 1, whatever the policy.
107
+ */
108
+ readonly load: (guard: ProcessGuardControl) => Promise<unknown>;
109
+ /**
110
+ * For a test of the guards themselves: raise one stray `kind` at `at`, so
111
+ * both halves of a policy can be driven end to end in a real process.
112
+ *
113
+ * @remarks
114
+ * - `"load"` raises it once both listeners are installed and before
115
+ * `load` is called, then waits for the guard to handle it: `load` is
116
+ * not called until the listener has run. If a test double's `exit`
117
+ * throws instead of ending the process, `run` rejects with what it
118
+ * threw and never calls `load`. The pre-connect half of the policy
119
+ * applies, so `"exitBeforeConnect"` exits 1 here, and only an
120
+ * `onRejection` of `"log"` lets the process go on to load and serve.
121
+ * The report uses the guard's own formatter, since no `useFormat` has
122
+ * run yet.
123
+ * - `"connected"` raises it after the first `markConnected`, where
124
+ * `"exitBeforeConnect"` logs and keeps serving. It is never raised if
125
+ * `markConnected` is never called. A throw from a test double's `exit`
126
+ * there is dropped.
127
+ *
128
+ * **The `"connected"` report is asynchronous.** It is raised on a later
129
+ * tick, a `setTimeout(0)` after `markConnected`, so it can land after the
130
+ * first responses the server sends: a test that reads stderr once, right
131
+ * after its first response, can see nothing. Wait for the report (with
132
+ * `McpProcess.stderrUntil` from `@effected/mcp-kit/testing`, for an MCP
133
+ * server) rather than reading it once.
134
+ *
135
+ * Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
136
+ * never as a real throw or rejection, so the guard's listeners handle it
137
+ * the same way under the real `process` and under a test double. Under
138
+ * `process`, every listener registered for the event sees it, not only
139
+ * the guard's. It carries an `[injected]` message; a rejection is emitted
140
+ * with an already-handled rejected promise. `undefined`, or an `at` or
141
+ * `kind` outside these values, does nothing at all. Wire it to an
142
+ * environment variable only a test sets.
143
+ */
144
+ readonly injectCrash?: ProcessGuardInjection | undefined;
145
+ }
146
+ /**
147
+ * Transport-neutral crash guards for a server process, installed before the
148
+ * server's module graph loads. Imported from `@effected/kit/guard`.
149
+ *
150
+ * @remarks
151
+ * {@link ProcessGuard.run} registers `uncaughtException` and
152
+ * `unhandledRejection` listeners on `host`, then awaits `load`. It launches
153
+ * nothing itself: `load` starts the server, over whatever transport, and
154
+ * calls `markConnected` once it is serving. `@effected/mcp-kit/guard`'s
155
+ * `McpGuard.run` is this guard with an MCP stdio launch in its `load`.
156
+ *
157
+ * - This entrypoint has no runtime import at all, so a throw while the
158
+ * server graph evaluates is still reported on stderr.
159
+ * - A `load` that rejects is reported as `startup failed` and exits 1
160
+ * whatever the policy. Left to a log-only rejection listener, it would let
161
+ * the event loop drain and exit 0 with no server.
162
+ * - Installing an `unhandledRejection` listener switches off Node's default
163
+ * of throwing on one, so `"log"` really does keep the process running.
164
+ * - An exit from a guard skips every finalizer the server registered.
165
+ * - Every report is one line: `<label>: uncaughtException (<origin>): …`,
166
+ * `<label>: unhandledRejection: …` or `<label>: startup failed: …`.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * import { ProcessGuard } from "@effected/kit/guard";
171
+ *
172
+ * await ProcessGuard.run({
173
+ * label: "my-lsp",
174
+ * host: process,
175
+ * policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
176
+ * load: async (guard) => {
177
+ * const { startServer } = await import("./server.js");
178
+ * await startServer();
179
+ * guard.markConnected();
180
+ * },
181
+ * });
182
+ * ```
183
+ *
184
+ * @public
185
+ */
186
+ export declare class ProcessGuard {
187
+ private constructor();
188
+ /**
189
+ * Parse a test-only crash-injection setting into {@link ProcessGuardOptions.injectCrash}: `<at>:<kind>`, where `at`
190
+ * is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
191
+ * value, is `undefined` (no injection), so a launcher can pass an environment variable straight through and every
192
+ * launcher shares one grammar.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * ProcessGuard.parseInjectCrash("connected:unhandledRejection"); // => { at: "connected", kind: "unhandledRejection" }
197
+ * ProcessGuard.parseInjectCrash("later:boom"); // => undefined
198
+ * ```
199
+ */
200
+ static readonly parseInjectCrash: (value: string | undefined) => ProcessGuardInjection | undefined;
201
+ /** Install the guards, then run `load`. Resolves once `load` has resolved. */
202
+ static readonly run: (options: ProcessGuardOptions) => Promise<void>;
203
+ }
204
+ export type { ProcessGuardControl, ProcessGuardHost, ProcessGuardInjection, ProcessGuardOptions, ProcessGuardPolicy };
205
+ //# sourceMappingURL=guard.d.ts.map
package/guard.js ADDED
@@ -0,0 +1,3 @@
1
+ import { ProcessGuard } from "./ProcessGuard.js";
2
+
3
+ export { ProcessGuard };