@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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=main.d.ts.map