@executablemd/runtime 0.6.0 → 0.8.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/esm/test/stubs.js CHANGED
@@ -19,13 +19,34 @@
19
19
  * });
20
20
  * ```
21
21
  */
22
+ import { Stdio } from "@effectionx/process";
22
23
  import { API } from "../apis.js";
24
+ import { SERVICE_HOSTNAME } from "../service.js";
25
+ /** Install a provider-neutral scoped service attachment stub. */
26
+ export function* useStubService(endpoint) {
27
+ if (endpoint.hostname !== SERVICE_HOSTNAME) {
28
+ throw new Error("stub service endpoint must use 127.0.0.1");
29
+ }
30
+ if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65_535) {
31
+ throw new Error("stub service endpoint port must be an integer from 1 through 65535");
32
+ }
33
+ const exact = Object.freeze({ hostname: SERVICE_HOSTNAME, port: endpoint.port });
34
+ yield* API.Service.around({
35
+ // deno-lint-ignore require-yield
36
+ *start() {
37
+ return { endpoint: exact };
38
+ },
39
+ });
40
+ }
23
41
  /**
24
42
  * Install an in-memory filesystem stub.
25
43
  *
26
44
  * - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
27
45
  * - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
28
46
  * - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
47
+ * - the writing half — `writeTextFile`, `ensureDir`, `rename`, `remove`, and
48
+ * `realpath` — is not stubbed and reaches the real filesystem. A test that
49
+ * exercises a document writing files wants a real temporary directory.
29
50
  *
30
51
  * The `files` object is captured **by reference** — mutating it between
31
52
  * operations changes what `readTextFile`/`stat` see. This is useful for
@@ -54,15 +75,20 @@ export function* useStubFs(files) {
54
75
  *
55
76
  * Recognizes `bash -c "echo ..."` and returns the echo'd text as stdout.
56
77
  * All other commands return the script text as stdout with exit code 0.
78
+ *
79
+ * It answers like a real child: the text is written to the stdio chain as it
80
+ * "arrives", so whatever encloses the call sees it the way it would see a
81
+ * child's, and it is retained in the outcome only when the caller asked
82
+ * for retention. A stub that always returned strings would let a document
83
+ * render output the contract says was already displayed.
57
84
  */
58
85
  export function* useEchoExec() {
59
86
  yield* API.Process.around({
60
87
  *exec([options], _next) {
61
88
  const script = (options.command[2] ?? "").trim();
62
- if (script.startsWith("echo ")) {
63
- return { exitCode: 0, stdout: script.slice(5) + "\n", stderr: "" };
64
- }
65
- return { exitCode: 0, stdout: script + "\n", stderr: "" };
89
+ const text = script.startsWith("echo ") ? script.slice(5) + "\n" : script + "\n";
90
+ yield* Stdio.operations.stdout(encoder.encode(text));
91
+ return retained(options, { exitCode: 0, stdout: text, stderr: "" });
66
92
  },
67
93
  });
68
94
  }
@@ -73,8 +99,17 @@ export function* useEchoExec() {
73
99
  */
74
100
  export function* useFailingExec(exitCode, stderr = "command failed") {
75
101
  yield* API.Process.around({
76
- *exec(_args, _next) {
77
- return { exitCode, stdout: "", stderr };
102
+ *exec([options], _next) {
103
+ yield* Stdio.operations.stderr(encoder.encode(stderr));
104
+ return retained(options, { exitCode, stdout: "", stderr });
78
105
  },
79
106
  });
80
107
  }
108
+ const encoder = new TextEncoder();
109
+ /** What the caller asked to keep, so a stub cannot retain more than a call would. */
110
+ function retained(options, outcome) {
111
+ if (options.retain === false) {
112
+ return { exitCode: outcome.exitCode, stdout: undefined, stderr: undefined };
113
+ }
114
+ return outcome;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@executablemd/runtime",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Runtime host APIs for executable.md documents.",
5
5
  "homepage": "https://executable.md",
6
6
  "repository": {
@@ -20,6 +20,12 @@
20
20
  "default": "./esm/mod.js"
21
21
  }
22
22
  },
23
+ "./files": {
24
+ "import": {
25
+ "types": "./types/files.d.ts",
26
+ "default": "./esm/files.js"
27
+ }
28
+ },
23
29
  "./test": {
24
30
  "import": {
25
31
  "types": "./types/test/mod.d.ts",
@@ -30,11 +36,11 @@
30
36
  "scripts": {},
31
37
  "dependencies": {
32
38
  "@effectionx/context-api": "0.6.0",
33
- "@effectionx/fetch": "0.2.0",
39
+ "@effectionx/fetch": "0.2.1",
34
40
  "@effectionx/fs": "0.3.0",
35
- "@effectionx/node": "0.2.4",
36
41
  "@effectionx/process": "0.8.1",
37
- "effection": "4.1.0-alpha.10"
42
+ "effection": "4.1.0",
43
+ "@effectionx/node": "0.2.4"
38
44
  },
39
45
  "_generatedBy": "dnt@dev"
40
46
  }
package/types/apis.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Runtime Context APIs — platform I/O operations with pluggable middleware.
3
3
  *
4
- * Five domain-specific context APIs built on `@effectionx/context-api`.
4
+ * Five host-backed domain APIs plus the provider-neutral Service Api, built on
5
+ * `@effectionx/context-api`.
5
6
  * Each API provides default Node.js implementations. Use `.around()` to
6
7
  * install middleware (mocking, instrumentation, sandboxing) scoped to the
7
8
  * current Effection scope.
@@ -25,17 +26,29 @@
25
26
  * });
26
27
  * ```
27
28
  *
28
- * ## Why four separate APIs?
29
+ * ## Why separate APIs?
29
30
  *
30
31
  * - **Process** — subprocess lifecycle has its own cancellation semantics
31
32
  * (killing processes on scope teardown). Middleware targets exec only.
32
- * - **Fs** — readTextFile, stat, and glob form a cohesive file-IO surface
33
- * used together for component resolution and replay guards.
33
+ * - **Fs** — the low-level host file surface: reading, writing, and inspecting
34
+ * paths the engine itself resolves, for component lookup, replay guards, and
35
+ * the root document. It is the host adapter's own dependency, not the
36
+ * boundary a document's paths cross.
37
+ * - **Files** — document filesystem access, in whole semantic operations
38
+ * (`files.ts`). `<File>`, `<Glob>`, and `<TempDir>` speak only this Api, so
39
+ * the same document means the same thing whether its paths resolve in the
40
+ * caller's filesystem or in a run-owned logical one. Its terminal handler
41
+ * throws: an uninstalled provider must not silently reach the host.
34
42
  * - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
35
43
  * with Fs or Process would blur cancellation boundaries.
36
- * - **Env** — synchronous host metadata (env vars, platform). Kept as a
37
- * context-api despite being sync because tests use `.around()` to mock
38
- * platform/env for deterministic replay testing.
44
+ * - **Env** — the host itself: metadata (env vars, platform) plus the two
45
+ * capabilities only the entrypoint can supply, `command` (how to re-invoke
46
+ * this xmd) and `compile` (how this host loads a generated module). Tests
47
+ * use `.around()` to mock platform/env for deterministic replay; an
48
+ * entrypoint installs its `command` and `compile` with `{ at: "min" }` so
49
+ * ordinary middleware can wrap them.
50
+ * - **Service** — scoped service attachment. Its terminal handler requires an
51
+ * explicit host provider and never detects or imports a runtime.
39
52
  *
40
53
  * ## Middleware
41
54
  *
@@ -54,10 +67,13 @@
54
67
  * ## Test stubs
55
68
  *
56
69
  * Common stubs are provided by `@executablemd/runtime/test`:
57
- * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`.
70
+ * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`,
71
+ * `useStubService(endpoint)`.
58
72
  */
59
73
  import { type Api } from "@effectionx/context-api";
60
74
  import type { Operation } from "effection";
75
+ import { Files } from "./files.js";
76
+ import { Service } from "./service.js";
61
77
  /**
62
78
  * Result of a `stat` call.
63
79
  *
@@ -90,21 +106,54 @@ export interface RuntimeFetchResponse {
90
106
  /** Read the response body as text. */
91
107
  text(): Operation<string>;
92
108
  }
109
+ /**
110
+ * What a finished child process reports.
111
+ *
112
+ * `stdout` and `stderr` are the text a caller asked to be retained. A caller
113
+ * that asked for none gets `undefined` rather than an empty string: nothing was
114
+ * accumulated, and "no output" and "not retained" are different answers.
115
+ */
116
+ export interface ProcessOutcome {
117
+ exitCode: number;
118
+ stdout: string | undefined;
119
+ stderr: string | undefined;
120
+ }
121
+ export interface ProcessExecOptions {
122
+ command: string[];
123
+ cwd?: string;
124
+ env?: Record<string, string>;
125
+ timeout?: number;
126
+ /**
127
+ * Retain the child's output in the outcome. Default `true`.
128
+ *
129
+ * Retention is the caller's explicit choice, never an inference: a run that
130
+ * keeps no diagnostic record asks for `false` and the text is forwarded to
131
+ * whatever is displaying it without ever being accumulated here.
132
+ */
133
+ retain?: boolean;
134
+ }
93
135
  interface ProcessHandler {
94
- exec(options: {
95
- command: string[];
96
- cwd?: string;
97
- env?: Record<string, string>;
98
- timeout?: number;
99
- }): Operation<{
100
- exitCode: number;
101
- stdout: string;
102
- stderr: string;
103
- }>;
136
+ exec(options: ProcessExecOptions): Operation<ProcessOutcome>;
104
137
  }
105
138
  interface FsHandler {
106
139
  readTextFile(path: string): Operation<string>;
107
140
  stat(path: string): Operation<StatResult>;
141
+ /**
142
+ * Files and symbolic links beneath `root` whose path relative to it matches
143
+ * `patterns` and matches none of `exclude`. Paths come back relative and
144
+ * POSIX-separated, which is what both pattern lists are matched against, so a
145
+ * caller's patterns mean the same thing on every platform.
146
+ *
147
+ * Exclusion is per candidate: an entry is dropped when its own relative path
148
+ * matches. Directories are not candidates and are not reported, so an
149
+ * exclusion matching a directory does not remove what is beneath it — only a
150
+ * pattern ending in `/**`, which provably covers every descendant, lets the
151
+ * subtree be skipped rather than walked and filtered.
152
+ *
153
+ * Symbolic links are reported but never followed: a link's own path can
154
+ * match, and a link to a directory is not descended into. Traversal
155
+ * therefore stays inside `root` and cannot cycle.
156
+ */
108
157
  glob(options: {
109
158
  patterns: string[];
110
159
  root: string;
@@ -113,6 +162,20 @@ interface FsHandler {
113
162
  path: string;
114
163
  isFile: boolean;
115
164
  }>>;
165
+ writeTextFile(path: string, content: string): Operation<void>;
166
+ ensureDir(path: string): Operation<void>;
167
+ rename(from: string, to: string): Operation<void>;
168
+ remove(path: string, options?: {
169
+ recursive?: boolean;
170
+ force?: boolean;
171
+ }): Operation<void>;
172
+ /**
173
+ * The canonical path, with every symlink resolved, or `undefined` when the
174
+ * path does not exist. Like `stat`, "it isn't there" is an answer rather
175
+ * than a failure — a caller resolving a path it is about to create asks
176
+ * about ancestors that may legitimately be missing.
177
+ */
178
+ realpath(path: string): Operation<string | undefined>;
116
179
  }
117
180
  interface FetchHandler {
118
181
  fetch(input: string, init?: {
@@ -122,6 +185,12 @@ interface FetchHandler {
122
185
  timeout?: number;
123
186
  }): Operation<RuntimeFetchResponse>;
124
187
  }
188
+ /**
189
+ * A compiled eval block accepts the document binding environment and returns
190
+ * an Operation. Current compilers implement it with generated `function*`
191
+ * modules, but callers do not depend on that representation.
192
+ */
193
+ export type EvalBlock = (env: Record<string, unknown>) => Operation<unknown>;
125
194
  interface EnvHandler {
126
195
  cwd(): Operation<string>;
127
196
  env(name: string): Operation<string | undefined>;
@@ -129,26 +198,75 @@ interface EnvHandler {
129
198
  os: string;
130
199
  arch: string;
131
200
  }>;
132
- }
133
- interface CompilerHandler {
201
+ command(args?: string[]): Operation<string[]>;
134
202
  compile(source: string, options?: {
135
203
  imports: string[];
136
- }): Operation<(env: Record<string, unknown>) => Generator<unknown, unknown, unknown>>;
204
+ }): Operation<EvalBlock>;
137
205
  }
138
206
  export declare const API: {
139
207
  Process: Api<ProcessHandler>;
140
208
  Fs: Api<FsHandler>;
209
+ Files: typeof Files;
141
210
  Fetch: Api<FetchHandler>;
142
211
  Env: Api<EnvHandler>;
143
- Compiler: Api<CompilerHandler>;
212
+ Service: typeof Service;
144
213
  };
145
- export declare const exec: typeof API.Process.operations.exec;
214
+ /**
215
+ * Run a child process.
216
+ *
217
+ * A caller that says nothing about retention keeps the output, which is what
218
+ * every caller with something to read wants and what callers have always had.
219
+ * Asking for `retain: false` keeps the exit status alone, and the overloads say
220
+ * so: there is no string to read on that path, and the type refuses to pretend
221
+ * otherwise. Core, which decides retention per block, calls the Api operation
222
+ * directly and handles both.
223
+ */
224
+ export declare function exec(options: ProcessExecOptions & {
225
+ retain: false;
226
+ }): Operation<{
227
+ exitCode: number;
228
+ stdout: undefined;
229
+ stderr: undefined;
230
+ }>;
231
+ export declare function exec(options: ProcessExecOptions & {
232
+ retain?: true;
233
+ }): Operation<{
234
+ exitCode: number;
235
+ stdout: string;
236
+ stderr: string;
237
+ }>;
146
238
  export declare const readTextFile: typeof API.Fs.operations.readTextFile;
147
239
  export declare const stat: typeof API.Fs.operations.stat;
148
240
  export declare const glob: typeof API.Fs.operations.glob;
241
+ export declare const writeTextFile: typeof API.Fs.operations.writeTextFile;
242
+ export declare const ensureDir: typeof API.Fs.operations.ensureDir;
243
+ export declare const rename: typeof API.Fs.operations.rename;
244
+ export declare const remove: typeof API.Fs.operations.remove;
245
+ export declare const realpath: typeof API.Fs.operations.realpath;
149
246
  export declare const fetch: typeof API.Fetch.operations.fetch;
150
247
  export declare const env: typeof API.Env.operations.env;
151
248
  export declare const cwd: typeof API.Env.operations.cwd;
152
249
  export declare const platform: typeof API.Env.operations.platform;
153
- export declare const compile: typeof API.Compiler.operations.compile;
250
+ export declare const command: typeof API.Env.operations.command;
251
+ export declare const compile: typeof API.Env.operations.compile;
252
+ /**
253
+ * Discard the standard output of subprocesses started in this scope.
254
+ *
255
+ * For a caller whose subprocess output is an *answer* rather than something to
256
+ * show: a command whose stdout is parsed and returned would otherwise also
257
+ * print itself into whatever the process was rendering. `stderr` is left alone,
258
+ * because that is where a failing command explains itself and a diagnostic is
259
+ * worth seeing.
260
+ *
261
+ * It lives here because reaching the process Api's stdio directly is host
262
+ * behavior, and modules held to the runtime-neutral boundary may not import a
263
+ * host process module of their own.
264
+ *
265
+ * Installed at the display boundary, where the host's own writer sits: not
266
+ * showing something and not knowing it are different, and a caller that asked
267
+ * for the answer must still be given it. Anything upstream — this adapter's
268
+ * retention, a document's capture, a run's record — reads the bytes first and
269
+ * only the host is left out.
270
+ */
271
+ export declare function useQuietProcessOutput(): Operation<void>;
154
272
  export {};
package/types/config.d.ts CHANGED
@@ -1,24 +1,42 @@
1
1
  /**
2
2
  * Config Api — shared execution configuration with pluggable middleware.
3
3
  *
4
- * Supplies the contextual timeout in milliseconds. Process, Fetch, and
5
- * Agent operations read it when a call does not provide an explicit
6
- * timeout. Override it for a scope with:
4
+ * Three timeouts, three owners, no defaults:
5
+ *
6
+ * - `timeout` is the deadline for the whole run — preparation and execution
7
+ * together — and only the outer run boundary consumes it.
8
+ * - `timeoutExec` is what each exec block gets, and only exec blocks and the
9
+ * built-in `timeout` modifier consume it.
10
+ * - `timeoutFetch` is what each Fetch gets, and only Fetch consumes it.
11
+ *
12
+ * `undefined` means no timeout, and it is what every field starts as. An
13
+ * operation nobody bounded runs until it finishes or the run's own deadline
14
+ * cancels it; a general "shared timeout" that quietly bounded processes,
15
+ * requests, prompts, and services alike is what this replaces. Override a
16
+ * field for a scope with:
7
17
  *
8
18
  * ```typescript
9
- * yield* Config.around({ timeout: () => 30_000 }, { at: "min" });
19
+ * yield* Config.around({ timeoutExec: () => 30_000 }, { at: "min" });
10
20
  * ```
21
+ *
22
+ * Installing at `min` is what lets a nested override win: a block's own
23
+ * `timeout=` outranks the value the command line established for the run.
24
+ * Omitting a field inherits the enclosing value rather than clearing it.
11
25
  */
12
26
  import { type Api } from "@effectionx/context-api";
13
27
  import type { Operation } from "effection";
14
28
  export interface ConfigApi {
15
- /** Shared timeout in milliseconds. */
16
- timeout: number;
29
+ /** Deadline for the entire run, in milliseconds; undefined for none. */
30
+ timeout: number | undefined;
31
+ /** Default timeout for each exec block, in milliseconds; undefined for none. */
32
+ timeoutExec: number | undefined;
33
+ /** Default timeout for each Fetch, in milliseconds; undefined for none. */
34
+ timeoutFetch: number | undefined;
17
35
  }
18
36
  export declare const Config: Api<ConfigApi>;
19
- /**
20
- * The validated contextual timeout. Always a positive, finite number of
21
- * milliseconds a middleware-supplied value that is not valid fails loudly
22
- * here rather than silently disabling or corrupting timeouts downstream.
23
- */
24
- export declare const timeout: Operation<number>;
37
+ /** The validated run deadline. Read by the run boundary and nothing else. */
38
+ export declare const timeout: Operation<number | undefined>;
39
+ /** The validated default timeout for an exec block. */
40
+ export declare const timeoutExec: Operation<number | undefined>;
41
+ /** The validated default timeout for a Fetch. */
42
+ export declare const timeoutFetch: Operation<number | undefined>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The duration grammar, in one place.
3
+ *
4
+ * Every timeout a caller or a document writes is spelled the same way: the
5
+ * three CLI options, and the `timeout=` modifier a block declares. A duration
6
+ * is a positive whole number with a unit — `500ms`, `30s`, `5min`, `20min` —
7
+ * or bare digits, which are milliseconds.
8
+ *
9
+ * Nothing here substitutes a value. An empty, zero, negative, or malformed
10
+ * duration is refused where it was written, because the alternative is a run
11
+ * bounded by a number nobody asked for.
12
+ */
13
+ /** Milliseconds, or `undefined` when `text` is not a duration. */
14
+ export declare function asDuration(text: string): number | undefined;
15
+ /** What a rejected duration says, with `label` naming where it was written. */
16
+ export declare function durationError(label: string, text: string): Error;
17
+ /** Milliseconds. Throws when `text` is not a duration this grammar accepts. */
18
+ export declare function parseDuration(text: string, label: string): number;