@zenera/faker 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrey Ryabov
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,106 @@
1
+ # @zenera/faker
2
+
3
+ **A mock HTTP server for swagger/OpenAPI documents. Point it at a spec and it
4
+ serves it — the response bodies are written, once, by a model.**
5
+
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE)
7
+ [![Node](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](https://nodejs.org)
8
+
9
+ > Part of [ZeneraNeo](https://github.com/andreyryabov/ZeneraNeo). It ships no
10
+ > binary of its own: installing it adds a `faker` **subcommand** to
11
+ > [`zen`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/README.md),
12
+ > which is also where the credentials already are.
13
+
14
+ ## Install
15
+
16
+ Node.js 24+ and [podman](https://podman.io). Install it alongside the CLI:
17
+
18
+ ```sh
19
+ npm i -g @zenera/cli @zenera/faker openai
20
+ zen key add openai # the keyring `zen` already uses
21
+ ```
22
+
23
+ The first start builds one image; every start after that reuses it. `zen --help`
24
+ lists `faker` whether or not it is installed, and says what to run if not.
25
+
26
+ ## Use
27
+
28
+ ```sh
29
+ zen faker serve api/openapi.yaml --port 8787
30
+ curl -s localhost:8787/users/12324
31
+ # { "user_id": 12324, "email": "brooke.hoffman@example.org", ... }
32
+ ```
33
+
34
+ More than one document at a time is fine, and the same request answers the same
35
+ way every time when you pin a seed:
36
+
37
+ ```sh
38
+ zen faker serve specs/*.yaml --seed 42
39
+ ```
40
+
41
+ Warm it up before a demo or a test run, so no request pays for a model turn:
42
+
43
+ ```sh
44
+ zen faker build api/openapi.yaml # write every generator now and exit
45
+ zen faker cache ls # what has been generated
46
+ zen faker cache clear # throw it away
47
+ ```
48
+
49
+ `zen mock` is the same command under a shorter noun.
50
+
51
+ ## How a body is produced
52
+
53
+ The first time an operation is called, the faker asks a model to write a
54
+ **Python generator** for it — one file, taking a JSON input path and a JSON
55
+ output path. That file is then run against synthetic probes and judged twice:
56
+ against the operation's response schema, and against the echo rule, which says
57
+ that where a path or query parameter shares a name with a property in the
58
+ response, the response has to carry the value that was asked for.
59
+ `GET /users/12324` answering with somebody else's id validates perfectly and is
60
+ still wrong.
61
+
62
+ If it fails, the diagnostics go back to the model and it tries again, up to
63
+ `--attempts`. If it passes, the file is cached under `~/.zenera/neo/faker` and
64
+ every later request is just `podman exec python3 gen.py in.json out.json` — no
65
+ model, no tokens.
66
+
67
+ Generators run in a container with **no network**, on an image baked once with
68
+ `faker`, `exrex`, `jsonschema` and `python-dateutil`.
69
+
70
+ ## Commands
71
+
72
+ ```
73
+ zen faker serve <spec...> Serve them. Generators are written on demand.
74
+ zen faker build <spec...> Write every generator now and exit.
75
+ zen faker cache ls | clear What has been generated, or throw it away.
76
+ ```
77
+
78
+ Useful options: `--port`, `--host` (loopback by default), `--model`, `--seed`
79
+ (same request, same answer), `--rebuild`, `--attempts`, `--concurrency`,
80
+ `--timeout`, `--cache <dir>`, `--quiet`. `zen help faker` prints the full table.
81
+
82
+ `GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
83
+ health check.
84
+
85
+ ## Credentials
86
+
87
+ The keyring is `zen`'s, so there is nothing new to configure:
88
+
89
+ ```sh
90
+ zen key add openai
91
+ zen key ls --check
92
+ ```
93
+
94
+ Environment variables still win over the keyring, exactly as they do for `zen`.
95
+
96
+ ## The rest of the family
97
+
98
+ | Package | What it is |
99
+ | ------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
100
+ | [`@zenera/cli`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/README.md) | `zen` — agent projects on the command line |
101
+ | [`@zenera/neo`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/neo/README.md) | the runtime — agents, models, tools, skills, memory |
102
+ | [`@zenera/rag`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/rag/README.md) | `zen rag` — an API description as a searchable graph |
103
+
104
+ ## License
105
+
106
+ [MIT](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE).
package/dist/box.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { type Runner, type Sandbox } from '@zenera/neo';
2
+ export declare const GENERATORS = "generators";
3
+ export interface BoxOptions {
4
+ /** host directory mounted at /workspace; holds generators/ and io/ */
5
+ root: string;
6
+ image: string;
7
+ /** seconds one generator may take */
8
+ timeout?: number;
9
+ engine?: string;
10
+ exec?: Runner;
11
+ }
12
+ export interface Outcome {
13
+ ok: boolean;
14
+ /** parsed `output.json`, when the run produced one */
15
+ value?: unknown;
16
+ /** what went wrong, in the words the build loop feeds back to the model */
17
+ fault?: string;
18
+ stderr?: string;
19
+ durationMs: number;
20
+ }
21
+ export declare class Box {
22
+ #private;
23
+ readonly root: string;
24
+ constructor(opts: BoxOptions);
25
+ get sandbox(): Sandbox;
26
+ /**
27
+ * Removes a container of this name left behind by a process that did not
28
+ * get to clean up. Belt to `persist: false`'s braces: a hard kill never
29
+ * runs `dispose`, and the leftover is exactly the stale-mount trap above.
30
+ */
31
+ fresh(): Promise<void>;
32
+ /** Host path of a generator's source file. */
33
+ sourceOf(key: string): string;
34
+ write(key: string, source: string): Promise<void>;
35
+ /**
36
+ * One generator, one input, one output. The io directory is removed
37
+ * afterwards whatever happened — a mock server left alone for a week must
38
+ * not fill a disk with request envelopes.
39
+ */
40
+ run(key: string, input: unknown): Promise<Outcome>;
41
+ dispose(): Promise<void>;
42
+ }
43
+ //# sourceMappingURL=box.d.ts.map
package/dist/box.js ADDED
@@ -0,0 +1,158 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { SANDBOX_MOUNT, SandboxPool, runProcess } from '@zenera/neo';
6
+ // ---------------------------------------------------------------------------
7
+ // Where generators run
8
+ //
9
+ // One container for the whole process, offline, with the faker's own directory
10
+ // bind-mounted at /workspace. That single mount is what makes the file contract
11
+ // work in both directions: the host writes `input.json` and reads
12
+ // `output.json`, the generator sees the same two paths from inside, and neither
13
+ // side has to serialise anything through a pipe.
14
+ //
15
+ // Nothing the model wrote ever reaches an argument on this side. The generator
16
+ // is invoked by a script built here out of paths derived here, and the script
17
+ // itself travels on stdin — the same rule the sandbox tools follow.
18
+ // ---------------------------------------------------------------------------
19
+ export const GENERATORS = 'generators';
20
+ const IO = 'io';
21
+ const ENTRY = 'gen.py';
22
+ export class Box {
23
+ root;
24
+ #pool;
25
+ #timeout;
26
+ #engine;
27
+ #exec;
28
+ constructor(opts) {
29
+ this.root = opts.root;
30
+ this.#timeout = opts.timeout ?? 30;
31
+ this.#engine = opts.engine ?? 'podman';
32
+ this.#exec = opts.exec ?? runProcess;
33
+ mkdirSync(join(opts.root, GENERATORS), { recursive: true });
34
+ mkdirSync(join(opts.root, IO), { recursive: true });
35
+ this.#pool = new SandboxPool({
36
+ root: opts.root,
37
+ key: 'faker',
38
+ image: opts.image,
39
+ // The one line that keeps model-written code from calling home.
40
+ network: 'none',
41
+ workdir: SANDBOX_MOUNT,
42
+ timeout: this.#timeout,
43
+ // Deliberately NOT persisted, unlike `zen`'s sandbox. A container's
44
+ // name is a hash of its configuration, which includes the host
45
+ // path but not the directory behind it — so a cache directory that
46
+ // is deleted and recreated (`zen faker cache clear`, or any rm -rf)
47
+ // gets a stopped container reattached whose bind mount still points
48
+ // at the old inode. Everything written here is then invisible
49
+ // inside, and every generator fails with "can't open file".
50
+ // `zen` persists to keep `pip install`s; the libraries here are
51
+ // baked into the image, so there is nothing to keep.
52
+ persist: false,
53
+ readOnly: false,
54
+ // Deliberately empty: the keyring is in this process's environment
55
+ // and none of it belongs in the container.
56
+ env: {},
57
+ engine: opts.engine,
58
+ exec: opts.exec,
59
+ });
60
+ }
61
+ get sandbox() {
62
+ return this.#pool.for();
63
+ }
64
+ /**
65
+ * Removes a container of this name left behind by a process that did not
66
+ * get to clean up. Belt to `persist: false`'s braces: a hard kill never
67
+ * runs `dispose`, and the leftover is exactly the stale-mount trap above.
68
+ */
69
+ async fresh() {
70
+ await this.#exec(this.#engine, ['rm', '--force', '--volumes', this.sandbox.name], {
71
+ timeoutMs: 60_000,
72
+ }).catch(() => undefined);
73
+ }
74
+ /** Host path of a generator's source file. */
75
+ sourceOf(key) {
76
+ return join(this.root, GENERATORS, key, ENTRY);
77
+ }
78
+ async write(key, source) {
79
+ mkdirSync(join(this.root, GENERATORS, key), { recursive: true });
80
+ await writeFile(this.sourceOf(key), source, 'utf8');
81
+ }
82
+ /**
83
+ * One generator, one input, one output. The io directory is removed
84
+ * afterwards whatever happened — a mock server left alone for a week must
85
+ * not fill a disk with request envelopes.
86
+ */
87
+ async run(key, input) {
88
+ const id = randomUUID();
89
+ const dir = join(this.root, IO, id);
90
+ mkdirSync(dir, { recursive: true });
91
+ const started = Date.now();
92
+ try {
93
+ await writeFile(join(dir, 'input.json'), JSON.stringify(input), 'utf8');
94
+ const script = [
95
+ `exec python3 ${inside(GENERATORS, key, ENTRY)}`,
96
+ inside(IO, id, 'input.json'),
97
+ inside(IO, id, 'output.json'),
98
+ ].join(' ');
99
+ const res = await this.sandbox.exec(script, { timeout: this.#timeout });
100
+ const stderr = res.stderr.trim();
101
+ if (res.timed_out) {
102
+ return {
103
+ ok: false,
104
+ fault: `took longer than ${this.#timeout}s`,
105
+ stderr,
106
+ durationMs: Date.now() - started,
107
+ };
108
+ }
109
+ if (res.exit_code !== 0) {
110
+ return {
111
+ ok: false,
112
+ fault: `exited ${res.exit_code}`,
113
+ stderr: stderr || res.stdout.trim(),
114
+ durationMs: Date.now() - started,
115
+ };
116
+ }
117
+ let text;
118
+ try {
119
+ text = await readFile(join(dir, 'output.json'), 'utf8');
120
+ }
121
+ catch {
122
+ return {
123
+ ok: false,
124
+ fault: 'wrote no output file',
125
+ stderr,
126
+ durationMs: Date.now() - started,
127
+ };
128
+ }
129
+ try {
130
+ return {
131
+ ok: true,
132
+ value: JSON.parse(text),
133
+ stderr,
134
+ durationMs: Date.now() - started,
135
+ };
136
+ }
137
+ catch (err) {
138
+ return {
139
+ ok: false,
140
+ fault: `output.json is not JSON: ${err instanceof Error ? err.message : String(err)}`,
141
+ stderr,
142
+ durationMs: Date.now() - started,
143
+ };
144
+ }
145
+ }
146
+ finally {
147
+ rmSync(dir, { recursive: true, force: true });
148
+ }
149
+ }
150
+ async dispose() {
151
+ await this.#pool.dispose();
152
+ }
153
+ }
154
+ /** A path inside the container. Every segment is derived here, never given. */
155
+ function inside(...parts) {
156
+ return [SANDBOX_MOUNT, ...parts].join('/');
157
+ }
158
+ //# sourceMappingURL=box.js.map
@@ -0,0 +1,57 @@
1
+ import type { Model } from '@zenera/neo';
2
+ import { type Box } from './box.ts';
3
+ import { BuildFailed } from './generate.ts';
4
+ import type { Operation } from './spec.ts';
5
+ import type { Checks } from './validate.ts';
6
+ export interface Generator {
7
+ key: string;
8
+ source: string;
9
+ /** whether it came off disk rather than out of a model */
10
+ cached: boolean;
11
+ }
12
+ export interface CacheEvent {
13
+ operation: Operation;
14
+ attempt?: number;
15
+ diagnostics?: readonly string[];
16
+ }
17
+ export interface CacheOptions {
18
+ box: Box;
19
+ checks: Checks;
20
+ model: Model;
21
+ attempts?: number;
22
+ /**
23
+ * How many generators may be written at once. A client that walks every
24
+ * route — which is exactly what people do to a new mock — would otherwise
25
+ * open one model request per operation simultaneously and be rate limited
26
+ * on all of them.
27
+ */
28
+ concurrency?: number;
29
+ /** ignore what is on disk and write fresh */
30
+ rebuild?: boolean;
31
+ /** run generators but keep nothing */
32
+ ephemeral?: boolean;
33
+ onStart?: (e: CacheEvent) => void;
34
+ onAttempt?: (e: CacheEvent) => void;
35
+ onReady?: (e: CacheEvent & {
36
+ cached: boolean;
37
+ attempts: number;
38
+ }) => void;
39
+ onFail?: (e: CacheEvent & {
40
+ error: Error;
41
+ }) => void;
42
+ }
43
+ export declare class Cache {
44
+ #private;
45
+ constructor(opts: CacheOptions);
46
+ /**
47
+ * The generator for an operation, written if it does not exist yet. Callers
48
+ * that arrive during a build wait for that build rather than starting one.
49
+ *
50
+ * `cached` answers "did this call cost a model turn", which is what the
51
+ * response header is for — so it is false for the caller that triggered the
52
+ * build and for everyone who waited on it, and true from then on.
53
+ */
54
+ ensure(operation: Operation): Promise<Generator>;
55
+ }
56
+ export { BuildFailed };
57
+ //# sourceMappingURL=cache.d.ts.map
package/dist/cache.js ADDED
@@ -0,0 +1,125 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { writeJson } from '@zenera/cli/lib';
5
+ import { GENERATORS } from "./box.js";
6
+ import { build, BuildFailed } from "./generate.js";
7
+ const DEFAULT_CONCURRENCY = 4;
8
+ export class Cache {
9
+ #opts;
10
+ #live = new Map();
11
+ #settled = new Set();
12
+ #slots;
13
+ #waiting = [];
14
+ #running = 0;
15
+ constructor(opts) {
16
+ this.#opts = opts;
17
+ this.#slots = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
18
+ }
19
+ /**
20
+ * The generator for an operation, written if it does not exist yet. Callers
21
+ * that arrive during a build wait for that build rather than starting one.
22
+ *
23
+ * `cached` answers "did this call cost a model turn", which is what the
24
+ * response header is for — so it is false for the caller that triggered the
25
+ * build and for everyone who waited on it, and true from then on.
26
+ */
27
+ ensure(operation) {
28
+ const already = this.#settled.has(operation.key);
29
+ const running = this.#live.get(operation.key);
30
+ if (running) {
31
+ return already ? running.then((g) => ({ ...g, cached: true })) : running;
32
+ }
33
+ const started = this.#make(operation).then((g) => {
34
+ this.#settled.add(operation.key);
35
+ return g;
36
+ }, (err) => {
37
+ // A model that tried and could not is remembered: re-asking it
38
+ // once per request is how a mock server becomes an invoice.
39
+ // Anything else — a rate limit, a dropped connection — is about
40
+ // this moment rather than this operation, so it is forgotten
41
+ // and the next request gets a fresh go.
42
+ if (!(err instanceof BuildFailed)) {
43
+ this.#live.delete(operation.key);
44
+ }
45
+ throw err;
46
+ });
47
+ this.#live.set(operation.key, started);
48
+ return started;
49
+ }
50
+ async #make(operation) {
51
+ const { box, model, checks, rebuild, ephemeral } = this.#opts;
52
+ // Read before queueing: a cache hit costs nothing and must not wait
53
+ // behind somebody else's model call.
54
+ const source = rebuild ? undefined : await read(box, operation.key);
55
+ if (source !== undefined) {
56
+ this.#opts.onReady?.({ operation, cached: true, attempts: 0 });
57
+ return { key: operation.key, source, cached: true };
58
+ }
59
+ await this.#enter();
60
+ this.#opts.onStart?.({ operation });
61
+ try {
62
+ const built = await build(operation, {
63
+ model,
64
+ box,
65
+ checks,
66
+ attempts: this.#opts.attempts,
67
+ onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
68
+ });
69
+ if (!ephemeral) {
70
+ writeJson(join(box.root, GENERATORS, operation.key, 'meta.json'), {
71
+ version: 1,
72
+ operationId: operation.operationId,
73
+ method: operation.method,
74
+ path: operation.path,
75
+ source: operation.source,
76
+ model: model.id,
77
+ attempts: built.attempts,
78
+ rebuilt: Boolean(rebuild),
79
+ createdAt: new Date().toISOString(),
80
+ }, 0o644);
81
+ }
82
+ this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
83
+ return { key: operation.key, source: built.source, cached: false };
84
+ }
85
+ catch (err) {
86
+ const error = err instanceof Error ? err : new Error(String(err));
87
+ this.#opts.onFail?.({ operation, error });
88
+ throw error;
89
+ }
90
+ finally {
91
+ this.#leave();
92
+ }
93
+ }
94
+ #enter() {
95
+ if (this.#running < this.#slots) {
96
+ this.#running++;
97
+ return Promise.resolve();
98
+ }
99
+ return new Promise((admit) => this.#waiting.push(admit));
100
+ }
101
+ /** Hands the slot straight to whoever is next, so the count stays exact. */
102
+ #leave() {
103
+ const next = this.#waiting.shift();
104
+ if (next) {
105
+ next();
106
+ return;
107
+ }
108
+ this.#running--;
109
+ }
110
+ }
111
+ async function read(box, key) {
112
+ const path = box.sourceOf(key);
113
+ if (!existsSync(path)) {
114
+ return undefined;
115
+ }
116
+ try {
117
+ const source = await readFile(path, 'utf8');
118
+ return source.trim() ? source : undefined;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ export { BuildFailed };
125
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1,3 @@
1
+ import { type Command } from '@zenera/cli/lib';
2
+ export declare const command: Command;
3
+ //# sourceMappingURL=command.d.ts.map