@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 +21 -0
- package/README.md +106 -0
- package/dist/box.d.ts +43 -0
- package/dist/box.js +158 -0
- package/dist/cache.d.ts +57 -0
- package/dist/cache.js +125 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +339 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +38 -0
- package/dist/generate.js +125 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +77 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +278 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +98 -0
- package/dist/router.d.ts +14 -0
- package/dist/router.js +83 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +221 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +256 -0
- package/dist/setup.d.ts +35 -0
- package/dist/setup.js +81 -0
- package/dist/spec.d.ts +41 -0
- package/dist/spec.js +257 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +51 -0
package/dist/command.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { bold, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
|
|
5
|
+
import { GENERATORS } from "./box.js";
|
|
6
|
+
import { reason } from "./generate.js";
|
|
7
|
+
import { listen } from "./server.js";
|
|
8
|
+
import { open } from "./setup.js";
|
|
9
|
+
import {} from "./spec.js";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// zen faker — a mock API from a specification
|
|
12
|
+
//
|
|
13
|
+
// A command in another package rather than a binary of its own: one thing to
|
|
14
|
+
// install, one keyring, one name to remember. `zen` loads this module only when
|
|
15
|
+
// somebody types `zen faker`, so nothing here is on the path of `zen list`.
|
|
16
|
+
//
|
|
17
|
+
// That it stays up is not a problem for the frame — `run` simply does not
|
|
18
|
+
// resolve until a signal arrives, and the process lives as long as the answer
|
|
19
|
+
// takes, which is what every other command already means.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const USAGE = 'zen faker <serve|build|cache> [spec...]';
|
|
22
|
+
// `--json` is the frame's, lifted out of the arguments before they arrive; it
|
|
23
|
+
// reaches us as `ctx.json` and must not be declared again or `strict` rejects it.
|
|
24
|
+
const OPTIONS = {
|
|
25
|
+
port: { type: 'string' },
|
|
26
|
+
host: { type: 'string' },
|
|
27
|
+
model: { type: 'string' },
|
|
28
|
+
image: { type: 'string' },
|
|
29
|
+
cache: { type: 'string' },
|
|
30
|
+
attempts: { type: 'string' },
|
|
31
|
+
concurrency: { type: 'string' },
|
|
32
|
+
seed: { type: 'string' },
|
|
33
|
+
timeout: { type: 'string' },
|
|
34
|
+
'max-body': { type: 'string' },
|
|
35
|
+
rebuild: { type: 'boolean' },
|
|
36
|
+
'no-cache': { type: 'boolean' },
|
|
37
|
+
quiet: { type: 'boolean' },
|
|
38
|
+
};
|
|
39
|
+
export const command = {
|
|
40
|
+
summary: 'A mock API from an openapi/swagger document.',
|
|
41
|
+
usage: USAGE,
|
|
42
|
+
details: [
|
|
43
|
+
'Commands',
|
|
44
|
+
...table([
|
|
45
|
+
[' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
|
|
46
|
+
[' build <spec...>', dim('Write every generator now and exit.')],
|
|
47
|
+
[' cache ls|clear', dim('What has been generated, or throw it away.')],
|
|
48
|
+
]),
|
|
49
|
+
'',
|
|
50
|
+
'Options',
|
|
51
|
+
...table([
|
|
52
|
+
[' --port <n>', dim('Default 8787.')],
|
|
53
|
+
[' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
|
|
54
|
+
[' --model <ref>', dim('Which model writes the generators.')],
|
|
55
|
+
[' --image <ref>', dim('Skip the baked image and use this one.')],
|
|
56
|
+
[' --cache <dir>', dim('Where generators live. Default ~/.zenera/neo/faker.')],
|
|
57
|
+
[' --seed <n>', dim('Answer the same request the same way every time.')],
|
|
58
|
+
[' --attempts <n>', dim('Tries per generator before giving up. Default 3.')],
|
|
59
|
+
[' --concurrency <n>', dim('Generators written at once. Default 4.')],
|
|
60
|
+
[' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
|
|
61
|
+
[' --max-body <n>', dim('Largest request body accepted, in bytes.')],
|
|
62
|
+
[' --rebuild', dim('Ignore what is cached and write it again.')],
|
|
63
|
+
[' --no-cache', dim('Do not record what is written.')],
|
|
64
|
+
[' --quiet', dim('No narration.')],
|
|
65
|
+
]),
|
|
66
|
+
'',
|
|
67
|
+
dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
|
|
68
|
+
],
|
|
69
|
+
async run(ctx) {
|
|
70
|
+
const [name, ...rest] = ctx.args;
|
|
71
|
+
switch (name) {
|
|
72
|
+
case 'serve':
|
|
73
|
+
return await serve(rest, ctx);
|
|
74
|
+
case 'build':
|
|
75
|
+
return await warm(rest, ctx);
|
|
76
|
+
case 'cache':
|
|
77
|
+
return await cache(rest, ctx);
|
|
78
|
+
default:
|
|
79
|
+
throw usageError(name ? `unknown command "${name}"` : 'no command given', USAGE);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// serve
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
async function serve(args, ctx) {
|
|
87
|
+
const { values, positionals } = parse(args, OPTIONS, 'zen faker serve <spec...>');
|
|
88
|
+
const loud = !values.quiet && !ctx.json;
|
|
89
|
+
const setup = await start(values, positionals, ctx, 'serve');
|
|
90
|
+
if (loud) {
|
|
91
|
+
printSpecs(setup.router.operations, ctx.cwd);
|
|
92
|
+
}
|
|
93
|
+
const host = values.host ?? '127.0.0.1';
|
|
94
|
+
const listener = await listen({
|
|
95
|
+
router: setup.router,
|
|
96
|
+
cache: setup.cache,
|
|
97
|
+
checks: setup.checks,
|
|
98
|
+
box: setup.box,
|
|
99
|
+
seed: number(values.seed, 'seed'),
|
|
100
|
+
maxBody: number(values['max-body'], 'max-body'),
|
|
101
|
+
onRequest: values.quiet ? undefined : (line) => note(dim(line)),
|
|
102
|
+
}, host, number(values.port, 'port') ?? 8787);
|
|
103
|
+
if (!values.quiet) {
|
|
104
|
+
note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
|
|
105
|
+
dim(`${setup.router.operations.length} operations`));
|
|
106
|
+
if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
107
|
+
note(yellow(`bound to ${host} — this mock is reachable from the network`));
|
|
108
|
+
}
|
|
109
|
+
note(dim(`generators: ${setup.root}/${GENERATORS}`));
|
|
110
|
+
}
|
|
111
|
+
// The address is the answer; the rest was narration.
|
|
112
|
+
write(`http://${host}:${listener.port}`);
|
|
113
|
+
await until(['SIGINT', 'SIGTERM']);
|
|
114
|
+
if (!values.quiet) {
|
|
115
|
+
note(dim('stopping'));
|
|
116
|
+
}
|
|
117
|
+
await listener.close();
|
|
118
|
+
await setup.close();
|
|
119
|
+
}
|
|
120
|
+
/** Resolves when one of the signals arrives, and stops listening for them. */
|
|
121
|
+
function until(signals) {
|
|
122
|
+
return new Promise((settle) => {
|
|
123
|
+
const done = () => {
|
|
124
|
+
for (const s of signals) {
|
|
125
|
+
process.off(s, done);
|
|
126
|
+
}
|
|
127
|
+
settle();
|
|
128
|
+
};
|
|
129
|
+
for (const s of signals) {
|
|
130
|
+
process.once(s, done);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// build — every generator, up front
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
async function warm(args, ctx) {
|
|
138
|
+
const { values, positionals } = parse(args, OPTIONS, 'zen faker build <spec...>');
|
|
139
|
+
const loud = !values.quiet && !ctx.json;
|
|
140
|
+
const setup = await start(values, positionals, ctx, 'build');
|
|
141
|
+
if (loud) {
|
|
142
|
+
printSpecs(setup.router.operations, ctx.cwd);
|
|
143
|
+
}
|
|
144
|
+
const results = [];
|
|
145
|
+
try {
|
|
146
|
+
for (const operation of setup.router.operations) {
|
|
147
|
+
const id = `${operation.method.toUpperCase()} ${operation.path}`;
|
|
148
|
+
if (!operation.success.schema) {
|
|
149
|
+
results.push({ operation: id, status: 'skipped', detail: 'no response body' });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const generator = await setup.cache.ensure(operation);
|
|
154
|
+
results.push({ operation: id, status: generator.cached ? 'cached' : 'built' });
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
results.push({
|
|
158
|
+
operation: id,
|
|
159
|
+
status: 'failed',
|
|
160
|
+
detail: reason(err),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await setup.close();
|
|
167
|
+
}
|
|
168
|
+
if (ctx.json) {
|
|
169
|
+
json(results);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
writeAll(table(results.map((r) => [
|
|
173
|
+
` ${mark(r.status)}`,
|
|
174
|
+
r.operation,
|
|
175
|
+
dim(r.detail?.slice(0, 100) ?? ''),
|
|
176
|
+
])));
|
|
177
|
+
}
|
|
178
|
+
// Reported first, then failed: the table is the answer either way.
|
|
179
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
180
|
+
if (failed > 0) {
|
|
181
|
+
throw new CliError(`${failed} of ${results.length} generators failed`, EXIT.failed, 'run again to retry, or raise --attempts');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const mark = (status) => status === 'failed' ? red('failed') : status === 'skipped' ? dim('skipped') : green(status);
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// cache
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
async function cache(args, ctx) {
|
|
189
|
+
const { values, positionals } = parse(args, OPTIONS, 'zen faker cache <ls|clear>');
|
|
190
|
+
const root = values.cache ? resolve(ctx.cwd, values.cache) : paths.faker();
|
|
191
|
+
const sub = positionals[0] ?? 'ls';
|
|
192
|
+
if (sub === 'ls') {
|
|
193
|
+
const entries = await listGenerators(root);
|
|
194
|
+
if (ctx.json) {
|
|
195
|
+
json(entries);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (entries.length === 0) {
|
|
199
|
+
note('nothing cached yet');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
writeAll(table([
|
|
203
|
+
[bold('KEY'), bold('OPERATION'), bold('MODEL'), bold('TRIES')],
|
|
204
|
+
...entries.map((e) => [
|
|
205
|
+
e.key,
|
|
206
|
+
`${e.method?.toUpperCase() ?? '?'} ${e.path ?? ''}`,
|
|
207
|
+
dim(e.model ?? '—'),
|
|
208
|
+
dim(String(e.attempts ?? '—')),
|
|
209
|
+
]),
|
|
210
|
+
]));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (sub === 'clear') {
|
|
214
|
+
rmSync(join(root, GENERATORS), { recursive: true, force: true });
|
|
215
|
+
// The container is named after its configuration, so a stale one would
|
|
216
|
+
// otherwise sit there stopped forever with nothing pointing at it.
|
|
217
|
+
// `zn-<key>-<digest>` is the shape, and this one's key is `faker`.
|
|
218
|
+
const mine = (await ownedContainers()).filter((c) => c.name.startsWith('zn-faker-'));
|
|
219
|
+
if (mine.length > 0) {
|
|
220
|
+
await removeContainers(mine.map((c) => c.name));
|
|
221
|
+
}
|
|
222
|
+
note(`${green('cleared')} ${dim(root)}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
throw usageError(`unknown cache command "${sub}"`, 'zen faker cache <ls|clear>');
|
|
226
|
+
}
|
|
227
|
+
async function listGenerators(root) {
|
|
228
|
+
let keys;
|
|
229
|
+
try {
|
|
230
|
+
keys = await readdir(join(root, GENERATORS));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const key of keys.sort()) {
|
|
237
|
+
try {
|
|
238
|
+
const meta = JSON.parse(await readFile(join(root, GENERATORS, key, 'meta.json'), 'utf8'));
|
|
239
|
+
out.push({ key, ...meta });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
out.push({ key });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
function summarize(operations) {
|
|
248
|
+
const by = new Map();
|
|
249
|
+
for (const op of operations) {
|
|
250
|
+
let stat = by.get(op.source);
|
|
251
|
+
if (!stat) {
|
|
252
|
+
stat = { source: op.source, paths: new Set(), methods: 0, functions: 0 };
|
|
253
|
+
by.set(op.source, stat);
|
|
254
|
+
}
|
|
255
|
+
stat.paths.add(op.path);
|
|
256
|
+
stat.methods += 1;
|
|
257
|
+
if (op.success.schema) {
|
|
258
|
+
stat.functions += 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return [...by.values()];
|
|
262
|
+
}
|
|
263
|
+
const HEADERS = ['PATHS', 'METHODS', 'FUNCTIONS'];
|
|
264
|
+
function printSpecs(operations, cwd) {
|
|
265
|
+
const stats = summarize(operations);
|
|
266
|
+
const rows = stats.map((s) => ({
|
|
267
|
+
name: relative(cwd, s.source) || s.source,
|
|
268
|
+
cells: [s.paths.size, s.methods, s.functions],
|
|
269
|
+
}));
|
|
270
|
+
if (rows.length > 1) {
|
|
271
|
+
rows.push({
|
|
272
|
+
name: 'total',
|
|
273
|
+
cells: HEADERS.map((_, i) => rows.reduce((n, r) => n + r.cells[i], 0)),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// Numbers are padded before they are styled: a colour code has no width,
|
|
277
|
+
// and `table` cannot know that.
|
|
278
|
+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r.cells[i]).length)));
|
|
279
|
+
const lines = table([
|
|
280
|
+
[bold('SPEC'), ...HEADERS.map((h, i) => bold(h.padStart(widths[i])))],
|
|
281
|
+
...rows.map((r) => [
|
|
282
|
+
r.name === 'total' ? dim(r.name) : r.name,
|
|
283
|
+
...r.cells.map((c, i) => String(c).padStart(widths[i])),
|
|
284
|
+
]),
|
|
285
|
+
]);
|
|
286
|
+
note('');
|
|
287
|
+
for (const line of lines) {
|
|
288
|
+
note(` ${line}`);
|
|
289
|
+
}
|
|
290
|
+
note('');
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Shared
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
async function start(values, specs, ctx, what) {
|
|
296
|
+
const loud = !values.quiet && !ctx.json;
|
|
297
|
+
return open({
|
|
298
|
+
specs,
|
|
299
|
+
cwd: ctx.cwd,
|
|
300
|
+
cache: values.cache,
|
|
301
|
+
model: values.model,
|
|
302
|
+
image: values.image,
|
|
303
|
+
attempts: number(values.attempts, 'attempts'),
|
|
304
|
+
concurrency: number(values.concurrency, 'concurrency'),
|
|
305
|
+
timeout: number(values.timeout, 'timeout'),
|
|
306
|
+
rebuild: values.rebuild,
|
|
307
|
+
ephemeral: values['no-cache'],
|
|
308
|
+
onImageBuild: loud
|
|
309
|
+
? (tag) => note(`${dim('building')} ${tag} ${dim('— once, then cached')}`)
|
|
310
|
+
: undefined,
|
|
311
|
+
events: {
|
|
312
|
+
onStart: loud
|
|
313
|
+
? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation.path}`)
|
|
314
|
+
: undefined,
|
|
315
|
+
onAttempt: loud
|
|
316
|
+
? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
|
|
317
|
+
: undefined,
|
|
318
|
+
onReady: loud && what === 'serve'
|
|
319
|
+
? ({ operation, cached }) => cached
|
|
320
|
+
? undefined
|
|
321
|
+
: note(` ${green('ready')} ${dim(operation.operationId)}`)
|
|
322
|
+
: undefined,
|
|
323
|
+
onFail: loud
|
|
324
|
+
? ({ operation, error }) => note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(error)}`)
|
|
325
|
+
: undefined,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function number(raw, what) {
|
|
330
|
+
if (raw === undefined) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
const value = Number(raw);
|
|
334
|
+
if (!Number.isFinite(value)) {
|
|
335
|
+
throw usageError(`--${what} must be a number, got "${raw}"`);
|
|
336
|
+
}
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
//# sourceMappingURL=command.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface GeneratorInput {
|
|
2
|
+
operationId: string;
|
|
3
|
+
method: string;
|
|
4
|
+
/** the template, not the resolved path — `/users/{user_id}` */
|
|
5
|
+
path: string;
|
|
6
|
+
pathParams: Record<string, unknown>;
|
|
7
|
+
query: Record<string, unknown>;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
body: unknown;
|
|
10
|
+
/** seeds `random` and `Faker` inside the generator */
|
|
11
|
+
seed: number;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=envelope.d.ts.map
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// What a generator is handed
|
|
3
|
+
//
|
|
4
|
+
// One shape, written by the server for a real request and by the build loop for
|
|
5
|
+
// a synthetic probe. That they are the same shape is the whole reason a
|
|
6
|
+
// generator that satisfies its probes also satisfies traffic.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=envelope.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Model } from '@zenera/neo';
|
|
2
|
+
import type { Box } from './box.ts';
|
|
3
|
+
import type { Operation } from './spec.ts';
|
|
4
|
+
import { type Checks } from './validate.ts';
|
|
5
|
+
export interface BuildOptions {
|
|
6
|
+
model: Model;
|
|
7
|
+
box: Box;
|
|
8
|
+
checks: Checks;
|
|
9
|
+
/** how many times the model may be asked before giving up */
|
|
10
|
+
attempts?: number;
|
|
11
|
+
onAttempt?: (attempt: number, diagnostics: readonly string[]) => void;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
export interface Built {
|
|
15
|
+
source: string;
|
|
16
|
+
attempts: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class BuildFailed extends Error {
|
|
19
|
+
readonly operation: Operation;
|
|
20
|
+
readonly diagnostics: readonly string[];
|
|
21
|
+
constructor(operation: Operation, diagnostics: readonly string[]);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Why something failed, in one line, including the part that is usually hidden.
|
|
25
|
+
*
|
|
26
|
+
* `fetch failed` is undici's word for a dozen different problems — a DNS
|
|
27
|
+
* miss, a refused connection, a TLS error, a timeout — and which one it was is
|
|
28
|
+
* only ever in `cause`. Reporting the top-level message alone turns every
|
|
29
|
+
* network fault into the same useless sentence.
|
|
30
|
+
*/
|
|
31
|
+
export declare function reason(err: unknown): string;
|
|
32
|
+
export declare function build(operation: Operation, opts: BuildOptions): Promise<Built>;
|
|
33
|
+
/**
|
|
34
|
+
* Models fence code even when told not to, and a stray ```python line is a
|
|
35
|
+
* syntax error rather than a bad answer — not worth a round trip.
|
|
36
|
+
*/
|
|
37
|
+
export declare function unfence(text: string): string;
|
|
38
|
+
//# sourceMappingURL=generate.d.ts.map
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { echoIssues, probesFor } from "./probe.js";
|
|
2
|
+
import { instruction, retry, SYSTEM } from "./prompt.js";
|
|
3
|
+
import { describeIssues, issues } from "./validate.js";
|
|
4
|
+
export class BuildFailed extends Error {
|
|
5
|
+
operation;
|
|
6
|
+
diagnostics;
|
|
7
|
+
constructor(operation, diagnostics) {
|
|
8
|
+
super(`could not write a generator for ${operation.method.toUpperCase()} ${operation.path}`);
|
|
9
|
+
this.name = 'BuildFailed';
|
|
10
|
+
this.operation = operation;
|
|
11
|
+
this.diagnostics = diagnostics;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Why something failed, in one line, including the part that is usually hidden.
|
|
16
|
+
*
|
|
17
|
+
* `fetch failed` is undici's word for a dozen different problems — a DNS
|
|
18
|
+
* miss, a refused connection, a TLS error, a timeout — and which one it was is
|
|
19
|
+
* only ever in `cause`. Reporting the top-level message alone turns every
|
|
20
|
+
* network fault into the same useless sentence.
|
|
21
|
+
*/
|
|
22
|
+
export function reason(err) {
|
|
23
|
+
if (err instanceof BuildFailed && err.diagnostics.length > 0) {
|
|
24
|
+
return `${err.message}: ${err.diagnostics.join(' ')}`;
|
|
25
|
+
}
|
|
26
|
+
const parts = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
let at = err;
|
|
29
|
+
while (at instanceof Error && !seen.has(at)) {
|
|
30
|
+
seen.add(at);
|
|
31
|
+
const e = at;
|
|
32
|
+
const head = [e.status ?? e.statusCode, e.code].filter(Boolean).join(' ');
|
|
33
|
+
const line = `${head} ${e.message.split('\n')[0].trim()}`.trim();
|
|
34
|
+
if (line && !parts.includes(line)) {
|
|
35
|
+
parts.push(line);
|
|
36
|
+
}
|
|
37
|
+
at = e.cause;
|
|
38
|
+
}
|
|
39
|
+
return parts.join(' — ') || String(err);
|
|
40
|
+
}
|
|
41
|
+
export async function build(operation, opts) {
|
|
42
|
+
const limit = Math.max(1, opts.attempts ?? 3);
|
|
43
|
+
const probes = probesFor(operation);
|
|
44
|
+
const response = opts.checks.for(operation).response;
|
|
45
|
+
const messages = [
|
|
46
|
+
{ role: 'user', content: [{ type: 'text', text: instruction(operation) }] },
|
|
47
|
+
];
|
|
48
|
+
let last = [];
|
|
49
|
+
for (let attempt = 1; attempt <= limit; attempt++) {
|
|
50
|
+
const answer = await ask(opts, {
|
|
51
|
+
system: SYSTEM,
|
|
52
|
+
messages,
|
|
53
|
+
tools: [],
|
|
54
|
+
signal: opts.signal,
|
|
55
|
+
});
|
|
56
|
+
const source = unfence(answer.text);
|
|
57
|
+
if (!source.trim()) {
|
|
58
|
+
last = ['the answer was empty'];
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await opts.box.write(operation.key, source);
|
|
62
|
+
last = await judge(operation, probes, response, opts.box);
|
|
63
|
+
if (last.length === 0) {
|
|
64
|
+
return { source, attempts: attempt };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
opts.onAttempt?.(attempt, last);
|
|
68
|
+
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
69
|
+
}
|
|
70
|
+
throw new BuildFailed(operation, last);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Streamed when the adapter can, and every delta is dropped: nothing here
|
|
74
|
+
* renders progress. It is the *socket* that needs them.
|
|
75
|
+
*
|
|
76
|
+
* A generator is a few hundred lines, and a model asked for one in a single
|
|
77
|
+
* non-streaming call sends nothing at all while it thinks. Something upstream
|
|
78
|
+
* closes a connection that has been idle for a minute, and the whole attempt
|
|
79
|
+
* dies with `UND_ERR_SOCKET other side closed` at ~61 s — not a timeout anyone
|
|
80
|
+
* here set, and not the SDK's, which is ten minutes. Tokens on the wire keep
|
|
81
|
+
* it open.
|
|
82
|
+
*/
|
|
83
|
+
function ask(opts, request) {
|
|
84
|
+
return opts.model.stream ? opts.model.stream(request, discard) : opts.model.generate(request);
|
|
85
|
+
}
|
|
86
|
+
const discard = () => { };
|
|
87
|
+
/** Every probe, run and checked. Empty means the generator is good. */
|
|
88
|
+
async function judge(operation, probes, response, box) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const probe of probes) {
|
|
91
|
+
const outcome = await box.run(operation.key, probe);
|
|
92
|
+
const called = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
|
|
93
|
+
if (!outcome.ok) {
|
|
94
|
+
out.push(`- ${called}: the file ${outcome.fault}.`);
|
|
95
|
+
if (outcome.stderr) {
|
|
96
|
+
out.push(` stderr: ${tail(outcome.stderr)}`);
|
|
97
|
+
}
|
|
98
|
+
// A file that will not run says nothing about the next probe.
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (response && !response(outcome.value)) {
|
|
102
|
+
out.push(`- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
|
|
103
|
+
}
|
|
104
|
+
const echo = echoIssues(probe, outcome.value, operation.success.schema);
|
|
105
|
+
if (echo.length > 0) {
|
|
106
|
+
out.push(`- ${called}: ${describeIssues(echo)}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Models fence code even when told not to, and a stray ```python line is a
|
|
113
|
+
* syntax error rather than a bad answer — not worth a round trip.
|
|
114
|
+
*/
|
|
115
|
+
export function unfence(text) {
|
|
116
|
+
const trimmed = text.trim();
|
|
117
|
+
if (!trimmed.startsWith('```')) {
|
|
118
|
+
return trimmed;
|
|
119
|
+
}
|
|
120
|
+
const body = trimmed.slice(trimmed.indexOf('\n') + 1);
|
|
121
|
+
const close = body.lastIndexOf('```');
|
|
122
|
+
return (close === -1 ? body : body.slice(0, close)).trimEnd();
|
|
123
|
+
}
|
|
124
|
+
const tail = (s) => s.trim().split('\n').slice(-6).join('\n ');
|
|
125
|
+
//# sourceMappingURL=generate.js.map
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Runner } from '@zenera/neo';
|
|
2
|
+
/**
|
|
3
|
+
* Lower bounds rather than exact pins: the tag is a function of this list, so
|
|
4
|
+
* an edit here yields a new image and a new container either way, and pinning a
|
|
5
|
+
* patch release would only mean a rebuild every time one is published.
|
|
6
|
+
*/
|
|
7
|
+
export declare const REQUIREMENTS: readonly ["Faker>=37", "exrex>=0.11", "jsonschema>=4.23", "python-dateutil>=2.9"];
|
|
8
|
+
/** What the generator may import. Quoted verbatim in the prompt. */
|
|
9
|
+
export declare const AVAILABLE: readonly ["faker", "exrex", "jsonschema", "dateutil"];
|
|
10
|
+
export declare const BASE_IMAGE = "docker.io/library/python:3.14-slim-bookworm";
|
|
11
|
+
export declare function imageTag(base?: string): string;
|
|
12
|
+
export interface ImageOptions {
|
|
13
|
+
/** where the empty build context is created */
|
|
14
|
+
root: string;
|
|
15
|
+
base?: string;
|
|
16
|
+
engine?: string;
|
|
17
|
+
exec?: Runner;
|
|
18
|
+
onBuild?: (tag: string) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The tag to run, built if it is not there yet. Returns without touching podman
|
|
22
|
+
* when the image already exists, which is every start after the first.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ensureImage(opts: ImageOptions): Promise<string>;
|
|
25
|
+
//# sourceMappingURL=image.d.ts.map
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DEFAULT_SANDBOX_IMAGE, runProcess } from '@zenera/neo';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The image
|
|
7
|
+
//
|
|
8
|
+
// The generators are Python, and Python that has to invent a plausible name out
|
|
9
|
+
// of `random.choice` writes output that looks like test data because it is. So
|
|
10
|
+
// the container gets real libraries.
|
|
11
|
+
//
|
|
12
|
+
// They cannot be installed into the *running* box. `containerName()` in
|
|
13
|
+
// @zenera/neo hashes the image and the network, so "start online, install, then
|
|
14
|
+
// switch to `network: none`" resolves to two different containers and the
|
|
15
|
+
// install goes with the first one. Baking them into an image instead is one
|
|
16
|
+
// build, cached by podman's own layers, and leaves the serving container
|
|
17
|
+
// offline — which is the property that matters, because the code it runs was
|
|
18
|
+
// written by a model.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Lower bounds rather than exact pins: the tag is a function of this list, so
|
|
22
|
+
* an edit here yields a new image and a new container either way, and pinning a
|
|
23
|
+
* patch release would only mean a rebuild every time one is published.
|
|
24
|
+
*/
|
|
25
|
+
export const REQUIREMENTS = [
|
|
26
|
+
'Faker>=37',
|
|
27
|
+
'exrex>=0.11',
|
|
28
|
+
'jsonschema>=4.23',
|
|
29
|
+
'python-dateutil>=2.9',
|
|
30
|
+
];
|
|
31
|
+
/** What the generator may import. Quoted verbatim in the prompt. */
|
|
32
|
+
export const AVAILABLE = ['faker', 'exrex', 'jsonschema', 'dateutil'];
|
|
33
|
+
export const BASE_IMAGE = DEFAULT_SANDBOX_IMAGE;
|
|
34
|
+
export function imageTag(base = BASE_IMAGE) {
|
|
35
|
+
const digest = createHash('sha256')
|
|
36
|
+
.update(JSON.stringify([base, [...REQUIREMENTS].sort()]))
|
|
37
|
+
.digest('hex')
|
|
38
|
+
.slice(0, 12);
|
|
39
|
+
return `localhost/zenera-faker:${digest}`;
|
|
40
|
+
}
|
|
41
|
+
function containerfile(base) {
|
|
42
|
+
return [
|
|
43
|
+
`FROM ${base}`,
|
|
44
|
+
`RUN pip install --no-cache-dir --disable-pip-version-check ${REQUIREMENTS.join(' ')}`,
|
|
45
|
+
'',
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The tag to run, built if it is not there yet. Returns without touching podman
|
|
50
|
+
* when the image already exists, which is every start after the first.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureImage(opts) {
|
|
53
|
+
const base = opts.base ?? BASE_IMAGE;
|
|
54
|
+
const tag = imageTag(base);
|
|
55
|
+
const engine = opts.engine ?? 'podman';
|
|
56
|
+
const run = opts.exec ?? runProcess;
|
|
57
|
+
const exists = await run(engine, ['image', 'exists', tag], { timeoutMs: 60_000 });
|
|
58
|
+
if (exists.code === 0) {
|
|
59
|
+
return tag;
|
|
60
|
+
}
|
|
61
|
+
opts.onBuild?.(tag);
|
|
62
|
+
// An empty directory, because the Containerfile arrives on stdin and there
|
|
63
|
+
// is nothing to COPY: handing podman the working directory instead would
|
|
64
|
+
// tar up whatever happened to be in it.
|
|
65
|
+
const context = join(opts.root, 'build');
|
|
66
|
+
mkdirSync(context, { recursive: true });
|
|
67
|
+
const built = await run(engine, ['build', '--tag', tag, '--file', '-', context], {
|
|
68
|
+
input: containerfile(base),
|
|
69
|
+
timeoutMs: 900_000,
|
|
70
|
+
maxBytes: 256 * 1024,
|
|
71
|
+
});
|
|
72
|
+
if (built.code !== 0) {
|
|
73
|
+
throw new Error(`could not build ${tag}: ${(built.stderr.trim() || built.stdout.trim()).split('\n').slice(-3).join(' ')}`);
|
|
74
|
+
}
|
|
75
|
+
return tag;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=image.js.map
|
package/dist/main.d.ts
ADDED