@executablemd/runtime 0.6.0 → 0.7.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/apis.js CHANGED
@@ -29,13 +29,18 @@
29
29
  *
30
30
  * - **Process** — subprocess lifecycle has its own cancellation semantics
31
31
  * (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.
32
+ * - **Fs** — reading, writing, and inspecting files form a cohesive file-IO
33
+ * surface used together for component resolution, replay guards, and the
34
+ * `<File>` component. Middleware installed here sees a document's own file
35
+ * access on the same terms as the engine's.
34
36
  * - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
35
37
  * 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.
38
+ * - **Env** — the host itself: metadata (env vars, platform) plus the two
39
+ * capabilities only the entrypoint can supply, `command` (how to re-invoke
40
+ * this xmd) and `compile` (how this host loads a generated module). Tests
41
+ * use `.around()` to mock platform/env for deterministic replay; an
42
+ * entrypoint installs its `command` and `compile` with `{ at: "min" }` so
43
+ * ordinary middleware can wrap them.
39
44
  *
40
45
  * ## Middleware
41
46
  *
@@ -57,13 +62,28 @@
57
62
  * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`.
58
63
  */
59
64
  import { createApi } from "@effectionx/context-api";
60
- import { relative, sep } from "node:path";
65
+ import { join } from "node:path";
61
66
  import process from "node:process";
67
+ import { realpath as fsRealpath, rename as fsRename } from "node:fs/promises";
62
68
  import { fetch as effectionFetch } from "@effectionx/fetch";
63
- import { readTextFile as fsReadTextFile, stat as fsStat, globToRegExp, walk } from "@effectionx/fs";
69
+ import { ensureDir as fsEnsureDir, FsApi, globToRegExp, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
64
70
  import { exec as processExec } from "@effectionx/process";
65
- import { each, race, sleep } from "effection";
71
+ import { race, sleep, until } from "effection";
66
72
  import { timeout as contextualTimeout } from "./config.js";
73
+ /**
74
+ * The `errno` string a failed filesystem call carries, when it carries one.
75
+ *
76
+ * Read rather than asserted: `catch` gives back `unknown`, and what arrives
77
+ * there is only conventionally an `ErrnoException`. Narrowing says what is
78
+ * actually known about the value instead of claiming a shape it may not have.
79
+ */
80
+ function errorCode(error) {
81
+ if (typeof error !== "object" || error === null || !("code" in error)) {
82
+ return undefined;
83
+ }
84
+ const { code } = error;
85
+ return typeof code === "string" ? code : undefined;
86
+ }
67
87
  function* withTimeout(label, timeout, operation) {
68
88
  if (timeout === undefined) {
69
89
  return yield* operation;
@@ -79,6 +99,97 @@ function* withTimeout(label, timeout, operation) {
79
99
  })(),
80
100
  ]));
81
101
  }
102
+ /**
103
+ * A glob pattern as a matcher for relative POSIX paths.
104
+ *
105
+ * A pattern that cannot be compiled throws here — an unterminated character
106
+ * class is a `SyntaxError` from `RegExp` — so it surfaces from `glob` itself
107
+ * rather than silently matching nothing.
108
+ */
109
+ function toRegExp(pattern) {
110
+ return globToRegExp(pattern, { extended: true, globstar: true });
111
+ }
112
+ /**
113
+ * A matcher for directories whose entire subtree an exclusion covers, when the
114
+ * pattern is one that can prove it.
115
+ *
116
+ * Skipping a subtree is only sound if *every* path beneath it is excluded, and
117
+ * matching the directory tells us nothing of the sort: `foo` does not match
118
+ * `foo/deep/keep.md`, and `foo/*` matches `foo/direct.md` but stops at the next
119
+ * separator. Testing the directory — or the directory with a trailing separator
120
+ * — as a proxy for "all descendants" prunes more than the pattern selects.
121
+ *
122
+ * A trailing `/**` is the form that does prove it. It compiles to
123
+ * `(?:[^/]*(?:/|$))*`, which matches any sequence of segments, so once the part
124
+ * before it matches a directory the pattern matches every path under that
125
+ * directory at any depth. `**` alone covers the whole tree the same way.
126
+ *
127
+ * Anything else returns `undefined` and the subtree is walked, with its files
128
+ * filtered one at a time. That is the conservative direction: descending a
129
+ * subtree whose files are all excluded costs reads, while skipping one that
130
+ * holds a match loses the match.
131
+ */
132
+ const SUBTREE = "/**";
133
+ function isRegExp(value) {
134
+ return value !== undefined;
135
+ }
136
+ function pruneMatcher(pattern) {
137
+ if (pattern === "**") {
138
+ return toRegExp("**");
139
+ }
140
+ if (!pattern.endsWith(SUBTREE)) {
141
+ return undefined;
142
+ }
143
+ return toRegExp(pattern.slice(0, -SUBTREE.length));
144
+ }
145
+ /**
146
+ * Collect matches under `directory`, whose path relative to the glob root is
147
+ * `prefix`.
148
+ *
149
+ * A plain recursive generator rather than `@effectionx/fs`'s `walk()`, whose
150
+ * producer runs in a spawned task: a `readdir` that fails there tears down the
151
+ * surrounding scope instead of throwing at the call site, so no caller can
152
+ * report it. Recursing here makes a failure `glob`'s own, and makes every
153
+ * directory read a cancellation point.
154
+ *
155
+ * Paths are assembled from entry names with `/`, so what patterns match is the
156
+ * relative POSIX path on every platform.
157
+ *
158
+ * Exclusion is decided per **candidate**: a file or symlink whose own path an
159
+ * exclude pattern matches is not reported, which is what makes exclusions win.
160
+ * A directory is not a candidate — it is never reported — so its own path is
161
+ * not tested against exclusions at all. The only question a directory raises is
162
+ * whether walking it can still produce something, and that is `pruneMatcher`'s.
163
+ */
164
+ function* descend(directory, prefix, walk) {
165
+ for (const entry of yield* FsApi.operations.readdirDirents(directory)) {
166
+ const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
167
+ // Before the exclusion test, because a directory is not a candidate: an
168
+ // exclusion matching its path says nothing about the files beneath it.
169
+ // A symlink to a directory takes the branches below instead — `isDirectory`
170
+ // is false for one — so traversal never follows it.
171
+ if (entry.isDirectory()) {
172
+ if (!walk.prune.some((re) => re.test(path))) {
173
+ yield* descend(join(directory, entry.name), path, walk);
174
+ }
175
+ continue;
176
+ }
177
+ if (walk.exclude.some((re) => re.test(path))) {
178
+ continue;
179
+ }
180
+ // A symlink is reported by its own path and never followed, so traversal
181
+ // stays under the root and cannot cycle.
182
+ if (entry.isSymbolicLink()) {
183
+ if (walk.include.some((re) => re.test(path))) {
184
+ walk.matched.push({ path, isFile: false });
185
+ }
186
+ continue;
187
+ }
188
+ if (entry.isFile() && walk.include.some((re) => re.test(path))) {
189
+ walk.matched.push({ path, isFile: true });
190
+ }
191
+ }
192
+ }
82
193
  export const API = {
83
194
  /**
84
195
  * Subprocess execution.
@@ -125,7 +236,7 @@ export const API = {
125
236
  };
126
237
  }
127
238
  catch (err) {
128
- if (err.code === "ENOENT") {
239
+ if (errorCode(err) === "ENOENT") {
129
240
  return { exists: false, isFile: false, isDirectory: false };
130
241
  }
131
242
  throw err;
@@ -133,27 +244,38 @@ export const API = {
133
244
  },
134
245
  *glob(options) {
135
246
  const { patterns, root, exclude = [] } = options;
136
- const results = [];
137
- // Convert include/exclude patterns to RegExp for matching
138
- // against relative paths from root
139
- const includeRegexes = patterns.map((p) => globToRegExp(p, { extended: true, globstar: true }));
140
- const excludeRegexes = exclude.map((e) => globToRegExp(e, { extended: true, globstar: true }));
141
- // Walk the directory tree and match relative paths
142
- const stream = walk(root, {
143
- includeFiles: true,
144
- includeDirs: false,
145
- skip: excludeRegexes.length > 0 ? excludeRegexes : undefined,
247
+ const matched = [];
248
+ yield* descend(root, "", {
249
+ include: patterns.map(toRegExp),
250
+ exclude: exclude.map(toRegExp),
251
+ prune: exclude.map(pruneMatcher).filter(isRegExp),
252
+ matched,
146
253
  });
147
- for (const entry of yield* each(stream)) {
148
- // Normalize to POSIX separators for consistent matching across platforms
149
- const relPath = relative(root, entry.path).split(sep).join("/");
150
- const matches = includeRegexes.some((re) => re.test(relPath));
151
- if (matches) {
152
- results.push({ path: relPath, isFile: entry.isFile });
254
+ return matched;
255
+ },
256
+ *writeTextFile(path, content) {
257
+ yield* fsWriteTextFile(path, content);
258
+ },
259
+ *ensureDir(path) {
260
+ yield* fsEnsureDir(path);
261
+ },
262
+ *rename(from, to) {
263
+ yield* until(fsRename(from, to));
264
+ },
265
+ *remove(path, options) {
266
+ yield* fsRm(path, options);
267
+ },
268
+ *realpath(path) {
269
+ try {
270
+ return yield* until(fsRealpath(path));
271
+ }
272
+ catch (err) {
273
+ const code = errorCode(err);
274
+ if (code === "ENOENT" || code === "ENOTDIR") {
275
+ return undefined;
153
276
  }
154
- yield* each.next();
277
+ throw err;
155
278
  }
156
- return results;
157
279
  },
158
280
  }),
159
281
  /**
@@ -201,18 +323,25 @@ export const API = {
201
323
  arch: process.arch,
202
324
  };
203
325
  },
204
- }),
205
- /**
206
- * Block compilation.
207
- *
208
- * Default handler throws platform-specific middleware must be
209
- * installed via `yield* API.Compiler.around(...)` before use.
210
- * See `packages/core/src/deno-compiler.ts` for the Deno implementation.
211
- */
212
- Compiler: createApi("runtime.compiler", {
326
+ /**
327
+ * The default cannot be derived. `process.execPath` names the executable
328
+ * but not how it was launched — `deno run --allow-all <entry>` and
329
+ * `node <entry>` are not recoverable from "deno" or "node", and a
330
+ * compiled binary takes no entry script at all. Only the entrypoint that
331
+ * started this process knows.
332
+ */
333
+ // deno-lint-ignore require-yield
334
+ *command(_args) {
335
+ throw new Error("xmd command not installed — a runtime-named entrypoint must install it via API.Env.around()");
336
+ },
337
+ /**
338
+ * Compiling an eval block means loading a module the way this host
339
+ * loads modules, so the implementation belongs with the entrypoint that
340
+ * knows the host — beside `command`, installed in the same call.
341
+ */
213
342
  // deno-lint-ignore require-yield
214
343
  *compile(_source, _options) {
215
- throw new Error("compiler not installed — install platform-specific middleware via API.Compiler.around()");
344
+ throw new Error("compiler not installed — install platform-specific middleware via API.Env.around()");
216
345
  },
217
346
  }),
218
347
  };
@@ -220,8 +349,14 @@ export const exec = API.Process.operations.exec;
220
349
  export const readTextFile = API.Fs.operations.readTextFile;
221
350
  export const stat = API.Fs.operations.stat;
222
351
  export const glob = API.Fs.operations.glob;
352
+ export const writeTextFile = API.Fs.operations.writeTextFile;
353
+ export const ensureDir = API.Fs.operations.ensureDir;
354
+ export const rename = API.Fs.operations.rename;
355
+ export const remove = API.Fs.operations.remove;
356
+ export const realpath = API.Fs.operations.realpath;
223
357
  export const fetch = API.Fetch.operations.fetch;
224
358
  export const env = API.Env.operations.env;
225
359
  export const cwd = API.Env.operations.cwd;
226
360
  export const platform = API.Env.operations.platform;
227
- export const compile = API.Compiler.operations.compile;
361
+ export const command = API.Env.operations.command;
362
+ export const compile = API.Env.operations.compile;
package/esm/mod.js CHANGED
@@ -7,16 +7,18 @@
7
7
  *
8
8
  * Six domain APIs:
9
9
  * - `API.Process` — subprocess execution (`exec`)
10
- * - `API.Fs` — filesystem (`readTextFile`, `stat`, `glob`)
10
+ * - `API.Fs` — filesystem (`readTextFile`, `writeTextFile`, `stat`, `glob`,
11
+ * `realpath`, `ensureDir`, `rename`, `remove`)
11
12
  * - `API.Fetch` — HTTP requests (`fetch`)
12
- * - `API.Env` — environment variables and platform info (`cwd`, `env`, `platform`)
13
- * - `API.Compiler` block compilation (`compile`)
13
+ * - `API.Env` — the host: variables, platform info, the command that invokes
14
+ * this xmd, and eval-block compilation
15
+ * (`cwd`, `env`, `platform`, `command`, `compile`)
14
16
  * - `Config` — shared execution config (`timeout`)
15
17
  *
16
18
  * See `apis.ts` for architecture rationale.
17
19
  * See `@executablemd/runtime/test` for composable test stubs.
18
20
  */
19
21
  export { API } from "./apis.js";
20
- export { exec, readTextFile, stat, glob, fetch, cwd, env, platform, compile } from "./apis.js";
22
+ export { exec, readTextFile, writeTextFile, stat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, } from "./apis.js";
21
23
  export { findFreePort } from "./find-free-port.js";
22
24
  export { Config, timeout } from "./config.js";
package/esm/test/stubs.js CHANGED
@@ -26,6 +26,9 @@ import { API } from "../apis.js";
26
26
  * - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
27
27
  * - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
28
28
  * - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
29
+ * - the writing half — `writeTextFile`, `ensureDir`, `rename`, `remove`, and
30
+ * `realpath` — is not stubbed and reaches the real filesystem. A test that
31
+ * exercises a document writing files wants a real temporary directory.
29
32
  *
30
33
  * The `files` object is captured **by reference** — mutating it between
31
34
  * operations changes what `readTextFile`/`stat` see. This is useful for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@executablemd/runtime",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Runtime host APIs for executable.md documents.",
5
5
  "homepage": "https://executable.md",
6
6
  "repository": {
package/types/apis.d.ts CHANGED
@@ -29,13 +29,18 @@
29
29
  *
30
30
  * - **Process** — subprocess lifecycle has its own cancellation semantics
31
31
  * (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.
32
+ * - **Fs** — reading, writing, and inspecting files form a cohesive file-IO
33
+ * surface used together for component resolution, replay guards, and the
34
+ * `<File>` component. Middleware installed here sees a document's own file
35
+ * access on the same terms as the engine's.
34
36
  * - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
35
37
  * 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.
38
+ * - **Env** — the host itself: metadata (env vars, platform) plus the two
39
+ * capabilities only the entrypoint can supply, `command` (how to re-invoke
40
+ * this xmd) and `compile` (how this host loads a generated module). Tests
41
+ * use `.around()` to mock platform/env for deterministic replay; an
42
+ * entrypoint installs its `command` and `compile` with `{ at: "min" }` so
43
+ * ordinary middleware can wrap them.
39
44
  *
40
45
  * ## Middleware
41
46
  *
@@ -105,6 +110,22 @@ interface ProcessHandler {
105
110
  interface FsHandler {
106
111
  readTextFile(path: string): Operation<string>;
107
112
  stat(path: string): Operation<StatResult>;
113
+ /**
114
+ * Files and symbolic links beneath `root` whose path relative to it matches
115
+ * `patterns` and matches none of `exclude`. Paths come back relative and
116
+ * POSIX-separated, which is what both pattern lists are matched against, so a
117
+ * caller's patterns mean the same thing on every platform.
118
+ *
119
+ * Exclusion is per candidate: an entry is dropped when its own relative path
120
+ * matches. Directories are not candidates and are not reported, so an
121
+ * exclusion matching a directory does not remove what is beneath it — only a
122
+ * pattern ending in `/**`, which provably covers every descendant, lets the
123
+ * subtree be skipped rather than walked and filtered.
124
+ *
125
+ * Symbolic links are reported but never followed: a link's own path can
126
+ * match, and a link to a directory is not descended into. Traversal
127
+ * therefore stays inside `root` and cannot cycle.
128
+ */
108
129
  glob(options: {
109
130
  patterns: string[];
110
131
  root: string;
@@ -113,6 +134,20 @@ interface FsHandler {
113
134
  path: string;
114
135
  isFile: boolean;
115
136
  }>>;
137
+ writeTextFile(path: string, content: string): Operation<void>;
138
+ ensureDir(path: string): Operation<void>;
139
+ rename(from: string, to: string): Operation<void>;
140
+ remove(path: string, options?: {
141
+ recursive?: boolean;
142
+ force?: boolean;
143
+ }): Operation<void>;
144
+ /**
145
+ * The canonical path, with every symlink resolved, or `undefined` when the
146
+ * path does not exist. Like `stat`, "it isn't there" is an answer rather
147
+ * than a failure — a caller resolving a path it is about to create asks
148
+ * about ancestors that may legitimately be missing.
149
+ */
150
+ realpath(path: string): Operation<string | undefined>;
116
151
  }
117
152
  interface FetchHandler {
118
153
  fetch(input: string, init?: {
@@ -122,6 +157,12 @@ interface FetchHandler {
122
157
  timeout?: number;
123
158
  }): Operation<RuntimeFetchResponse>;
124
159
  }
160
+ /**
161
+ * A compiled eval block accepts the document binding environment and returns
162
+ * an Operation. Current compilers implement it with generated `function*`
163
+ * modules, but callers do not depend on that representation.
164
+ */
165
+ export type EvalBlock = (env: Record<string, unknown>) => Operation<unknown>;
125
166
  interface EnvHandler {
126
167
  cwd(): Operation<string>;
127
168
  env(name: string): Operation<string | undefined>;
@@ -129,26 +170,30 @@ interface EnvHandler {
129
170
  os: string;
130
171
  arch: string;
131
172
  }>;
132
- }
133
- interface CompilerHandler {
173
+ command(args?: string[]): Operation<string[]>;
134
174
  compile(source: string, options?: {
135
175
  imports: string[];
136
- }): Operation<(env: Record<string, unknown>) => Generator<unknown, unknown, unknown>>;
176
+ }): Operation<EvalBlock>;
137
177
  }
138
178
  export declare const API: {
139
179
  Process: Api<ProcessHandler>;
140
180
  Fs: Api<FsHandler>;
141
181
  Fetch: Api<FetchHandler>;
142
182
  Env: Api<EnvHandler>;
143
- Compiler: Api<CompilerHandler>;
144
183
  };
145
184
  export declare const exec: typeof API.Process.operations.exec;
146
185
  export declare const readTextFile: typeof API.Fs.operations.readTextFile;
147
186
  export declare const stat: typeof API.Fs.operations.stat;
148
187
  export declare const glob: typeof API.Fs.operations.glob;
188
+ export declare const writeTextFile: typeof API.Fs.operations.writeTextFile;
189
+ export declare const ensureDir: typeof API.Fs.operations.ensureDir;
190
+ export declare const rename: typeof API.Fs.operations.rename;
191
+ export declare const remove: typeof API.Fs.operations.remove;
192
+ export declare const realpath: typeof API.Fs.operations.realpath;
149
193
  export declare const fetch: typeof API.Fetch.operations.fetch;
150
194
  export declare const env: typeof API.Env.operations.env;
151
195
  export declare const cwd: typeof API.Env.operations.cwd;
152
196
  export declare const platform: typeof API.Env.operations.platform;
153
- export declare const compile: typeof API.Compiler.operations.compile;
197
+ export declare const command: typeof API.Env.operations.command;
198
+ export declare const compile: typeof API.Env.operations.compile;
154
199
  export {};
package/types/mod.d.ts CHANGED
@@ -7,18 +7,20 @@
7
7
  *
8
8
  * Six domain APIs:
9
9
  * - `API.Process` — subprocess execution (`exec`)
10
- * - `API.Fs` — filesystem (`readTextFile`, `stat`, `glob`)
10
+ * - `API.Fs` — filesystem (`readTextFile`, `writeTextFile`, `stat`, `glob`,
11
+ * `realpath`, `ensureDir`, `rename`, `remove`)
11
12
  * - `API.Fetch` — HTTP requests (`fetch`)
12
- * - `API.Env` — environment variables and platform info (`cwd`, `env`, `platform`)
13
- * - `API.Compiler` block compilation (`compile`)
13
+ * - `API.Env` — the host: variables, platform info, the command that invokes
14
+ * this xmd, and eval-block compilation
15
+ * (`cwd`, `env`, `platform`, `command`, `compile`)
14
16
  * - `Config` — shared execution config (`timeout`)
15
17
  *
16
18
  * See `apis.ts` for architecture rationale.
17
19
  * See `@executablemd/runtime/test` for composable test stubs.
18
20
  */
19
21
  export { API } from "./apis.js";
20
- export { exec, readTextFile, stat, glob, fetch, cwd, env, platform, compile } from "./apis.js";
21
- export type { ResponseHeaders, RuntimeFetchResponse, StatResult } from "./apis.js";
22
+ export { exec, readTextFile, writeTextFile, stat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, } from "./apis.js";
23
+ export type { EvalBlock, ResponseHeaders, RuntimeFetchResponse, StatResult } from "./apis.js";
22
24
  export { findFreePort } from "./find-free-port.js";
23
25
  export { Config, timeout } from "./config.js";
24
26
  export type { ConfigApi } from "./config.js";
@@ -26,6 +26,9 @@ import type { Operation } from "effection";
26
26
  * - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
27
27
  * - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
28
28
  * - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
29
+ * - the writing half — `writeTextFile`, `ensureDir`, `rename`, `remove`, and
30
+ * `realpath` — is not stubbed and reaches the real filesystem. A test that
31
+ * exercises a document writing files wants a real temporary directory.
29
32
  *
30
33
  * The `files` object is captured **by reference** — mutating it between
31
34
  * operations changes what `readTextFile`/`stat` see. This is useful for