@wacht/bench 0.1.2 → 0.1.4
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/commands.js +23 -3
- package/dist/docs-search.js +81 -0
- package/dist/env-pull.js +7 -2
- package/dist/init.js +33 -8
- package/dist/machine-api.js +8 -1
- package/dist/openapi-validate.js +172 -0
- package/dist/openapi.js +223 -3
- package/package.json +2 -1
package/dist/commands.js
CHANGED
|
@@ -16,6 +16,7 @@ import { completionScript } from './completion.js';
|
|
|
16
16
|
import { configApply, configDiff, configPull, configSchemaCommand, printConfigTemplate, } from './config-workflow.js';
|
|
17
17
|
import { clearDeployment, createDeploymentCommand, createProjectCommand, currentDeployment, selectDeployment, } from './deployment-context.js';
|
|
18
18
|
import { initProject, initStarter } from './init.js';
|
|
19
|
+
import { docsSearch } from './docs-search.js';
|
|
19
20
|
import { envPull } from './env-pull.js';
|
|
20
21
|
import { apiCommand, listProjects } from './machine-api.js';
|
|
21
22
|
import { openApiCall, openApiDescribe, openApiList, openApiRefresh } from './openapi.js';
|
|
@@ -219,6 +220,21 @@ export async function runCli(args) {
|
|
|
219
220
|
.action((options) => {
|
|
220
221
|
printMcpConfig(options.client);
|
|
221
222
|
});
|
|
223
|
+
const docs = program.command('docs').description('search and explore Wacht docs');
|
|
224
|
+
docs
|
|
225
|
+
.command('search <query...>')
|
|
226
|
+
.description('full-text search Wacht docs and print matching pages')
|
|
227
|
+
.option('--limit <n>', 'max number of pages to print', (v) => Number.parseInt(v, 10))
|
|
228
|
+
.option('--base-url <url>', 'docs base URL (default: https://wacht.dev/docs, or $WACHT_DOCS_URL)')
|
|
229
|
+
.option('--json', 'emit JSON instead of human-readable output')
|
|
230
|
+
.action(async (queryParts, options) => {
|
|
231
|
+
await docsSearch(context(program), {
|
|
232
|
+
query: queryParts.join(' '),
|
|
233
|
+
limit: options.limit,
|
|
234
|
+
baseUrl: options.baseUrl,
|
|
235
|
+
json: options.json,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
222
238
|
const env = program.command('env').description('manage deployment credentials and environment files');
|
|
223
239
|
env
|
|
224
240
|
.command('pull')
|
|
@@ -307,14 +323,18 @@ export async function runCli(args) {
|
|
|
307
323
|
.argument('<operation>', 'OpenAPI operation id')
|
|
308
324
|
.option('--deployment <id>', 'deployment id override; defaults to active deployment')
|
|
309
325
|
.option('--param <key=value>', 'path or query parameter; repeatable', collect, [])
|
|
310
|
-
.option('--body <json>', 'JSON request body')
|
|
326
|
+
.option('--body <json>', 'JSON request body; pass @path/to/file.json to read from disk')
|
|
311
327
|
.option('--field <key=value>', 'URL-encoded form field; repeatable', collect, [])
|
|
312
328
|
.option('--form <key=value>', 'multipart form field; value @path is treated as a file; repeatable', collect, [])
|
|
313
329
|
.option('--file <key=path>', 'multipart file field; key=path or key=@path; repeatable', collect, [])
|
|
314
330
|
.option('--header <key=value>', 'request header; repeatable', collect, [])
|
|
315
331
|
.option('--refresh', 'refresh the cached OpenAPI schema first')
|
|
316
|
-
.
|
|
317
|
-
|
|
332
|
+
.option('--no-validate', 'skip local JSON Schema validation of the request body')
|
|
333
|
+
.action(async (operation, _options, cmd) => {
|
|
334
|
+
// The parent `api` command also declares --body / --field / --form / --file / --header
|
|
335
|
+
// so `optsWithGlobals()` is what actually surfaces them through this subcommand.
|
|
336
|
+
// Commander does not merge clashing options automatically.
|
|
337
|
+
await openApiCall(context(program), operation, cmd.optsWithGlobals());
|
|
318
338
|
});
|
|
319
339
|
const schema = api.command('schema').description('manage cached OpenAPI schema');
|
|
320
340
|
schema
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { field, log, printBannerFor, printError, printJson, section } from './ui.js';
|
|
2
|
+
const DEFAULT_DOCS_URL = 'https://wacht.dev/docs';
|
|
3
|
+
export async function docsSearch(ctx, options) {
|
|
4
|
+
const baseUrl = (options.baseUrl ?? process.env.WACHT_DOCS_URL ?? DEFAULT_DOCS_URL).replace(/\/+$/, '');
|
|
5
|
+
const endpoint = `${baseUrl}/api/search?query=${encodeURIComponent(options.query)}`;
|
|
6
|
+
let response;
|
|
7
|
+
try {
|
|
8
|
+
response = await fetch(endpoint, { headers: { Accept: 'application/json' } });
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
printError(error);
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
printError(new Error(`docs search failed: ${response.status} ${response.statusText} (${endpoint})`));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const raw = (await response.json());
|
|
21
|
+
if (!Array.isArray(raw)) {
|
|
22
|
+
printError(new Error('unexpected docs search response shape'));
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const pages = new Map();
|
|
27
|
+
const snippets = new Map();
|
|
28
|
+
for (const hit of raw) {
|
|
29
|
+
const pageUrl = hit.url.split('#')[0];
|
|
30
|
+
if (hit.type === 'page') {
|
|
31
|
+
pages.set(pageUrl, hit);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (!snippets.has(pageUrl))
|
|
35
|
+
snippets.set(pageUrl, []);
|
|
36
|
+
snippets.get(pageUrl).push(hit);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const orderedPages = Array.from(pages.values());
|
|
40
|
+
const sliced = options.limit ? orderedPages.slice(0, options.limit) : orderedPages;
|
|
41
|
+
if (options.json) {
|
|
42
|
+
printJson({
|
|
43
|
+
ok: true,
|
|
44
|
+
query: options.query,
|
|
45
|
+
baseUrl,
|
|
46
|
+
results: sliced.map((page) => ({
|
|
47
|
+
title: page.content,
|
|
48
|
+
url: `${baseUrl}${page.url}`,
|
|
49
|
+
breadcrumbs: page.breadcrumbs ?? [],
|
|
50
|
+
snippets: (snippets.get(page.url) ?? []).map((s) => ({
|
|
51
|
+
text: s.content,
|
|
52
|
+
url: `${baseUrl}${s.url}`,
|
|
53
|
+
})),
|
|
54
|
+
})),
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
printBannerFor(ctx);
|
|
59
|
+
log(ctx, section('Docs Search'));
|
|
60
|
+
log(ctx, field('Query', options.query));
|
|
61
|
+
log(ctx, field('Source', baseUrl));
|
|
62
|
+
log(ctx, field('Results', String(sliced.length)));
|
|
63
|
+
log(ctx, '');
|
|
64
|
+
if (sliced.length === 0) {
|
|
65
|
+
log(ctx, 'No matches.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const page of sliced) {
|
|
69
|
+
const breadcrumbs = page.breadcrumbs?.length ? page.breadcrumbs.join(' › ') : '';
|
|
70
|
+
log(ctx, `• ${page.content}`);
|
|
71
|
+
if (breadcrumbs)
|
|
72
|
+
log(ctx, ` ${breadcrumbs}`);
|
|
73
|
+
log(ctx, ` ${baseUrl}${page.url}`);
|
|
74
|
+
const pageSnippets = snippets.get(page.url) ?? [];
|
|
75
|
+
for (const s of pageSnippets.slice(0, 3)) {
|
|
76
|
+
const trimmed = s.content.length > 140 ? `${s.content.slice(0, 137).trimEnd()}…` : s.content;
|
|
77
|
+
log(ctx, ` – ${trimmed}`);
|
|
78
|
+
}
|
|
79
|
+
log(ctx, '');
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/env-pull.js
CHANGED
|
@@ -73,10 +73,15 @@ export async function envPull(ctx, options = {}) {
|
|
|
73
73
|
}
|
|
74
74
|
const root = process.cwd();
|
|
75
75
|
const profile = await detectProject(root);
|
|
76
|
-
const
|
|
76
|
+
const frameworks = new Set(profile.frameworks);
|
|
77
|
+
const pubVar = publishableKeyVar(frameworks);
|
|
78
|
+
// Vite-based frameworks read `.env`; Next.js reads `.env.local`.
|
|
79
|
+
const defaultFile = frameworks.has('React Router') || frameworks.has('TanStack Router')
|
|
80
|
+
? '.env'
|
|
81
|
+
: '.env.local';
|
|
77
82
|
const filePath = options.file
|
|
78
83
|
? path.resolve(root, options.file)
|
|
79
|
-
: path.join(root,
|
|
84
|
+
: path.join(root, defaultFile);
|
|
80
85
|
const existing = await readFile(filePath, 'utf8').catch((err) => {
|
|
81
86
|
if (err.code === 'ENOENT')
|
|
82
87
|
return '';
|
package/dist/init.js
CHANGED
|
@@ -43,7 +43,7 @@ This project is configured for AI-assisted Wacht development.
|
|
|
43
43
|
- Detected project shape: \`${frameworks}\`.
|
|
44
44
|
- Suggested Wacht skills for this project: \`${skills}\`.
|
|
45
45
|
- Active skill router: \`wacht\` (always start there). For CLI work specifically, use the \`wacht-bench-cli\` skill.
|
|
46
|
-
- Install or update skills with \`wacht skills
|
|
46
|
+
- Install or update skills with \`wacht skills install\`.
|
|
47
47
|
|
|
48
48
|
### Where to look (single source of truth)
|
|
49
49
|
|
|
@@ -96,6 +96,13 @@ async function upsertAgentsBlock(root, profile) {
|
|
|
96
96
|
return agentsPath;
|
|
97
97
|
}
|
|
98
98
|
async function writeEnvTemplate(root, profile) {
|
|
99
|
+
// If the project already ships a `.env.local.example` / `.env.example` (typical for
|
|
100
|
+
// scaffolded starters), don't drop a second redundant file alongside it.
|
|
101
|
+
for (const existing of ['.env.local.example', '.env.example']) {
|
|
102
|
+
if (await pathExists(path.join(root, existing))) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
99
106
|
const envPath = path.join(root, '.env.wacht.example');
|
|
100
107
|
const frameworks = new Set(profile.frameworks);
|
|
101
108
|
const isVite = frameworks.has('React Router') || frameworks.has('TanStack Router');
|
|
@@ -105,7 +112,8 @@ async function writeEnvTemplate(root, profile) {
|
|
|
105
112
|
? 'VITE_WACHT_PUBLISHABLE_KEY'
|
|
106
113
|
: 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY';
|
|
107
114
|
const lines = [
|
|
108
|
-
'# Wacht SDK environment.
|
|
115
|
+
'# Wacht SDK environment. Run `wacht env pull` to populate these automatically,',
|
|
116
|
+
'# or copy values from https://console.wacht.dev.',
|
|
109
117
|
'',
|
|
110
118
|
'# Client-safe publishable key. Encodes deployment + frontend host.',
|
|
111
119
|
`${publishableKeyVar}=`,
|
|
@@ -129,7 +137,9 @@ export async function initProject(args, ctx) {
|
|
|
129
137
|
log(ctx, field('Suggested skills', profile.suggestedSkills.join(', ')));
|
|
130
138
|
log(ctx, '');
|
|
131
139
|
if (!options.skipEnv) {
|
|
132
|
-
|
|
140
|
+
const envPath = await writeEnvTemplate(root, profile);
|
|
141
|
+
if (envPath)
|
|
142
|
+
written.push(envPath);
|
|
133
143
|
}
|
|
134
144
|
if (!options.skipAgents) {
|
|
135
145
|
written.push(await upsertAgentsBlock(root, profile));
|
|
@@ -142,10 +152,6 @@ export async function initProject(args, ctx) {
|
|
|
142
152
|
log(ctx, section('Install Skills'));
|
|
143
153
|
await installSkills({ yes: true });
|
|
144
154
|
}
|
|
145
|
-
else {
|
|
146
|
-
log(ctx, '');
|
|
147
|
-
log(ctx, field('Skills', `run ${command('wacht skills install')} when you want to install/update the pack`));
|
|
148
|
-
}
|
|
149
155
|
if (ctx.json) {
|
|
150
156
|
printJson({
|
|
151
157
|
ok: true,
|
|
@@ -162,6 +168,22 @@ export async function initProject(args, ctx) {
|
|
|
162
168
|
log(ctx, '');
|
|
163
169
|
log(ctx, success('Wacht Bench project bootstrap complete.'));
|
|
164
170
|
}
|
|
171
|
+
function printAgentBootstrapSteps(ctx, opts) {
|
|
172
|
+
log(ctx, '');
|
|
173
|
+
log(ctx, section('Next'));
|
|
174
|
+
const cd = opts.starterDir ? `cd ${opts.starterDir} && ` : '';
|
|
175
|
+
if (!opts.installedSkills) {
|
|
176
|
+
log(ctx, ` ${command('wacht skills install --agent claude-code --global --yes')}`);
|
|
177
|
+
}
|
|
178
|
+
log(ctx, ` ${command('wacht mcp install --client claude-code-user --yes')}`);
|
|
179
|
+
log(ctx, ` ${command('wacht login && wacht deployments select')}`);
|
|
180
|
+
log(ctx, ` ${command(`${cd}wacht env pull`)}`);
|
|
181
|
+
if (opts.starterDir) {
|
|
182
|
+
log(ctx, ` ${command(`${cd}pnpm install && pnpm dev`)}`);
|
|
183
|
+
}
|
|
184
|
+
log(ctx, '');
|
|
185
|
+
log(ctx, '(Restart your AI client after installing skills/MCP so they load.)');
|
|
186
|
+
}
|
|
165
187
|
// ─── Starter mode ───────────────────────────────────────────────────
|
|
166
188
|
const STARTERS = {
|
|
167
189
|
nextjs: {
|
|
@@ -232,7 +254,10 @@ export async function initStarter(options, ctx) {
|
|
|
232
254
|
}
|
|
233
255
|
log(ctx, '');
|
|
234
256
|
log(ctx, success(`Starter ready at ${path.relative(process.cwd(), absoluteTarget) || '.'}`));
|
|
235
|
-
|
|
257
|
+
printAgentBootstrapSteps(ctx, {
|
|
258
|
+
starterDir: path.relative(process.cwd(), absoluteTarget) || '.',
|
|
259
|
+
installedSkills: options.install,
|
|
260
|
+
});
|
|
236
261
|
}
|
|
237
262
|
export function listStarters() {
|
|
238
263
|
return Object.entries(STARTERS).map(([framework, info]) => ({ framework, description: info.description, repo: info.repo }));
|
package/dist/machine-api.js
CHANGED
|
@@ -239,9 +239,16 @@ export async function requestBody(options) {
|
|
|
239
239
|
headers.set(key, value);
|
|
240
240
|
}
|
|
241
241
|
if (options.body) {
|
|
242
|
+
// `--body @path/to/file.json` reads the JSON from disk so prompt files and
|
|
243
|
+
// larger configuration blobs don't have to be inlined in the shell.
|
|
244
|
+
let source = options.body;
|
|
245
|
+
if (source.startsWith('@')) {
|
|
246
|
+
const filePath = path.resolve(source.slice(1));
|
|
247
|
+
source = await readFile(filePath, 'utf8');
|
|
248
|
+
}
|
|
242
249
|
headers.set('content-type', 'application/json');
|
|
243
250
|
return {
|
|
244
|
-
body: JSON.stringify(JSON.parse(
|
|
251
|
+
body: JSON.stringify(JSON.parse(source)),
|
|
245
252
|
headers,
|
|
246
253
|
};
|
|
247
254
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Ajv } from 'ajv';
|
|
2
|
+
/**
|
|
3
|
+
* Validate a JSON request body against an operation's `requestBody` schema using
|
|
4
|
+
* the cached OpenAPI spec. Returns `null` on success; an array of human-readable
|
|
5
|
+
* error strings on failure.
|
|
6
|
+
*
|
|
7
|
+
* The validator handles OpenAPI 3.0 `nullable: true` (rewritten to JSON-Schema
|
|
8
|
+
* `type: [..., "null"]`), `$ref` resolution against `components.schemas`, and
|
|
9
|
+
* discriminated unions via `oneOf` + `discriminator`.
|
|
10
|
+
*/
|
|
11
|
+
export function validateBody(spec, schema, body) {
|
|
12
|
+
if (!isRecord(schema))
|
|
13
|
+
return null;
|
|
14
|
+
const componentSchemas = readComponentSchemas(spec);
|
|
15
|
+
const ajv = buildAjv(componentSchemas);
|
|
16
|
+
const compiled = compileSafely(ajv, schema);
|
|
17
|
+
if (!compiled)
|
|
18
|
+
return null;
|
|
19
|
+
if (compiled(body))
|
|
20
|
+
return null;
|
|
21
|
+
return summariseErrors(compiled.errors ?? [], body);
|
|
22
|
+
}
|
|
23
|
+
// Collapse `oneOf` failures into one line per union and drop the noisy umbrella
|
|
24
|
+
// + `type: null` errors our `nullable` rewrite introduces.
|
|
25
|
+
function summariseErrors(rawErrors, body) {
|
|
26
|
+
// Discover oneOf failure points by looking for the `oneOf` umbrella error.
|
|
27
|
+
const oneOfPaths = new Set();
|
|
28
|
+
for (const e of rawErrors) {
|
|
29
|
+
if (e.keyword === 'oneOf')
|
|
30
|
+
oneOfPaths.add(e.instancePath);
|
|
31
|
+
}
|
|
32
|
+
const collapsed = [];
|
|
33
|
+
const skip = new Set();
|
|
34
|
+
for (const path of oneOfPaths) {
|
|
35
|
+
const value = readJsonPointer(body, path);
|
|
36
|
+
// The variant tag is conventionally `type`; if absent, just describe the path.
|
|
37
|
+
const tag = isRecord(value) && typeof value.type === 'string'
|
|
38
|
+
? `, got type "${value.type}"`
|
|
39
|
+
: '';
|
|
40
|
+
collapsed.push(`${path || '(body root)'}: doesn't match any variant of the discriminated union${tag}. ` +
|
|
41
|
+
`Run \`wacht api describe <operation>\` to see each variant's required fields.`);
|
|
42
|
+
// Drop every error at the union path; the user fixes the variant first.
|
|
43
|
+
for (const e of rawErrors) {
|
|
44
|
+
if (e.instancePath === path || e.instancePath.startsWith(path + '/'))
|
|
45
|
+
skip.add(e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const out = [...collapsed];
|
|
49
|
+
for (const e of rawErrors) {
|
|
50
|
+
if (skip.has(e))
|
|
51
|
+
continue;
|
|
52
|
+
if (e.keyword === 'anyOf' || e.keyword === 'oneOf')
|
|
53
|
+
continue;
|
|
54
|
+
if (e.keyword === 'type' && e.params?.type === 'null')
|
|
55
|
+
continue;
|
|
56
|
+
out.push(formatError(e));
|
|
57
|
+
}
|
|
58
|
+
// Dedupe identical lines (e.g. two variants both miss the same field).
|
|
59
|
+
return [...new Set(out)];
|
|
60
|
+
}
|
|
61
|
+
function readJsonPointer(value, pointer) {
|
|
62
|
+
if (!pointer || pointer === '/')
|
|
63
|
+
return value;
|
|
64
|
+
const segments = pointer.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
65
|
+
let cur = value;
|
|
66
|
+
for (const seg of segments) {
|
|
67
|
+
if (Array.isArray(cur)) {
|
|
68
|
+
const idx = Number.parseInt(seg, 10);
|
|
69
|
+
if (Number.isNaN(idx))
|
|
70
|
+
return undefined;
|
|
71
|
+
cur = cur[idx];
|
|
72
|
+
}
|
|
73
|
+
else if (isRecord(cur)) {
|
|
74
|
+
cur = cur[seg];
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return cur;
|
|
81
|
+
}
|
|
82
|
+
function readComponentSchemas(spec) {
|
|
83
|
+
if (!isRecord(spec))
|
|
84
|
+
return {};
|
|
85
|
+
const components = isRecord(spec.components) ? spec.components : undefined;
|
|
86
|
+
const schemas = components && isRecord(components.schemas) ? components.schemas : undefined;
|
|
87
|
+
return schemas ?? {};
|
|
88
|
+
}
|
|
89
|
+
function buildAjv(componentSchemas) {
|
|
90
|
+
// OpenAPI 3.1 schemas are JSON Schema draft 2020-12, but the spec we generate
|
|
91
|
+
// also leans on draft-7 `nullable: true` shorthand. Rewrite to draft-7 unions
|
|
92
|
+
// and run Ajv permissively — we're validating user payloads, not enforcing
|
|
93
|
+
// the spec itself.
|
|
94
|
+
const ajv = new Ajv({
|
|
95
|
+
strict: false,
|
|
96
|
+
allErrors: true,
|
|
97
|
+
coerceTypes: false,
|
|
98
|
+
allowUnionTypes: true,
|
|
99
|
+
validateFormats: false,
|
|
100
|
+
});
|
|
101
|
+
// Make every component schema addressable by its $ref so nested references resolve.
|
|
102
|
+
for (const [name, raw] of Object.entries(componentSchemas)) {
|
|
103
|
+
const rewritten = rewriteNullable(raw);
|
|
104
|
+
try {
|
|
105
|
+
ajv.addSchema(rewritten, `#/components/schemas/${name}`);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Bad schemas shouldn't break the CLI — skip and keep going.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return ajv;
|
|
112
|
+
}
|
|
113
|
+
function compileSafely(ajv, schema) {
|
|
114
|
+
try {
|
|
115
|
+
return ajv.compile(rewriteNullable(schema));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Walk a schema and rewrite `{type: X, nullable: true}` → `{type: [X, "null"]}`. */
|
|
122
|
+
function rewriteNullable(value) {
|
|
123
|
+
if (Array.isArray(value))
|
|
124
|
+
return value.map(rewriteNullable);
|
|
125
|
+
if (!isRecord(value))
|
|
126
|
+
return value;
|
|
127
|
+
const out = {};
|
|
128
|
+
let nullable = false;
|
|
129
|
+
for (const [key, v] of Object.entries(value)) {
|
|
130
|
+
if (key === 'nullable' && v === true) {
|
|
131
|
+
nullable = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
out[key] = rewriteNullable(v);
|
|
135
|
+
}
|
|
136
|
+
if (nullable && typeof out.type === 'string') {
|
|
137
|
+
out.type = [out.type, 'null'];
|
|
138
|
+
}
|
|
139
|
+
else if (nullable && Array.isArray(out.type)) {
|
|
140
|
+
if (!out.type.includes('null'))
|
|
141
|
+
out.type = [...out.type, 'null'];
|
|
142
|
+
}
|
|
143
|
+
else if (nullable) {
|
|
144
|
+
// No `type`; allow null alongside whatever shape the schema describes.
|
|
145
|
+
out.anyOf = [{ ...out }, { type: 'null' }];
|
|
146
|
+
for (const k of Object.keys(out))
|
|
147
|
+
if (k !== 'anyOf')
|
|
148
|
+
delete out[k];
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function formatError(err) {
|
|
153
|
+
const path = err.instancePath || '(body root)';
|
|
154
|
+
const reason = err.message ?? 'invalid';
|
|
155
|
+
const extras = err.params ? formatParams(err.params) : '';
|
|
156
|
+
return `${path}: ${reason}${extras}`;
|
|
157
|
+
}
|
|
158
|
+
function formatParams(params) {
|
|
159
|
+
// Skim the most useful bits — missingProperty, allowedValues — without
|
|
160
|
+
// dumping every Ajv internal.
|
|
161
|
+
const bits = [];
|
|
162
|
+
if (typeof params.missingProperty === 'string')
|
|
163
|
+
bits.push(`missing "${params.missingProperty}"`);
|
|
164
|
+
if (Array.isArray(params.allowedValues))
|
|
165
|
+
bits.push(`allowed: ${params.allowedValues.map((v) => JSON.stringify(v)).join(', ')}`);
|
|
166
|
+
if (typeof params.additionalProperty === 'string')
|
|
167
|
+
bits.push(`unknown property "${params.additionalProperty}"`);
|
|
168
|
+
return bits.length ? ` (${bits.join('; ')})` : '';
|
|
169
|
+
}
|
|
170
|
+
function isRecord(value) {
|
|
171
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
package/dist/openapi.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { AUTH_DIR, OPENAPI_CACHE_FILE, PLATFORM_OPENAPI_URL } from './config.js';
|
|
3
4
|
import { readBenchContext } from './context-store.js';
|
|
4
5
|
import { entries, requestBody, machineRequest } from './machine-api.js';
|
|
6
|
+
import { validateBody } from './openapi-validate.js';
|
|
5
7
|
import { field, log, printBannerFor, printJson, section, warning } from './ui.js';
|
|
6
8
|
const OPENAPI_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
9
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
@@ -74,6 +76,7 @@ function operations(spec) {
|
|
|
74
76
|
tags: typed.tags ?? [],
|
|
75
77
|
parameters: typed.parameters ?? [],
|
|
76
78
|
requestBody: typed.requestBody,
|
|
79
|
+
responses: typed.responses,
|
|
77
80
|
});
|
|
78
81
|
}
|
|
79
82
|
}
|
|
@@ -95,14 +98,172 @@ function findOperation(spec, target, maybePath) {
|
|
|
95
98
|
function schemaType(schema) {
|
|
96
99
|
if (!schema)
|
|
97
100
|
return 'unknown';
|
|
98
|
-
if (typeof schema.type === 'string')
|
|
101
|
+
if (typeof schema.type === 'string') {
|
|
102
|
+
if (schema.type === 'array' && isRecord(schema.items)) {
|
|
103
|
+
return `array<${schemaType(schema.items)}>`;
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(schema.enum) && schema.enum.length) {
|
|
106
|
+
return `${schema.type}<${schema.enum.map((v) => JSON.stringify(v)).join(' | ')}>`;
|
|
107
|
+
}
|
|
108
|
+
if (typeof schema.const === 'string')
|
|
109
|
+
return `const "${schema.const}"`;
|
|
110
|
+
// Surface common formats like `binary` (file uploads) so multipart fields
|
|
111
|
+
// are recognisable at a glance vs plain strings.
|
|
112
|
+
if (typeof schema.format === 'string')
|
|
113
|
+
return `${schema.type} (${schema.format})`;
|
|
99
114
|
return schema.type;
|
|
115
|
+
}
|
|
100
116
|
if (typeof schema.$ref === 'string')
|
|
101
117
|
return schema.$ref.split('/').pop() ?? schema.$ref;
|
|
102
118
|
if (Array.isArray(schema.anyOf))
|
|
103
119
|
return schema.anyOf.map((item) => isRecord(item) ? schemaType(item) : 'unknown').join(' | ');
|
|
120
|
+
if (Array.isArray(schema.oneOf))
|
|
121
|
+
return schema.oneOf.map((item) => isRecord(item) ? schemaType(item) : 'unknown').join(' | ');
|
|
122
|
+
if (schema.const !== undefined)
|
|
123
|
+
return `const ${JSON.stringify(schema.const)}`;
|
|
104
124
|
return 'object';
|
|
105
125
|
}
|
|
126
|
+
const REF_PREFIX = '#/components/schemas/';
|
|
127
|
+
function resolveSchemaRef(spec, schema, seen = new Set()) {
|
|
128
|
+
if (!schema)
|
|
129
|
+
return undefined;
|
|
130
|
+
if (typeof schema.$ref !== 'string')
|
|
131
|
+
return schema;
|
|
132
|
+
const name = schema.$ref.startsWith(REF_PREFIX) ? schema.$ref.slice(REF_PREFIX.length) : '';
|
|
133
|
+
if (!name || seen.has(name))
|
|
134
|
+
return schema;
|
|
135
|
+
const schemas = isRecord(spec.components) && isRecord(spec.components.schemas) ? spec.components.schemas : undefined;
|
|
136
|
+
const target = schemas?.[name];
|
|
137
|
+
if (!isRecord(target))
|
|
138
|
+
return schema;
|
|
139
|
+
seen.add(name);
|
|
140
|
+
return resolveSchemaRef(spec, target, seen);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Merge an `allOf` chain into a single object schema. Used for our generator's
|
|
144
|
+
* internally-tagged enum encoding: `allOf: [{$ref: Payload}, {properties: {<tag>: const}}]`.
|
|
145
|
+
*/
|
|
146
|
+
function flattenAllOf(spec, schema) {
|
|
147
|
+
if (!Array.isArray(schema.allOf))
|
|
148
|
+
return schema;
|
|
149
|
+
const props = {};
|
|
150
|
+
const required = new Set();
|
|
151
|
+
for (const part of schema.allOf) {
|
|
152
|
+
if (!isRecord(part))
|
|
153
|
+
continue;
|
|
154
|
+
const resolved = resolveSchemaRef(spec, part) ?? part;
|
|
155
|
+
if (isRecord(resolved.properties)) {
|
|
156
|
+
for (const [key, value] of Object.entries(resolved.properties))
|
|
157
|
+
props[key] = value;
|
|
158
|
+
}
|
|
159
|
+
if (Array.isArray(resolved.required)) {
|
|
160
|
+
for (const key of resolved.required) {
|
|
161
|
+
if (typeof key === 'string')
|
|
162
|
+
required.add(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { type: 'object', properties: props, required: Array.from(required) };
|
|
167
|
+
}
|
|
168
|
+
/** How many levels of nested object schemas to inline before falling back to the type name. */
|
|
169
|
+
const MAX_BODY_DEPTH = 3;
|
|
170
|
+
/** Pull the schema-component name out of a `$ref`, if any. */
|
|
171
|
+
function refName(schema) {
|
|
172
|
+
if (!schema || typeof schema.$ref !== 'string')
|
|
173
|
+
return undefined;
|
|
174
|
+
return schema.$ref.startsWith(REF_PREFIX) ? schema.$ref.slice(REF_PREFIX.length) : undefined;
|
|
175
|
+
}
|
|
176
|
+
/** True when a resolved schema carries structure worth expanding inline. */
|
|
177
|
+
function isStructural(schema) {
|
|
178
|
+
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf))
|
|
179
|
+
return true;
|
|
180
|
+
if (isRecord(schema.properties) && Object.keys(schema.properties).length > 0)
|
|
181
|
+
return true;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
function printSchema(pctx, raw, indent, depth) {
|
|
185
|
+
// Stop recursing if we're already too deep — caller has shown the type name.
|
|
186
|
+
if (depth > MAX_BODY_DEPTH)
|
|
187
|
+
return;
|
|
188
|
+
const name = refName(raw);
|
|
189
|
+
if (name && pctx.seen.has(name)) {
|
|
190
|
+
log(pctx.ctx, `${indent}(circular reference to ${name})`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const resolved = resolveSchemaRef(pctx.spec, raw) ?? raw;
|
|
194
|
+
const flat = Array.isArray(resolved.allOf) ? flattenAllOf(pctx.spec, resolved) : resolved;
|
|
195
|
+
if (Array.isArray(flat.oneOf)) {
|
|
196
|
+
if (depth >= MAX_BODY_DEPTH) {
|
|
197
|
+
log(pctx.ctx, `${indent}(oneOf — drill further with \`wacht api describe ${name ?? '<schema>'}\`)`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (depth === 0)
|
|
201
|
+
log(pctx.ctx, `${indent}(oneOf — pick exactly one variant)`);
|
|
202
|
+
if (name)
|
|
203
|
+
pctx.seen.add(name);
|
|
204
|
+
for (const variant of flat.oneOf) {
|
|
205
|
+
if (!isRecord(variant))
|
|
206
|
+
continue;
|
|
207
|
+
const variantInner = resolveSchemaRef(pctx.spec, variant) ?? variant;
|
|
208
|
+
const variantFlat = Array.isArray(variantInner.allOf) ? flattenAllOf(pctx.spec, variantInner) : variantInner;
|
|
209
|
+
const variantProps = isRecord(variantFlat.properties) ? variantFlat.properties : {};
|
|
210
|
+
const tagEntry = Object.entries(variantProps).find(([, val]) => isRecord(val) && typeof val.const === 'string');
|
|
211
|
+
const label = tagEntry
|
|
212
|
+
? `variant "${tagEntry[1].const}"`
|
|
213
|
+
: 'variant';
|
|
214
|
+
log(pctx.ctx, `${indent} ${label}:`);
|
|
215
|
+
printObjectFields(pctx, variantFlat, `${indent} `, depth + 1);
|
|
216
|
+
}
|
|
217
|
+
if (name)
|
|
218
|
+
pctx.seen.delete(name);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
printObjectFields(pctx, flat, indent, depth);
|
|
222
|
+
}
|
|
223
|
+
function printObjectFields(pctx, schema, indent, depth) {
|
|
224
|
+
const expanded = Array.isArray(schema.allOf) ? flattenAllOf(pctx.spec, schema) : schema;
|
|
225
|
+
const props = isRecord(expanded.properties) ? expanded.properties : {};
|
|
226
|
+
const required = new Set(Array.isArray(expanded.required)
|
|
227
|
+
? expanded.required.filter((k) => typeof k === 'string')
|
|
228
|
+
: []);
|
|
229
|
+
if (Object.keys(props).length === 0) {
|
|
230
|
+
log(pctx.ctx, `${indent}(no fields)`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const [key, raw] of Object.entries(props)) {
|
|
234
|
+
if (!isRecord(raw))
|
|
235
|
+
continue;
|
|
236
|
+
const tag = required.has(key) ? 'required' : 'optional';
|
|
237
|
+
const inlineType = schemaType(raw);
|
|
238
|
+
log(pctx.ctx, `${indent}${key} (${tag}, ${inlineType})`);
|
|
239
|
+
// Drill into nested structures one indent deeper, guarded by depth + cycle.
|
|
240
|
+
if (depth >= MAX_BODY_DEPTH)
|
|
241
|
+
continue;
|
|
242
|
+
const target = refName(raw) ?? refName(isRecord(raw.items) ? raw.items : undefined);
|
|
243
|
+
const nested = resolveSchemaRef(pctx.spec, raw) ?? raw;
|
|
244
|
+
// Array element refs: descend into the items' resolved schema.
|
|
245
|
+
const arrayItem = nested.type === 'array' && isRecord(nested.items)
|
|
246
|
+
? (resolveSchemaRef(pctx.spec, nested.items) ?? nested.items)
|
|
247
|
+
: undefined;
|
|
248
|
+
const next = arrayItem ?? nested;
|
|
249
|
+
if (!isStructural(next))
|
|
250
|
+
continue;
|
|
251
|
+
if (target && pctx.seen.has(target)) {
|
|
252
|
+
log(pctx.ctx, `${indent} (circular reference to ${target})`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (target)
|
|
256
|
+
pctx.seen.add(target);
|
|
257
|
+
printSchema(pctx, next, `${indent} `, depth + 1);
|
|
258
|
+
if (target)
|
|
259
|
+
pctx.seen.delete(target);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function printBodySchema(ctx, spec, schema) {
|
|
263
|
+
if (!schema)
|
|
264
|
+
return;
|
|
265
|
+
printSchema({ ctx, spec, seen: new Set() }, schema, '', 0);
|
|
266
|
+
}
|
|
106
267
|
function requestContent(operation) {
|
|
107
268
|
return Object.keys(operation.requestBody?.content ?? {});
|
|
108
269
|
}
|
|
@@ -177,8 +338,40 @@ export async function openApiDescribe(ctx, target, maybePath, options) {
|
|
|
177
338
|
if (contentTypes.length) {
|
|
178
339
|
log(ctx, '');
|
|
179
340
|
log(ctx, section('Request Body'));
|
|
180
|
-
|
|
181
|
-
|
|
341
|
+
const required = operation.requestBody?.required ? ' (required)' : ' (optional)';
|
|
342
|
+
for (const contentType of contentTypes) {
|
|
343
|
+
log(ctx, `${contentType}${required}`);
|
|
344
|
+
const bodySchema = operation.requestBody?.content?.[contentType]?.schema;
|
|
345
|
+
if (!isRecord(bodySchema))
|
|
346
|
+
continue;
|
|
347
|
+
// JSON, multipart, and url-encoded bodies all surface as object schemas
|
|
348
|
+
// with `properties` once form annotations are in place; render uniformly.
|
|
349
|
+
const isStructured = contentType === 'application/json'
|
|
350
|
+
|| contentType === 'multipart/form-data'
|
|
351
|
+
|| contentType === 'application/x-www-form-urlencoded';
|
|
352
|
+
if (isStructured) {
|
|
353
|
+
printBodySchema(ctx, loaded.spec, bodySchema);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (operation.responses && Object.keys(operation.responses).length) {
|
|
358
|
+
log(ctx, '');
|
|
359
|
+
log(ctx, section('Responses'));
|
|
360
|
+
const statuses = Object.keys(operation.responses).sort();
|
|
361
|
+
for (const status of statuses) {
|
|
362
|
+
const response = operation.responses[status];
|
|
363
|
+
const description = response?.description ? ` — ${response.description}` : '';
|
|
364
|
+
log(ctx, `${status}${description}`);
|
|
365
|
+
// Only drill the 2xx success body; error responses share a common
|
|
366
|
+
// `{errors: [{message, code}]}` envelope and just clutter the output.
|
|
367
|
+
const isSuccess = status.startsWith('2');
|
|
368
|
+
if (!isSuccess)
|
|
369
|
+
continue;
|
|
370
|
+
const successJson = response?.content?.['application/json']?.schema;
|
|
371
|
+
if (isRecord(successJson)) {
|
|
372
|
+
printBodySchema(ctx, loaded.spec, successJson);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
182
375
|
}
|
|
183
376
|
}
|
|
184
377
|
function applyPathParams(path, params) {
|
|
@@ -218,6 +411,33 @@ export async function openApiCall(ctx, target, options) {
|
|
|
218
411
|
file: options.file,
|
|
219
412
|
header: options.header,
|
|
220
413
|
};
|
|
414
|
+
// Validate the JSON body against the operation's request schema first —
|
|
415
|
+
// local check, useful regardless of deployment state. Skips multipart
|
|
416
|
+
// (JSON Schema can't validate FormData) and anything not parseable as JSON.
|
|
417
|
+
if (apiOptions.body && options.validate !== false) {
|
|
418
|
+
const sourceBody = apiOptions.body.startsWith('@')
|
|
419
|
+
? await readFile(path.resolve(apiOptions.body.slice(1)), 'utf8')
|
|
420
|
+
: apiOptions.body;
|
|
421
|
+
let parsed;
|
|
422
|
+
try {
|
|
423
|
+
parsed = JSON.parse(sourceBody);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
parsed = undefined;
|
|
427
|
+
}
|
|
428
|
+
const bodySchema = operation.requestBody?.content?.['application/json']?.schema;
|
|
429
|
+
if (parsed !== undefined && bodySchema) {
|
|
430
|
+
const errors = validateBody(loaded.spec, bodySchema, parsed);
|
|
431
|
+
if (errors && errors.length) {
|
|
432
|
+
log(ctx, warning(`Request body did not match schema for ${operation.operationId}:`));
|
|
433
|
+
for (const e of errors)
|
|
434
|
+
log(ctx, ` - ${e}`);
|
|
435
|
+
log(ctx, '');
|
|
436
|
+
log(ctx, `Pass --no-validate to skip local validation, or \`wacht api describe ${operation.operationId}\` to see the schema.`);
|
|
437
|
+
throw new Error('Validation failed.');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
221
441
|
const pathWithParams = appendQueryParams(applyPathParams(operation.path, params), params, operation);
|
|
222
442
|
const isProjectScoped = pathWithParams.startsWith('/project') || pathWithParams === '/projects';
|
|
223
443
|
let machinePath;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wacht/bench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "CLI for Wacht Bench, the AI development workbench for Wacht.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"node": ">=20"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"ajv": "^8.20.0",
|
|
24
25
|
"commander": "^12.1.0"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|