@zenera/faker 1.1.0 → 1.1.3

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/README.md CHANGED
@@ -16,7 +16,7 @@ serves it — the response bodies are written, once, by a model.**
16
16
  Node.js 24+ and [podman](https://podman.io). Install it alongside the CLI:
17
17
 
18
18
  ```sh
19
- npm i -g @zenera/cli @zenera/faker openai
19
+ npm i -g @zenera/cli @zenera/faker
20
20
  zen key add openai # the keyring `zen` already uses
21
21
  ```
22
22
 
package/dist/setup.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { credentialError, ensureHome, ensurePodmanReady, envNames, invalidError, KeyStore, paths, PROVIDERS, } from '@zenera/cli/lib';
2
+ import { createModel } from '@zenera/neo';
1
3
  import { mkdirSync } from 'node:fs';
2
4
  import { resolve } from 'node:path';
3
- import { credentialError, ensureHome, ensurePodmanReady, invalidError, KeyStore, paths, PROVIDERS, SHAPES, } from '@zenera/cli/lib';
4
- import { createModel } from '@zenera/neo';
5
5
  import { Box } from "./box.js";
6
6
  import { Cache } from "./cache.js";
7
7
  import { ensureImage } from "./image.js";
@@ -70,7 +70,7 @@ export async function open(opts) {
70
70
  * itself is a better test than a round trip that costs the same.
71
71
  */
72
72
  function defaultRef(keys) {
73
- const provider = PROVIDERS.find((p) => process.env[SHAPES[p].env]) ??
73
+ const provider = PROVIDERS.find((p) => envNames(p).some((name) => process.env[name])) ??
74
74
  PROVIDERS.find((p) => keys.active(p) !== undefined);
75
75
  if (!provider) {
76
76
  throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/faker",
3
- "version": "1.1.0",
3
+ "version": "1.1.3",
4
4
  "description": "Mock HTTP server for swagger/OpenAPI documents, with response bodies generated by a model.",
5
5
  "keywords": [
6
6
  "openapi",
@@ -45,7 +45,7 @@
45
45
  "@apidevtools/swagger-parser": "^12.0.0",
46
46
  "ajv": "^8.17.1",
47
47
  "ajv-formats": "^3.0.1",
48
- "@zenera/cli": "^1.1.0",
49
- "@zenera/neo": "^1.1.0"
48
+ "@zenera/cli": "^1.1.3",
49
+ "@zenera/neo": "^1.1.3"
50
50
  }
51
51
  }
package/dist/main.d.ts DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
3
- //# sourceMappingURL=main.d.ts.map
package/dist/main.js DELETED
@@ -1,363 +0,0 @@
1
- #!/usr/bin/env node
2
- import { rmSync } from 'node:fs';
3
- import { readdir, readFile } from 'node:fs/promises';
4
- import { join, relative } from 'node:path';
5
- import { bold, CliError, cyan, dim, EXIT, fail, green, invokedAs, json, note, ownedContainers, parse, paths, printBanner, red, removeContainers, split, table, usageError, write, writeAll, yellow, } from 'zenera-cli/lib';
6
- import { GENERATORS } from "./box.js";
7
- import { reason } from "./generate.js";
8
- import { listen } from "./server.js";
9
- import { open } from "./setup.js";
10
- import { SpecError } from "./spec.js";
11
- // ---------------------------------------------------------------------------
12
- // zfake — a mock API from a specification
13
- //
14
- // A separate binary rather than a `zen` subcommand. `zen` runs agent projects
15
- // and everything in it is shaped by that; this is a server, it stays up, and
16
- // the only thing the two genuinely share is where credentials live. Putting it
17
- // under `zen` would have meant one command that means two different things.
18
- // ---------------------------------------------------------------------------
19
- /** What the user typed: `zfake`, `zen-fake`, `zen-faker` or `zenera-fake`. */
20
- const NAME = invokedAs('zfake');
21
- const USAGE = `${NAME} <serve|build|cache> [spec...] [options]`;
22
- const BANNER = {
23
- head: 'Zenera',
24
- accent: 'Faker',
25
- subtitle: 'Mock API Server',
26
- };
27
- const OPTIONS = {
28
- port: { type: 'string' },
29
- host: { type: 'string' },
30
- model: { type: 'string' },
31
- image: { type: 'string' },
32
- cache: { type: 'string' },
33
- attempts: { type: 'string' },
34
- concurrency: { type: 'string' },
35
- seed: { type: 'string' },
36
- timeout: { type: 'string' },
37
- 'max-body': { type: 'string' },
38
- rebuild: { type: 'boolean' },
39
- 'no-cache': { type: 'boolean' },
40
- quiet: { type: 'boolean' },
41
- json: { type: 'boolean' },
42
- };
43
- async function main(argv) {
44
- const { name, after } = split(argv);
45
- if (!name || name === 'help' || name === '--help' || name === '-h') {
46
- usage();
47
- return name ? EXIT.ok : EXIT.usage;
48
- }
49
- try {
50
- switch (name) {
51
- case 'serve':
52
- return await serve(after);
53
- case 'build':
54
- return await warm(after);
55
- case 'cache':
56
- return await cache(after);
57
- default:
58
- throw usageError(`unknown command "${name}"`, USAGE);
59
- }
60
- }
61
- catch (err) {
62
- if (err instanceof CliError) {
63
- fail(err.message, err.hint);
64
- return err.code;
65
- }
66
- if (err instanceof SpecError) {
67
- fail(err.message, err.hint);
68
- return EXIT.invalid;
69
- }
70
- fail(err instanceof Error ? err.message : String(err));
71
- return EXIT.failed;
72
- }
73
- }
74
- // ---------------------------------------------------------------------------
75
- // serve
76
- // ---------------------------------------------------------------------------
77
- async function serve(args) {
78
- const { values, positionals } = parse(args, OPTIONS, `${NAME} serve <spec...>`);
79
- const loud = !values.quiet && !values.json;
80
- if (loud) {
81
- printBanner(BANNER);
82
- }
83
- const setup = await start(values, positionals, 'serve');
84
- if (loud) {
85
- printSpecs(setup.router.operations);
86
- }
87
- const host = values.host ?? '127.0.0.1';
88
- const listener = await listen({
89
- router: setup.router,
90
- cache: setup.cache,
91
- checks: setup.checks,
92
- box: setup.box,
93
- seed: number(values.seed, 'seed'),
94
- maxBody: number(values['max-body'], 'max-body'),
95
- onRequest: values.quiet ? undefined : (line) => note(dim(line)),
96
- }, host, number(values.port, 'port') ?? 8787);
97
- if (!values.quiet) {
98
- note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
99
- dim(`${setup.router.operations.length} operations`));
100
- if (host !== '127.0.0.1' && host !== 'localhost') {
101
- note(yellow(`bound to ${host} — this mock is reachable from the network`));
102
- }
103
- note(dim(`generators: ${setup.root}/${GENERATORS}`));
104
- }
105
- // The address is the answer; the rest was narration.
106
- write(`http://${host}:${listener.port}`);
107
- await until(['SIGINT', 'SIGTERM']);
108
- if (!values.quiet) {
109
- note(dim('stopping'));
110
- }
111
- await listener.close();
112
- await setup.close();
113
- return EXIT.ok;
114
- }
115
- /** Resolves when one of the signals arrives, and stops listening for them. */
116
- function until(signals) {
117
- return new Promise((settle) => {
118
- const done = () => {
119
- for (const s of signals) {
120
- process.off(s, done);
121
- }
122
- settle();
123
- };
124
- for (const s of signals) {
125
- process.once(s, done);
126
- }
127
- });
128
- }
129
- // ---------------------------------------------------------------------------
130
- // build — every generator, up front
131
- // ---------------------------------------------------------------------------
132
- async function warm(args) {
133
- const { values, positionals } = parse(args, OPTIONS, `${NAME} build <spec...>`);
134
- const loud = !values.quiet && !values.json;
135
- if (loud) {
136
- printBanner(BANNER);
137
- }
138
- const setup = await start(values, positionals, 'build');
139
- if (loud) {
140
- printSpecs(setup.router.operations);
141
- }
142
- const results = [];
143
- try {
144
- for (const operation of setup.router.operations) {
145
- const id = `${operation.method.toUpperCase()} ${operation.path}`;
146
- if (!operation.success.schema) {
147
- results.push({ operation: id, status: 'skipped', detail: 'no response body' });
148
- continue;
149
- }
150
- try {
151
- const generator = await setup.cache.ensure(operation);
152
- results.push({ operation: id, status: generator.cached ? 'cached' : 'built' });
153
- }
154
- catch (err) {
155
- results.push({
156
- operation: id,
157
- status: 'failed',
158
- detail: reason(err),
159
- });
160
- }
161
- }
162
- }
163
- finally {
164
- await setup.close();
165
- }
166
- if (values.json) {
167
- json(results);
168
- }
169
- else {
170
- writeAll(table(results.map((r) => [
171
- ` ${mark(r.status)}`,
172
- r.operation,
173
- dim(r.detail?.slice(0, 100) ?? ''),
174
- ])));
175
- }
176
- return results.some((r) => r.status === 'failed') ? EXIT.failed : EXIT.ok;
177
- }
178
- const mark = (status) => status === 'failed' ? red('failed') : status === 'skipped' ? dim('skipped') : green(status);
179
- // ---------------------------------------------------------------------------
180
- // cache
181
- // ---------------------------------------------------------------------------
182
- async function cache(args) {
183
- const { values, positionals } = parse(args, OPTIONS, `${NAME} cache <ls|clear>`);
184
- const root = values.cache ?? paths.faker();
185
- const sub = positionals[0] ?? 'ls';
186
- if (sub === 'ls') {
187
- const entries = await listGenerators(root);
188
- if (values.json) {
189
- json(entries);
190
- return EXIT.ok;
191
- }
192
- if (entries.length === 0) {
193
- note('nothing cached yet');
194
- return EXIT.ok;
195
- }
196
- writeAll(table([
197
- [bold('KEY'), bold('OPERATION'), bold('MODEL'), bold('TRIES')],
198
- ...entries.map((e) => [
199
- e.key,
200
- `${e.method?.toUpperCase() ?? '?'} ${e.path ?? ''}`,
201
- dim(e.model ?? '—'),
202
- dim(String(e.attempts ?? '—')),
203
- ]),
204
- ]));
205
- return EXIT.ok;
206
- }
207
- if (sub === 'clear') {
208
- rmSync(join(root, GENERATORS), { recursive: true, force: true });
209
- // The container is named after its configuration, so a stale one would
210
- // otherwise sit there stopped forever with nothing pointing at it.
211
- // `zn-<key>-<digest>` is the shape, and this one's key is `faker`.
212
- const mine = (await ownedContainers()).filter((c) => c.name.startsWith('zn-faker-'));
213
- if (mine.length > 0) {
214
- await removeContainers(mine.map((c) => c.name));
215
- }
216
- note(`${green('cleared')} ${dim(root)}`);
217
- return EXIT.ok;
218
- }
219
- throw usageError(`unknown cache command "${sub}"`, `${NAME} cache <ls|clear>`);
220
- }
221
- async function listGenerators(root) {
222
- let keys;
223
- try {
224
- keys = await readdir(join(root, GENERATORS));
225
- }
226
- catch {
227
- return [];
228
- }
229
- const out = [];
230
- for (const key of keys.sort()) {
231
- try {
232
- const meta = JSON.parse(await readFile(join(root, GENERATORS, key, 'meta.json'), 'utf8'));
233
- out.push({ key, ...meta });
234
- }
235
- catch {
236
- out.push({ key });
237
- }
238
- }
239
- return out;
240
- }
241
- function summarize(operations) {
242
- const by = new Map();
243
- for (const op of operations) {
244
- let stat = by.get(op.source);
245
- if (!stat) {
246
- stat = { source: op.source, paths: new Set(), methods: 0, functions: 0 };
247
- by.set(op.source, stat);
248
- }
249
- stat.paths.add(op.path);
250
- stat.methods += 1;
251
- if (op.success.schema) {
252
- stat.functions += 1;
253
- }
254
- }
255
- return [...by.values()];
256
- }
257
- const HEADERS = ['PATHS', 'METHODS', 'FUNCTIONS'];
258
- function printSpecs(operations) {
259
- const stats = summarize(operations);
260
- const rows = stats.map((s) => ({
261
- name: relative(process.cwd(), s.source) || s.source,
262
- cells: [s.paths.size, s.methods, s.functions],
263
- }));
264
- if (rows.length > 1) {
265
- rows.push({
266
- name: 'total',
267
- cells: HEADERS.map((_, i) => rows.reduce((n, r) => n + r.cells[i], 0)),
268
- });
269
- }
270
- // Numbers are padded before they are styled: a colour code has no width,
271
- // and `table` cannot know that.
272
- const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r.cells[i]).length)));
273
- const lines = table([
274
- [bold('SPEC'), ...HEADERS.map((h, i) => bold(h.padStart(widths[i])))],
275
- ...rows.map((r) => [
276
- r.name === 'total' ? dim(r.name) : r.name,
277
- ...r.cells.map((c, i) => String(c).padStart(widths[i])),
278
- ]),
279
- ]);
280
- note('');
281
- for (const line of lines) {
282
- note(` ${line}`);
283
- }
284
- note('');
285
- }
286
- // ---------------------------------------------------------------------------
287
- // Shared
288
- // ---------------------------------------------------------------------------
289
- async function start(values, specs, what) {
290
- const loud = !values.quiet && !values.json;
291
- return open({
292
- specs,
293
- cwd: process.cwd(),
294
- cache: values.cache,
295
- model: values.model,
296
- image: values.image,
297
- attempts: number(values.attempts, 'attempts'),
298
- concurrency: number(values.concurrency, 'concurrency'),
299
- timeout: number(values.timeout, 'timeout'),
300
- rebuild: values.rebuild,
301
- ephemeral: values['no-cache'],
302
- onImageBuild: loud
303
- ? (tag) => note(`${dim('building')} ${tag} ${dim('— once, then cached')}`)
304
- : undefined,
305
- events: {
306
- onStart: loud
307
- ? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation.path}`)
308
- : undefined,
309
- onAttempt: loud
310
- ? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
311
- : undefined,
312
- onReady: loud && what === 'serve'
313
- ? ({ operation, cached }) => cached
314
- ? undefined
315
- : note(` ${green('ready')} ${dim(operation.operationId)}`)
316
- : undefined,
317
- onFail: loud
318
- ? ({ operation, error }) => note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(error)}`)
319
- : undefined,
320
- },
321
- });
322
- }
323
- function number(raw, what) {
324
- if (raw === undefined) {
325
- return undefined;
326
- }
327
- const value = Number(raw);
328
- if (!Number.isFinite(value)) {
329
- throw usageError(`--${what} must be a number, got "${raw}"`);
330
- }
331
- return value;
332
- }
333
- function usage() {
334
- printBanner(BANNER);
335
- write(`${bold(NAME)} ${dim('— a mock API from an openapi/swagger document')}`);
336
- write(`\n${bold('Usage')}\n ${USAGE}`);
337
- write(`\n${bold('Commands')}`);
338
- writeAll(table([
339
- [' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
340
- [' build <spec...>', dim('Write every generator now and exit.')],
341
- [' cache ls|clear', dim('What has been generated, or throw it away.')],
342
- ]));
343
- write(`\n${bold('Options')}`);
344
- writeAll(table([
345
- [' --port <n>', dim('Default 8787.')],
346
- [' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
347
- [' --model <ref>', dim('Which model writes the generators.')],
348
- [' --image <ref>', dim('Skip the baked image and use this one.')],
349
- [' --cache <dir>', dim('Where generators live. Default ~/.zenera/neo/faker.')],
350
- [' --seed <n>', dim('Answer the same request the same way every time.')],
351
- [' --attempts <n>', dim('Tries per generator before giving up. Default 3.')],
352
- [' --concurrency <n>', dim('Generators written at once. Default 4.')],
353
- [' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
354
- [' --max-body <n>', dim('Largest request body accepted, in bytes.')],
355
- [' --rebuild', dim('Ignore what is cached and write it again.')],
356
- [' --no-cache', dim('Do not record what is written.')],
357
- [' --quiet', dim('No narration.')],
358
- [' --json', dim('Machine-readable output.')],
359
- ]));
360
- write(`\n${dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`)}`);
361
- }
362
- process.exitCode = await main(process.argv.slice(2));
363
- //# sourceMappingURL=main.js.map