@effected/env 0.1.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/Audience.js +94 -0
- package/EnvOverride.js +109 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/RuntimeEnv.js +117 -0
- package/TerminalEnv.js +144 -0
- package/index.d.ts +442 -0
- package/index.js +6 -0
- package/internal/agentCi.js +69 -0
- package/internal/colorDepth.js +128 -0
- package/internal/envRecord.js +24 -0
- package/internal/keys.js +19 -0
- package/internal/osc8/detect.js +148 -0
- package/internal/osc8/env.js +21 -0
- package/internal/osc8/semver.js +46 -0
- package/internal/osc8/terminals.js +319 -0
- package/internal/osc8/wrappers.js +23 -0
- package/package.json +48 -0
- package/tsdoc-metadata.json +11 -0
package/Audience.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { CurrentRuntimeEnv } from "./RuntimeEnv.js";
|
|
2
|
+
import { Config, Context, Effect, Layer, Option } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/Audience.ts
|
|
5
|
+
const KINDS = [
|
|
6
|
+
"human",
|
|
7
|
+
"agent",
|
|
8
|
+
"ci"
|
|
9
|
+
];
|
|
10
|
+
/**
|
|
11
|
+
* Who the output is for, decided once.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Precedence: a valid override environment variable, then an agent, then CI, then a human, so an agent inside a
|
|
15
|
+
* CI job gets agent output. A human typing a command inside an agent-detected shell is still detected as an
|
|
16
|
+
* agent, never refused: the override variable flips it back. The shape is one immutable value, so the service is
|
|
17
|
+
* provided with `Layer.succeed` and there is no `Layer.mock` to reach for.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { Audience, CurrentRuntimeEnv } from "@effected/env"
|
|
22
|
+
* import { Effect, Layer } from "effect"
|
|
23
|
+
*
|
|
24
|
+
* const AudienceLive = Audience.layer({ envVar: "MYTOOL_AUDIENCE" }).pipe(Layer.provide(CurrentRuntimeEnv.layer))
|
|
25
|
+
*
|
|
26
|
+
* const program = Effect.gen(function* () {
|
|
27
|
+
* const { kind, source } = yield* Audience
|
|
28
|
+
* return `${kind} (${source})`
|
|
29
|
+
* }).pipe(Effect.provide(AudienceLive))
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
var Audience = class Audience extends Context.Service()("@effected/env/Audience") {
|
|
35
|
+
/**
|
|
36
|
+
* Decide the audience from `CurrentRuntimeEnv`, and from the override variable named by `options.envVar`.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* The variable is read through `Config`, lower-cased and matched against `human`, `agent` and `ci`. An empty
|
|
40
|
+
* or absent variable is unset. An invalid value logs one warning through `Effect.logWarning` and falls back to
|
|
41
|
+
* detection; it never fails the run. That warning is once per layer build, so building the layer a second time
|
|
42
|
+
* warns again. The warning goes through `Effect.logWarning`, and Effect's default logger writes to stdout unless
|
|
43
|
+
* `References.LogToStderr` is set: an MCP server or any stdio-sensitive host must route logs to stderr (the `cli`
|
|
44
|
+
* package's `CliLogger` does). Without `options.envVar` the audience is always detected. A layer-returning function
|
|
45
|
+
* mints a fresh layer per call: call it once and bind the result to a constant.
|
|
46
|
+
*
|
|
47
|
+
* @param options - `envVar` names the override variable
|
|
48
|
+
*/
|
|
49
|
+
static layer(options) {
|
|
50
|
+
return Layer.effect(Audience, Effect.gen(function* () {
|
|
51
|
+
const runtimeEnv = yield* CurrentRuntimeEnv;
|
|
52
|
+
const detected = {
|
|
53
|
+
kind: Audience.detect(runtimeEnv),
|
|
54
|
+
source: "detected"
|
|
55
|
+
};
|
|
56
|
+
const envVar = options?.envVar;
|
|
57
|
+
if (envVar === void 0) return detected;
|
|
58
|
+
const raw = yield* Config.option(Config.String(envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
59
|
+
if (Option.isNone(raw) || raw.value === "") return detected;
|
|
60
|
+
const value = raw.value.toLowerCase();
|
|
61
|
+
const kind = KINDS.find((candidate) => candidate === value);
|
|
62
|
+
if (kind !== void 0) return {
|
|
63
|
+
kind,
|
|
64
|
+
source: "override"
|
|
65
|
+
};
|
|
66
|
+
yield* Effect.logWarning(`${envVar}=${raw.value} is not one of ${KINDS.join("|")}; ignoring it`);
|
|
67
|
+
return detected;
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A fixed audience that touches neither `CurrentRuntimeEnv` nor `Config`.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* `source` defaults to `override`, since a test that fixes the kind has decided it. Pass `detected` to test a
|
|
75
|
+
* layer stacked on top, such as a CLI flag that only applies when the environment variable did not decide, or
|
|
76
|
+
* `flag` to test a consumer of that layer's result.
|
|
77
|
+
*
|
|
78
|
+
* @param kind - the audience to fix
|
|
79
|
+
* @param source - what decided it: `override` (the default), `detected`, or `flag`
|
|
80
|
+
*/
|
|
81
|
+
static layerTest = (kind, source = "override") => Layer.succeed(Audience, {
|
|
82
|
+
kind,
|
|
83
|
+
source
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* The audience a {@link RuntimeEnv} implies, ignoring any override: an agent, else CI, else a human.
|
|
87
|
+
*
|
|
88
|
+
* @param env - the runtime snapshot to decide from
|
|
89
|
+
*/
|
|
90
|
+
static detect = (env) => Option.isSome(env.agent) ? "agent" : Option.isSome(env.ci) ? "ci" : "human";
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
export { Audience };
|
package/EnvOverride.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Audience } from "./Audience.js";
|
|
2
|
+
import { Config, ConfigProvider, Effect, Option } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/EnvOverride.ts
|
|
5
|
+
const isProvider = (source) => typeof source.load === "function";
|
|
6
|
+
/**
|
|
7
|
+
* Reads an environment variable that picks a mode within an audience.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The kit never learns a consumer's literals: the caller passes, per audience, the values it accepts.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var EnvOverride = class EnvOverride {
|
|
15
|
+
constructor() {}
|
|
16
|
+
/**
|
|
17
|
+
* Read `options.envVar` and say what happened, without logging.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* `accepted` is the literal the current audience accepts (matching is case-insensitive and yields the
|
|
21
|
+
* literal), `rejected` is a set value it does not, with the value as written, the audience and the literals
|
|
22
|
+
* that audience accepts, so the caller owns the wording, the stream and any once-per-run dedupe. Unset and
|
|
23
|
+
* empty are neither. At most one of the two is `Some`. {@link EnvOverride.read} is the logging convenience.
|
|
24
|
+
*
|
|
25
|
+
* The variable is read from the ambient `ConfigProvider` unless `source` is given: a record (read fresh on every
|
|
26
|
+
* call, so a host passing `process.env` sees each change, and an `undefined` value is unset) or a `ConfigProvider`
|
|
27
|
+
* of its own.
|
|
28
|
+
*
|
|
29
|
+
* Without `source`, every read takes the `ConfigProvider` of the fiber that runs it, so a reader built once at
|
|
30
|
+
* module level stays testable: a test provides its own provider around the read.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* const consoleMode = EnvOverride.readResult({ envVar: "MYTOOL_CONSOLE", accepts })
|
|
34
|
+
* // in a test:
|
|
35
|
+
* consoleMode.pipe(
|
|
36
|
+
* Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ MYTOOL_CONSOLE: "silent" })),
|
|
37
|
+
* )
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* `source` is for a long-lived host that must re-read a record on each call (`process.env` in a watch-mode runner,
|
|
41
|
+
* where core's default provider snapshots the environment once). It is fixed when the options object is built, so a
|
|
42
|
+
* host that wants it AND testability builds the options inside a function that takes the source:
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const consoleModeFrom = (source: Readonly<Record<string, string | undefined>>) =>
|
|
46
|
+
* EnvOverride.readResult({ envVar: "MYTOOL_CONSOLE", accepts, source })
|
|
47
|
+
* // production: consoleModeFrom(process.env); a test: consoleModeFrom({ MYTOOL_CONSOLE: "silent" })
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @param options - `envVar` is the variable; `accepts` lists, per audience, the literals it accepts; `source`, if
|
|
51
|
+
* given, is where the variable is read in place of the ambient environment: a long-lived host's record, re-read
|
|
52
|
+
* on every call, or a `ConfigProvider`; omit it to read the fiber's `ConfigProvider`, which a test provides
|
|
53
|
+
*/
|
|
54
|
+
static readResult(options) {
|
|
55
|
+
return Effect.gen(function* () {
|
|
56
|
+
const { kind } = yield* Audience;
|
|
57
|
+
const config = Config.option(Config.String(options.envVar));
|
|
58
|
+
const { source } = options;
|
|
59
|
+
const raw = source === void 0 ? yield* config.pipe(Effect.orElseSucceed(() => Option.none())) : yield* config.parse(isProvider(source) ? source : ConfigProvider.fromEnvRecord({ ...source })).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
60
|
+
if (Option.isNone(raw) || raw.value === "") return {
|
|
61
|
+
audience: kind,
|
|
62
|
+
accepted: Option.none(),
|
|
63
|
+
rejected: Option.none()
|
|
64
|
+
};
|
|
65
|
+
const accepts = options.accepts[kind];
|
|
66
|
+
const match = accepts.find((literal) => literal.toLowerCase() === raw.value.toLowerCase());
|
|
67
|
+
if (match !== void 0) return {
|
|
68
|
+
audience: kind,
|
|
69
|
+
accepted: Option.some(match),
|
|
70
|
+
rejected: Option.none()
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
audience: kind,
|
|
74
|
+
accepted: Option.none(),
|
|
75
|
+
rejected: Option.some({
|
|
76
|
+
value: raw.value,
|
|
77
|
+
audience: kind,
|
|
78
|
+
accepts
|
|
79
|
+
})
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read `options.envVar` and accept it only when the current audience accepts it.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Matching is case-insensitive and yields the accepted literal, so the result narrows to the union of every
|
|
88
|
+
* audience's literals. An unset or empty variable is `None`. A value the current audience does not accept logs
|
|
89
|
+
* one warning through `Effect.logWarning`, naming the accepted values, and is `None`; it never fails the run.
|
|
90
|
+
* The warning is once per read, so reading again warns again. It goes through `Effect.logWarning`, and Effect's
|
|
91
|
+
* default logger writes to stdout unless `References.LogToStderr` is set: an MCP server or any stdio-sensitive
|
|
92
|
+
* host must route logs to stderr (the `cli` package's `CliLogger` does).
|
|
93
|
+
*
|
|
94
|
+
* @param options - `envVar` is the variable; `accepts` lists, per audience, the literals it accepts
|
|
95
|
+
*/
|
|
96
|
+
static read(options) {
|
|
97
|
+
return Effect.gen(function* () {
|
|
98
|
+
const { accepted, rejected } = yield* EnvOverride.readResult(options);
|
|
99
|
+
if (Option.isSome(rejected)) {
|
|
100
|
+
const { value, audience, accepts } = rejected.value;
|
|
101
|
+
yield* Effect.logWarning(`${options.envVar}=${value} is not accepted for the ${audience} audience (accepts ${accepts.join("|")}); ignoring it`);
|
|
102
|
+
}
|
|
103
|
+
return accepted;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
export { EnvOverride };
|
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/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @effected/env
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/env)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Effect-native environment detection for any front end of a tool: **who is running it** (an AI agent, a CI job, or neither), **what the terminal can do** (colour level, OSC 8 hyperlinks and width, per stream), and **who the output is for** (a human, an agent or CI). Every answer is read once through `Config`, never from `process`, and every service has a `layerTest`, so a test sets the environment it wants without touching a global.
|
|
9
|
+
|
|
10
|
+
> **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
|
|
11
|
+
> Effect v4 (`effect` `^4.0.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-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
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. Full
|
|
18
|
+
> policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
19
|
+
|
|
20
|
+
## Why @effected/env
|
|
21
|
+
|
|
22
|
+
Every tool that prints ends up asking these questions, usually by reading `process.env` and `process.stdout.isTTY` wherever the answer is needed. The answers then drift (the help text and the report disagree about colour), they cannot be tested without mutating globals, and the rules are subtle: `FORCE_COLOR` beats `NO_COLOR`, an empty `NO_COLOR` means unset, `TERM=dumb` is a terminal that cannot move the cursor, a Windows console sets no `TERM` at all yet draws truecolor, and an agent running inside CI should get agent output.
|
|
23
|
+
|
|
24
|
+
This package makes each decision once, as a service, from rules taken from established sources: colour depth from Node's own `getColorDepth` (with `OS=Windows_NT` standing in for its platform check, which gives a Windows terminal truecolor as Node does from Windows 10 build 14931), hyperlink support from std-osc8's terminal table, and agent detection from std-env's table.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @effected/env effect
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm add @effected/env effect
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Node.js >=24.11.0. `effect` v4 is the only peer. There are no runtime dependencies, no platform import and no `@effected/*` edge, so it runs unchanged on Node, Bun and Deno.
|
|
37
|
+
|
|
38
|
+
All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED`. Import from an ES module.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
Decide the audience once, as a service, and let anything that prints ask it:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Audience, CurrentRuntimeEnv } from "@effected/env";
|
|
46
|
+
import { Effect, Layer } from "effect";
|
|
47
|
+
|
|
48
|
+
// A valid MYTOOL_AUDIENCE beats detection; then an agent, then CI, then a human.
|
|
49
|
+
const AudienceLive = Audience.layer({ envVar: "MYTOOL_AUDIENCE" }).pipe(Layer.provide(CurrentRuntimeEnv.layer));
|
|
50
|
+
|
|
51
|
+
const program = Effect.gen(function* () {
|
|
52
|
+
const audience = yield* Audience;
|
|
53
|
+
if (audience.kind === "agent") {
|
|
54
|
+
// plain text, no escapes
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
Effect.runPromise(program.pipe(Effect.provide(AudienceLive)));
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
What the terminal can do comes from `TerminalEnv`, which reads core's `Stdio` and `Terminal` from your platform layer:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { TerminalEnv } from "@effected/env";
|
|
65
|
+
import { NodeServices } from "@effect/platform-node";
|
|
66
|
+
import { Effect } from "effect";
|
|
67
|
+
|
|
68
|
+
const describe = Effect.gen(function* () {
|
|
69
|
+
const terminal = yield* TerminalEnv;
|
|
70
|
+
return `${terminal.stdout.color} colour, ${terminal.width()} columns`;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
Effect.runPromise(describe.pipe(Effect.provide(TerminalEnv.layer()), Effect.provide(NodeServices.layer)));
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
In a test, fix the answers instead:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
80
|
+
import { Layer, Option } from "effect";
|
|
81
|
+
|
|
82
|
+
const Environment = Layer.mergeAll(
|
|
83
|
+
Audience.layerTest("agent"),
|
|
84
|
+
TerminalEnv.layerTest({ stdout: { isTerminal: true, color: "256", columns: Option.some(120) } }),
|
|
85
|
+
);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## What it exports
|
|
89
|
+
|
|
90
|
+
- **`RuntimeEnv`**: a schema-class snapshot of the agent, the CI and the terminal program, persistable as plain JSON. `RuntimeEnv.fromRecord(env)` builds one as a pure function.
|
|
91
|
+
- **`CurrentRuntimeEnv`**: that snapshot as a service. `layer` reads the ambient `ConfigProvider` once; `layerFrom(source)` reads a record or provider of your own, fresh on every use, for a long-lived host; `layerTest(overrides)` fixes it.
|
|
92
|
+
- **`TerminalEnv`**: per-stream `isTerminal`, colour level (`none`, `basic`, `256`, `truecolor`), `hyperlinks` and `columns`, plus `width(fallback)`. `layer()` needs `Stdio` and `Terminal`; `layerStdio()` needs only `Stdio`; `colorLevel("stdout")` decides colour alone.
|
|
93
|
+
- **`Audience`**: `human`, `agent` or `ci`, and the `source` that decided it. `layer({ envVar })` decides from `CurrentRuntimeEnv` and an override variable you name.
|
|
94
|
+
- **`EnvOverride`**: reads a variable that picks a mode *within* an audience, accepting only the literals you list per audience. `readResult` reports without logging; `read` logs one warning for a rejected value.
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
The guides and the full API reference are at [effected.spencerbeg.gs/env](https://effected.spencerbeg.gs/env). [`@effected/cli`](https://www.npmjs.com/package/@effected/cli) builds its whole presentation layer on these services.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
[MIT](LICENSE)
|
package/RuntimeEnv.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { detectAgent, detectCi } from "./internal/agentCi.js";
|
|
2
|
+
import { normalizeEnv, readEnv } from "./internal/envRecord.js";
|
|
3
|
+
import { detectOsc8 } from "./internal/osc8/detect.js";
|
|
4
|
+
import { allKeys } from "./internal/keys.js";
|
|
5
|
+
import { ConfigProvider, Context, Effect, Layer, Option, Schema } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/RuntimeEnv.ts
|
|
8
|
+
/**
|
|
9
|
+
* An `Option` field that encodes `None` as `null` and decodes when its key is absent, so a persisted snapshot keeps
|
|
10
|
+
* decoding after a field is added.
|
|
11
|
+
*/
|
|
12
|
+
const optionField = (schema) => Schema.OptionFromNullOr(schema).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(Option.none())));
|
|
13
|
+
/**
|
|
14
|
+
* A snapshot of who is running the program: the agent, the CI, and the terminal.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Each field is an `Option` in memory and `null` when absent on the wire, so the snapshot persists as plain JSON
|
|
18
|
+
* through `Schema.fromJsonString(RuntimeEnv)`. Every field decodes when its key is absent, so a persisted snapshot
|
|
19
|
+
* keeps decoding after a field is added. Built from `Config` only (no stream is consulted), so it is safe inside a
|
|
20
|
+
* stdio MCP server.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
var RuntimeEnv = class RuntimeEnv extends Schema.Class("@effected/env/RuntimeEnv")({
|
|
25
|
+
/**
|
|
26
|
+
* The AI agent running the process, as its family (`claude` for Claude Code, whatever `AI_AGENT` says it is
|
|
27
|
+
* beyond that), or `None`.
|
|
28
|
+
*/
|
|
29
|
+
agent: optionField(Schema.String),
|
|
30
|
+
/** The CI the process runs in: {@link CiName}, so a consumer can match it exhaustively, or `None`. */
|
|
31
|
+
ci: optionField(Schema.Literals(["github-actions", "generic"])),
|
|
32
|
+
/** The identified terminal program and its version when it exposes one, or `None`. */
|
|
33
|
+
terminal: optionField(Schema.Struct({
|
|
34
|
+
name: Schema.String,
|
|
35
|
+
version: optionField(Schema.String)
|
|
36
|
+
}))
|
|
37
|
+
}) {
|
|
38
|
+
/**
|
|
39
|
+
* The snapshot of an environment record, as a pure function: no `Config`, no `process`, no service.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* It is what {@link CurrentRuntimeEnv.layer} computes from the variables it reads, so the two agree on the same
|
|
43
|
+
* record. An `undefined` or empty value reads as unset, under every caller. Use it where a service is in the
|
|
44
|
+
* way: a long-lived host that holds its own environment record, or a renderer with no Effect context.
|
|
45
|
+
*
|
|
46
|
+
* @param env - variable name to value
|
|
47
|
+
*/
|
|
48
|
+
static fromRecord(env) {
|
|
49
|
+
const clean = normalizeEnv(env);
|
|
50
|
+
return RuntimeEnv.make({
|
|
51
|
+
agent: detectAgent(clean),
|
|
52
|
+
ci: detectCi(clean),
|
|
53
|
+
terminal: detectOsc8(clean, false, false).terminal
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The {@link RuntimeEnv} of the running process, as a service.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* The service's whole shape is one immutable value, so it is provided with `Layer.succeed` rather than mocked.
|
|
62
|
+
* `layer` reads the ambient `ConfigProvider` once when it is built; `layerTest` is the only way a test changes the
|
|
63
|
+
* environment.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* import { CurrentRuntimeEnv } from "@effected/env"
|
|
68
|
+
* import { Effect, Option } from "effect"
|
|
69
|
+
*
|
|
70
|
+
* const program = Effect.gen(function* () {
|
|
71
|
+
* const env = yield* CurrentRuntimeEnv
|
|
72
|
+
* return Option.getOrElse(env.agent, () => "no agent")
|
|
73
|
+
* }).pipe(Effect.provide(CurrentRuntimeEnv.layerTest({ agent: Option.some("claude") })))
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
var CurrentRuntimeEnv = class CurrentRuntimeEnv extends Context.Service()("@effected/env/CurrentRuntimeEnv") {
|
|
79
|
+
/**
|
|
80
|
+
* Reads the environment through `Config` once, when the layer is built. Requires nothing: the provider is read
|
|
81
|
+
* from the ambient `ConfigProvider`.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* Two things make this a frozen read, and a long-lived host (an MCP server, a watch-mode runner) trips on both.
|
|
85
|
+
* Core's default `ConfigProvider.fromEnv()` snapshots `process.env` once per process, so a change after the
|
|
86
|
+
* first read is never seen; and this is one static layer, memoized by reference, so two consumers that provide
|
|
87
|
+
* different providers in one graph share the first snapshot. Use {@link CurrentRuntimeEnv.layerFrom}, which is
|
|
88
|
+
* a fresh layer per call and per use, or provide a fresh `ConfigProvider` for every read.
|
|
89
|
+
*/
|
|
90
|
+
static layer = Layer.effect(this, Effect.map(readEnv(allKeys), (env) => RuntimeEnv.fromRecord(env)));
|
|
91
|
+
/**
|
|
92
|
+
* A snapshot of an explicit source: an environment record, or a `ConfigProvider` read instead of the ambient
|
|
93
|
+
* one.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* Each call returns a new layer, and the layer is `Layer.fresh`, so it is built again for every use rather
|
|
97
|
+
* than shared through the build's memo: two calls with different sources in one graph see different values,
|
|
98
|
+
* and a provider is read again each time the layer is used. A record is read when the layer is built, never
|
|
99
|
+
* at the call.
|
|
100
|
+
*
|
|
101
|
+
* @param source - a variable-name-to-value record, or a `ConfigProvider`
|
|
102
|
+
*/
|
|
103
|
+
static layerFrom = (source) => Layer.fresh(Layer.effect(CurrentRuntimeEnv, typeof source.load === "function" ? Effect.map(readEnv(allKeys).pipe(Effect.provideService(ConfigProvider.ConfigProvider, source)), (env) => RuntimeEnv.fromRecord(env)) : Effect.sync(() => RuntimeEnv.fromRecord(source))));
|
|
104
|
+
/**
|
|
105
|
+
* A fixed snapshot that never touches `Config`: every field is `None` unless `overrides` sets it.
|
|
106
|
+
*
|
|
107
|
+
* @param overrides - the fields to set
|
|
108
|
+
*/
|
|
109
|
+
static layerTest = (overrides = {}) => Layer.succeed(CurrentRuntimeEnv, RuntimeEnv.make({
|
|
110
|
+
agent: overrides.agent ?? Option.none(),
|
|
111
|
+
ci: overrides.ci ?? Option.none(),
|
|
112
|
+
terminal: overrides.terminal ?? Option.none()
|
|
113
|
+
}));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { CurrentRuntimeEnv, RuntimeEnv };
|
package/TerminalEnv.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readEnv } from "./internal/envRecord.js";
|
|
2
|
+
import { colorDepth, colorKeys } from "./internal/colorDepth.js";
|
|
3
|
+
import { detectOsc8 } from "./internal/osc8/detect.js";
|
|
4
|
+
import { allKeys } from "./internal/keys.js";
|
|
5
|
+
import { Context, Effect, Layer, Option, Stdio, Terminal } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/TerminalEnv.ts
|
|
8
|
+
const make = (env, isTTY, columns) => {
|
|
9
|
+
const links = detectOsc8(env, isTTY.stdout, isTTY.stderr);
|
|
10
|
+
const fromEnv = /^\d+$/.test(env.COLUMNS ?? "") ? Number(env.COLUMNS) : 0;
|
|
11
|
+
return {
|
|
12
|
+
stdinIsTerminal: isTTY.stdin,
|
|
13
|
+
stdout: {
|
|
14
|
+
isTerminal: isTTY.stdout,
|
|
15
|
+
color: colorDepth(env, isTTY.stdout),
|
|
16
|
+
hyperlinks: links.stdout,
|
|
17
|
+
columns
|
|
18
|
+
},
|
|
19
|
+
stderr: {
|
|
20
|
+
isTerminal: isTTY.stderr,
|
|
21
|
+
color: colorDepth(env, isTTY.stderr),
|
|
22
|
+
hyperlinks: links.stderr,
|
|
23
|
+
columns
|
|
24
|
+
},
|
|
25
|
+
width: (fallback = 80) => Option.getOrElse(columns, () => fromEnv || fallback)
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** Read the TTY facts from `Stdio` and the environment from `Config`, with the width the caller already knows. */
|
|
29
|
+
const snapshot = (options, columns) => Effect.gen(function* () {
|
|
30
|
+
const stdio = yield* Stdio.Stdio;
|
|
31
|
+
const stdin = yield* stdio.stdinIsTerminal;
|
|
32
|
+
const stdout = yield* stdio.stdoutIsTerminal;
|
|
33
|
+
const stderr = yield* options?.stderrIsTerminal ?? Effect.succeed(stdout);
|
|
34
|
+
const env = yield* readEnv([...allKeys, "COLUMNS"]);
|
|
35
|
+
return make(env, {
|
|
36
|
+
stdin,
|
|
37
|
+
stdout,
|
|
38
|
+
stderr
|
|
39
|
+
}, columns);
|
|
40
|
+
});
|
|
41
|
+
/** The quiet terminal {@link TerminalEnv.layerTest} starts from: not a terminal, no colour, no links, no width. */
|
|
42
|
+
const quiet = {
|
|
43
|
+
isTerminal: false,
|
|
44
|
+
color: "none",
|
|
45
|
+
hyperlinks: false,
|
|
46
|
+
columns: Option.none()
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* What the terminal can do: per-stream colour level, hyperlink support and columns, and the width to lay out at.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* `layer` reads the environment through `Config` and the TTY state through `Stdio` and `Terminal` once, when it
|
|
53
|
+
* is built. `layerTest` is the only way a test changes it, and defaults to a quiet terminal so a test opts into
|
|
54
|
+
* colour. `colorLevel` needs `Stdio` alone, so a caller that only decides colour never requires `Terminal`.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { TerminalEnv } from "@effected/env"
|
|
59
|
+
* import { Effect, Option } from "effect"
|
|
60
|
+
*
|
|
61
|
+
* const program = Effect.gen(function* () {
|
|
62
|
+
* const terminal = yield* TerminalEnv
|
|
63
|
+
* return { color: terminal.stdout.color, width: terminal.width() }
|
|
64
|
+
* }).pipe(Effect.provide(TerminalEnv.layerTest({ stdout: { color: "256", columns: Option.some(100) } })))
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
var TerminalEnv = class TerminalEnv extends Context.Service()("@effected/env/TerminalEnv") {
|
|
70
|
+
/**
|
|
71
|
+
* Build the snapshot from `Stdio`, `Terminal` and the ambient `ConfigProvider`.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* `Stdio` reports only stdout, so stderr mirrors it unless `options.stderrIsTerminal` supplies its own answer.
|
|
75
|
+
* A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
|
|
76
|
+
*
|
|
77
|
+
* @param options - `stderrIsTerminal` overrides the stderr TTY check
|
|
78
|
+
*/
|
|
79
|
+
static layer(options) {
|
|
80
|
+
return Layer.effect(TerminalEnv, Effect.gen(function* () {
|
|
81
|
+
const width = yield* (yield* Terminal.Terminal).columns;
|
|
82
|
+
return yield* snapshot(options, width > 0 ? Option.some(width) : Option.none());
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The snapshot from `Stdio` and the ambient `ConfigProvider` alone: it never requires or builds `Terminal`, and
|
|
87
|
+
* reports no columns.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* Everything else is what {@link TerminalEnv.layer} reports: the TTY facts come from `Stdio`, the colour and
|
|
91
|
+
* hyperlink decisions from the environment, and `width()` falls back to `COLUMNS`, then the fallback, since no
|
|
92
|
+
* terminal width is known. Use it where building the platform `Terminal` has a cost, for example a long-lived
|
|
93
|
+
* host, where `NodeTerminal` listens on `process.stdin`. A layer-returning function mints a fresh layer per
|
|
94
|
+
* call: call it once and bind the result to a constant.
|
|
95
|
+
*
|
|
96
|
+
* @param options - `stderrIsTerminal` overrides the stderr TTY check
|
|
97
|
+
*/
|
|
98
|
+
static layerStdio(options) {
|
|
99
|
+
return Layer.effect(TerminalEnv, snapshot(options, Option.none()));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A fixed snapshot that touches neither `Stdio`, `Terminal` nor `Config`. Everything is quiet unless a field of
|
|
103
|
+
* `partial` sets it.
|
|
104
|
+
*
|
|
105
|
+
* @param partial - the fields to set; `stdout` and `stderr` are merged over the quiet stream
|
|
106
|
+
*/
|
|
107
|
+
static layerTest = (partial) => {
|
|
108
|
+
const stdout = {
|
|
109
|
+
...quiet,
|
|
110
|
+
...partial?.stdout
|
|
111
|
+
};
|
|
112
|
+
return Layer.succeed(TerminalEnv, {
|
|
113
|
+
stdinIsTerminal: partial?.stdinIsTerminal ?? false,
|
|
114
|
+
stdout,
|
|
115
|
+
stderr: {
|
|
116
|
+
...quiet,
|
|
117
|
+
...partial?.stderr
|
|
118
|
+
},
|
|
119
|
+
width: (fallback = 80) => Option.getOrElse(stdout.columns, () => fallback)
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The colour level of a stream.
|
|
124
|
+
*
|
|
125
|
+
* @remarks
|
|
126
|
+
* An ambient `TerminalEnv`, when one is provided, answers with its stdout colour, so a test that fixes the
|
|
127
|
+
* terminal with `layerTest` also fixes this. Without one it is decided from `Config` and `Stdio` alone. It
|
|
128
|
+
* requires only `Stdio`, never `Terminal` or `TerminalEnv`, so a caller that only decides colour (a CLI's
|
|
129
|
+
* output formatter) keeps a `Stdio`-only requirement.
|
|
130
|
+
*
|
|
131
|
+
* @param _stream - the stream to decide for; only `stdout` is available, since `Stdio` reports no other
|
|
132
|
+
*/
|
|
133
|
+
static colorLevel(_stream) {
|
|
134
|
+
return Effect.gen(function* () {
|
|
135
|
+
const ambient = yield* Effect.serviceOption(TerminalEnv);
|
|
136
|
+
if (Option.isSome(ambient)) return ambient.value.stdout.color;
|
|
137
|
+
const isTTY = yield* (yield* Stdio.Stdio).stdoutIsTerminal;
|
|
138
|
+
return colorDepth(yield* readEnv(colorKeys), isTTY);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
//#endregion
|
|
144
|
+
export { TerminalEnv };
|