@zenera/gym 0.0.0-stage → 1.1.38

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/dist/logger.js ADDED
@@ -0,0 +1,321 @@
1
+ import { bold, cyan, dim, green, red, yellow } from '@zenera/cli/lib';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ // ---------------------------------------------------------------------------
6
+ // Formatting Helpers
7
+ // ---------------------------------------------------------------------------
8
+ export function formatTimestamp(d) {
9
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
10
+ const yyyy = d.getFullYear();
11
+ const mm = pad(d.getMonth() + 1);
12
+ const dd = pad(d.getDate());
13
+ const hh = pad(d.getHours());
14
+ const min = pad(d.getMinutes());
15
+ const ss = pad(d.getSeconds());
16
+ const ms = pad(d.getMilliseconds(), 3);
17
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}.${ms}`;
18
+ }
19
+ export function statusText(status) {
20
+ switch (status) {
21
+ case 200:
22
+ return 'OK';
23
+ case 201:
24
+ return 'Created';
25
+ case 202:
26
+ return 'Accepted';
27
+ case 204:
28
+ return 'No Content';
29
+ case 400:
30
+ return 'Bad Request';
31
+ case 401:
32
+ return 'Unauthorized';
33
+ case 403:
34
+ return 'Forbidden';
35
+ case 404:
36
+ return 'Not Found';
37
+ case 405:
38
+ return 'Method Not Allowed';
39
+ case 413:
40
+ return 'Payload Too Large';
41
+ case 500:
42
+ return 'Internal Server Error';
43
+ case 501:
44
+ return 'Not Implemented';
45
+ case 502:
46
+ return 'Bad Gateway';
47
+ case 503:
48
+ return 'Service Unavailable';
49
+ case 504:
50
+ return 'Gateway Timeout';
51
+ default:
52
+ return status < 400 ? 'Success' : status < 500 ? 'Client Error' : 'Server Error';
53
+ }
54
+ }
55
+ export function colorForStatus(status) {
56
+ if (status < 300)
57
+ return green;
58
+ if (status < 400)
59
+ return cyan;
60
+ if (status < 500)
61
+ return yellow;
62
+ return red;
63
+ }
64
+ export function colorForErrorKind(kind) {
65
+ if (kind.startsWith('CLIENT_ERROR'))
66
+ return yellow;
67
+ if (kind === 'SUCCESS')
68
+ return green;
69
+ return (s) => red(bold(s));
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // Markdown Request / Response Dump File
73
+ // ---------------------------------------------------------------------------
74
+ /**
75
+ * Saves a markdown file with all input information and output data for a request.
76
+ * Returns the absolute path of the generated markdown file.
77
+ */
78
+ export async function saveRequestDump(dir, data) {
79
+ mkdirSync(dir, { recursive: true });
80
+ const safePath = data.pathname.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'root';
81
+ const isoPrefix = data.timestamp.toISOString().replace(/[:.]/g, '-');
82
+ const filename = `${isoPrefix}-${data.method.toUpperCase()}-${safePath}-${data.id}.md`;
83
+ const fullPath = join(dir, filename);
84
+ const lines = [
85
+ `# Request: ${data.method.toUpperCase()} ${data.pathname}${data.search}`,
86
+ '',
87
+ `- **Request ID**: \`${data.id}\``,
88
+ `- **Timestamp**: ${data.timestamp.toISOString()}`,
89
+ `- **Status**: ${data.status} ${statusText(data.status)}`,
90
+ `- **Duration**: ${data.durationMs}ms`,
91
+ `- **Operation ID**: ${data.operationId ? `\`${data.operationId}\`` : '_None_'}`,
92
+ `- **Route Pattern**: ${data.routePath ? `\`${data.routePath}\`` : '_None_'}`,
93
+ `- **Cache**: ${data.cacheStatus ?? 'n/a'}`,
94
+ ];
95
+ if (data.errorKind) {
96
+ lines.push(`- **Error Kind**: \`${data.errorKind}\``);
97
+ }
98
+ if (data.regenerated) {
99
+ lines.push(`- **Regenerated**: Yes (attempts: ${data.regenAttempts ?? 1})`);
100
+ }
101
+ if (data.regenLimitHit) {
102
+ lines.push(`- **Regeneration Limit**: Hit (limit: ${data.regenLimit})`);
103
+ }
104
+ if (data.note) {
105
+ lines.push(`- **Note**: ${data.note}`);
106
+ }
107
+ if (data.buildDump) {
108
+ lines.push(`- **Build report**: [${data.buildDump}](file://${data.buildDump})`);
109
+ }
110
+ lines.push('', '---', '', '## Request Info', '');
111
+ lines.push('### URL & Method', '```http', `${data.method.toUpperCase()} ${data.url}`, '```', '');
112
+ lines.push('### Path Parameters');
113
+ if (data.pathParams && Object.keys(data.pathParams).length > 0) {
114
+ lines.push('```json', JSON.stringify(data.pathParams, null, 2), '```');
115
+ }
116
+ else {
117
+ lines.push('_None_');
118
+ }
119
+ lines.push('');
120
+ lines.push('### Query Parameters');
121
+ if (data.query && Object.keys(data.query).length > 0) {
122
+ lines.push('```json', JSON.stringify(data.query, null, 2), '```');
123
+ }
124
+ else {
125
+ lines.push('_None_');
126
+ }
127
+ lines.push('');
128
+ lines.push('### Request Headers');
129
+ if (data.requestHeaders && Object.keys(data.requestHeaders).length > 0) {
130
+ lines.push('```json', JSON.stringify(data.requestHeaders, null, 2), '```');
131
+ }
132
+ else {
133
+ lines.push('_None_');
134
+ }
135
+ lines.push('');
136
+ lines.push('### Request Body');
137
+ if (data.requestBody !== undefined) {
138
+ lines.push('```json', typeof data.requestBody === 'string'
139
+ ? data.requestBody
140
+ : JSON.stringify(data.requestBody, null, 2), '```');
141
+ }
142
+ else {
143
+ lines.push('_Empty_');
144
+ }
145
+ lines.push('');
146
+ lines.push('---', '', '## Response Data', '');
147
+ lines.push('### Status', '```http', `HTTP/1.1 ${data.status} ${statusText(data.status)}`, '```', '');
148
+ lines.push('### Response Headers');
149
+ if (data.responseHeaders && Object.keys(data.responseHeaders).length > 0) {
150
+ lines.push('```json', JSON.stringify(data.responseHeaders, null, 2), '```');
151
+ }
152
+ else {
153
+ lines.push('_None_');
154
+ }
155
+ lines.push('');
156
+ lines.push('### Response Body');
157
+ if (data.responseBody !== undefined) {
158
+ lines.push('```json', typeof data.responseBody === 'string'
159
+ ? data.responseBody
160
+ : JSON.stringify(data.responseBody, null, 2), '```');
161
+ }
162
+ else {
163
+ lines.push('_Empty_');
164
+ }
165
+ lines.push('');
166
+ if (data.errorMessage || data.stderr || (data.issues && data.issues.length > 0)) {
167
+ lines.push('---', '', '## Error & Diagnostics', '');
168
+ if (data.errorKind) {
169
+ lines.push(`### Classification`, `\`${data.errorKind}\``, '');
170
+ }
171
+ if (data.errorMessage) {
172
+ lines.push(`### Error Message`, `\`${data.errorMessage}\``, '');
173
+ }
174
+ if (data.hint) {
175
+ lines.push(`### What to do`, data.hint, '');
176
+ }
177
+ if (data.issues && data.issues.length > 0) {
178
+ lines.push('### Validation Issues', '```json', JSON.stringify(data.issues, null, 2), '```', '');
179
+ }
180
+ if (data.stderr) {
181
+ lines.push('### Stderr / Traceback', '```', data.stderr, '```', '');
182
+ }
183
+ }
184
+ await writeFile(fullPath, lines.join('\n'), 'utf8');
185
+ return resolve(fullPath);
186
+ }
187
+ export async function saveBuildDump(dir, data) {
188
+ mkdirSync(dir, { recursive: true });
189
+ const safePath = data.path.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'root';
190
+ const isoPrefix = data.started.toISOString().replace(/[:.]/g, '-');
191
+ const what = data.regeneration ? 'regen' : 'build';
192
+ const fullPath = join(dir, `${isoPrefix}-${what}-${data.method.toUpperCase()}-${safePath}.md`);
193
+ const outcome = data.gaveUp
194
+ ? `gave up after ${data.attempts.length} of ${data.limit}`
195
+ : `attempt ${data.attempts.length} of ${data.limit} rejected`;
196
+ const lines = [
197
+ `# ${data.regeneration ? 'Regeneration' : 'Build'}: ` +
198
+ `${data.method.toUpperCase()} ${data.path}`,
199
+ '',
200
+ `- **Operation ID**: ${data.operationId ? `\`${data.operationId}\`` : '_None_'}`,
201
+ `- **Key**: \`${data.key}\``,
202
+ `- **Started**: ${data.started.toISOString()}`,
203
+ `- **Model**: ${data.model ? `\`${data.model}\`` : '_Unknown_'}`,
204
+ `- **Outcome**: ${outcome}`,
205
+ '',
206
+ ];
207
+ for (const a of data.attempts) {
208
+ const elapsed = Math.max(0, a.at.getTime() - data.started.getTime());
209
+ lines.push('---', '', `## Attempt ${a.attempt} of ${data.limit}`, '', `_Rejected ${a.at.toISOString()}, ${elapsed}ms into the build._`, '', '### Why it was rejected', '', ...(a.diagnostics.length > 0
210
+ ? a.diagnostics.map((d) => (d.trimStart().startsWith('-') ? d : `- ${d}`))
211
+ : ['_No diagnostics._']), '', '### Generator', '', '```python', a.source, '```', '');
212
+ }
213
+ await writeFile(fullPath, lines.join('\n'), 'utf8');
214
+ return resolve(fullPath);
215
+ }
216
+ export function formatDumpPath(label, path) {
217
+ return ` ${dim(`↳ ${label}:`)} ${cyan(`file://${resolve(path)}`)}`;
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Log Line Formatter
221
+ // ---------------------------------------------------------------------------
222
+ export function formatLogLine(data, dumpPath) {
223
+ const ts = dim(`[${formatTimestamp(data.timestamp)}]`);
224
+ const method = cyan(bold(data.method.toUpperCase()));
225
+ const target = `${data.pathname}${data.search}`;
226
+ const statusCol = colorForStatus(data.status);
227
+ const status = statusCol(String(data.status));
228
+ const duration = dim(`${data.durationMs}ms`);
229
+ let tag = '';
230
+ if (data.errorKind) {
231
+ const tagCol = colorForErrorKind(data.errorKind);
232
+ tag = ` ${tagCol(`[${data.errorKind}]`)}`;
233
+ }
234
+ else if (data.status < 400) {
235
+ tag = ` ${green('[SUCCESS]')}`;
236
+ }
237
+ const note = data.note ? ` ${dim(`· ${data.note}`)}` : '';
238
+ const errDetail = data.errorMessage ? ` ${dim(`(${data.errorMessage})`)}` : '';
239
+ const firstLine = `${ts} ${method} ${target} ${status} ${duration}${tag}${note}${errDetail}`;
240
+ const lines = [firstLine];
241
+ if (data.stderr && data.stderr.trim()) {
242
+ const lastStderr = data.stderr.trim().split('\n').slice(-4).join('\n ');
243
+ lines.push(` ${dim('stderr:')} ${red(lastStderr)}`);
244
+ }
245
+ if (data.hint) {
246
+ lines.push(` ${dim('↳ fix:')} ${yellow(data.hint)}`);
247
+ }
248
+ if (dumpPath) {
249
+ const fileUrl = `file://${resolve(dumpPath)}`;
250
+ lines.push(` ${dim('↳ dump:')} ${cyan(fileUrl)}`);
251
+ }
252
+ if (data.buildDump) {
253
+ lines.push(formatDumpPath('build', data.buildDump));
254
+ }
255
+ return lines.join('\n');
256
+ }
257
+ // ---------------------------------------------------------------------------
258
+ // Special Log Messages
259
+ // ---------------------------------------------------------------------------
260
+ export function formatRegenLimitHit(urlPath, limit, operationId) {
261
+ const ts = dim(`[${formatTimestamp(new Date())}]`);
262
+ const op = operationId ? ` (${operationId})` : '';
263
+ return `${ts} ${red(bold('[REGENERATION_LIMIT_HIT]'))} Maximum regeneration limit (${limit}) reached for URL "${urlPath}"${op}. Halting further regeneration attempts.`;
264
+ }
265
+ export function formatRegenerating(urlPath, attempt, limit, operationId, reason, stderr) {
266
+ const ts = dim(`[${formatTimestamp(new Date())}]`);
267
+ const op = operationId ? ` (${operationId})` : '';
268
+ const r = reason ? ` [${reason}]` : '';
269
+ const head = `${ts} ${yellow(bold('[REGENERATING]'))} Error in generator for "${urlPath}"${op}${r}. Attempting regeneration (${attempt}/${limit})...`;
270
+ const tail = stderr?.trim().split('\n').slice(-3).join('\n ');
271
+ return tail ? `${head}\n ${dim('stderr:')} ${red(tail)}` : head;
272
+ }
273
+ export function formatRegenerated(urlPath, attempt, durationMs, operationId) {
274
+ const ts = dim(`[${formatTimestamp(new Date())}]`);
275
+ const op = operationId ? ` (${operationId})` : '';
276
+ return `${ts} ${green(bold('[REGENERATED]'))} Successfully regenerated generator for "${urlPath}"${op} in attempt ${attempt} (${durationMs}ms).`;
277
+ }
278
+ export class RequestHistory {
279
+ #successful = new Map();
280
+ #maxExamples;
281
+ constructor(maxExamples = 5) {
282
+ this.#maxExamples = maxExamples;
283
+ }
284
+ recordSuccess(operationKey, input, output) {
285
+ let list = this.#successful.get(operationKey);
286
+ if (!list) {
287
+ list = [];
288
+ this.#successful.set(operationKey, list);
289
+ }
290
+ list.push({ input, output });
291
+ if (list.length > this.#maxExamples) {
292
+ list.shift();
293
+ }
294
+ }
295
+ getSuccessful(operationKey, count = 2) {
296
+ const list = this.#successful.get(operationKey);
297
+ if (!list || list.length === 0) {
298
+ return [];
299
+ }
300
+ return list.slice(-count);
301
+ }
302
+ }
303
+ export class RegenerationLimiter {
304
+ #counts = new Map();
305
+ limit;
306
+ constructor(limit = 3) {
307
+ this.limit = limit;
308
+ }
309
+ countFor(url) {
310
+ return this.#counts.get(url) ?? 0;
311
+ }
312
+ isLimitHit(url) {
313
+ return (this.#counts.get(url) ?? 0) >= this.limit;
314
+ }
315
+ increment(url) {
316
+ const next = (this.#counts.get(url) ?? 0) + 1;
317
+ this.#counts.set(url, next);
318
+ return next;
319
+ }
320
+ }
321
+ //# sourceMappingURL=logger.js.map
package/dist/main.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=main.d.ts.map
package/dist/main.js ADDED
@@ -0,0 +1,363 @@
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
package/dist/page.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type Operation } from './spec.ts';
2
+ export declare function indexPage(operations: readonly Operation[]): string;
3
+ //# sourceMappingURL=page.d.ts.map