@wirecat/cli-base 0.62.0 → 0.63.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/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.63.0 — 27.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`@wirecat/cli-base/codegen` reads Google Discovery documents: `adaptDiscovery`, `syncDiscovery` and
16
+ `readDiscovery`.** A Google CLI no longer keeps its own copy of the Discovery adapter and the `spec:sync` script.
17
+ `adaptDiscovery` takes `exclude` (method id → reason) for methods a CLI cannot serve, such as a `watch` that needs a
18
+ public webhook, and returns each service's `rootUrl` and `servicePath` for a CLI that calls Google with `fetch`.
19
+ Effects come from the HTTP verb only: correct a POST that reads, or a method that deletes, with `generate`'s
20
+ `overrides`. `syncDiscovery` writes the downloaded bytes unchanged and every provenance field, including
21
+ `sourceSha256` and `normalization`.
22
+
11
23
  ## 0.62.0 — 26.10.2026
12
24
 
13
25
  ### Added
@@ -0,0 +1,52 @@
1
+ import type { ApiModel } from "./model.js";
2
+ export interface DiscoveryMetadata {
3
+ /** OAuth scopes per method id, as the document lists them. */
4
+ scopes: Record<string, string[]>;
5
+ /** What the model leaves out, one line each, for a reviewer. */
6
+ omissions: string[];
7
+ }
8
+ /** Where a service's paths are relative to: a request goes to `rootUrl + servicePath + path`. */
9
+ export interface DiscoveryService {
10
+ rootUrl: string;
11
+ servicePath: string;
12
+ }
13
+ export interface DiscoveryOptions {
14
+ sourceUrl?: string;
15
+ sourceRevision?: string;
16
+ /**
17
+ * Method ids left out of the model entirely, each with the reason, as for an override. An id that matches
18
+ * no method stops generation. For a method a CLI cannot serve, such as a `watch` that needs a public webhook.
19
+ */
20
+ exclude?: Readonly<Record<string, string>>;
21
+ }
22
+ export interface DiscoveryResult {
23
+ model: ApiModel;
24
+ metadata: DiscoveryMetadata;
25
+ services: Record<string, DiscoveryService>;
26
+ }
27
+ /** `documents` maps each service name to its parsed Discovery document; schema ids get the service as a prefix. */
28
+ export declare const adaptDiscovery: (documents: Readonly<Record<string, unknown>>, options?: DiscoveryOptions) => DiscoveryResult;
29
+ /** One service's entry in `provenance.json`. */
30
+ export interface DiscoveryProvenance {
31
+ source: string;
32
+ revision: string;
33
+ version: string;
34
+ /** Of the committed file; `readDiscovery` refuses a snapshot that no longer matches it. */
35
+ sha256: string;
36
+ /** Of the bytes Google served. Equal to `sha256` unless the file was normalized after download. */
37
+ sourceSha256: string;
38
+ normalization: string;
39
+ }
40
+ export interface SyncDiscoveryOptions {
41
+ /** Where `<service>.json` and `provenance.json` are written. */
42
+ dir: string;
43
+ fetch?: typeof globalThis.fetch;
44
+ }
45
+ /**
46
+ * Downloads each Discovery document byte for byte and records where it came from. Run by hand, then review
47
+ * the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
48
+ * the order of the generated schemas.
49
+ */
50
+ export declare const syncDiscovery: (sources: Readonly<Record<string, string>>, options: SyncDiscoveryOptions) => Promise<Record<string, DiscoveryProvenance>>;
51
+ /** The snapshots `syncDiscovery` wrote, parsed, in `provenance.json` order. Throws if a file's hash changed. */
52
+ export declare const readDiscovery: (dir: string) => Record<string, unknown>;
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Google Discovery documents, already parsed → the format-neutral `ApiModel`, plus the offline
3
+ * snapshot those documents come from: `syncDiscovery` downloads them with their provenance, and
4
+ * `readDiscovery` loads them back only when every hash still matches.
5
+ *
6
+ * Strict like `adaptOpenApi`: an unknown schema keyword, format or parameter location stops generation.
7
+ * Effects come from the HTTP verb; a method the verb misjudges is corrected with `generate`'s `overrides`.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { CodegenError } from "./pipeline.js";
13
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
14
+ /** Discovery links point at developers.google.com without the host, which breaks them in generated Markdown. */
15
+ const description = (value) => String(value ?? "").replace(/\]\((\/workspace\/[^)]+)\)/g, "](https://developers.google.com$1)");
16
+ const DESCRIPTIVE = new Set([
17
+ "id",
18
+ "description",
19
+ "format",
20
+ "enumDescriptions",
21
+ "enumDeprecated",
22
+ "readOnly",
23
+ "deprecated",
24
+ "annotations",
25
+ "location",
26
+ "required",
27
+ "repeated",
28
+ "parameterName",
29
+ ]);
30
+ const CONSTRAINTS = new Set([
31
+ "type",
32
+ "$ref",
33
+ "properties",
34
+ "items",
35
+ "additionalProperties",
36
+ "enum",
37
+ "default",
38
+ "minimum",
39
+ "maximum",
40
+ "pattern",
41
+ "minLength",
42
+ "maxLength",
43
+ "minItems",
44
+ "maxItems",
45
+ "nullable",
46
+ ]);
47
+ const FORMATS = new Set([
48
+ "int32",
49
+ "int64",
50
+ "float",
51
+ "double",
52
+ "byte",
53
+ "date",
54
+ "date-time",
55
+ "google-datetime",
56
+ "google-fieldmask",
57
+ ]);
58
+ const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
59
+ /** What the HTTP client sets itself; as flags they would only invite a second, wrong value. */
60
+ const TRANSPORT_PARAMETERS = new Set(["key", "oauth_token", "access_token", "upload_protocol", "uploadType"]);
61
+ /** `documents` maps each service name to its parsed Discovery document; schema ids get the service as a prefix. */
62
+ export const adaptDiscovery = (documents, options = {}) => {
63
+ const exclude = options.exclude ?? {};
64
+ const excluded = new Set();
65
+ const schemas = [];
66
+ const operations = [];
67
+ const metadata = { scopes: {}, omissions: [] };
68
+ const services = {};
69
+ const fail = (problem) => {
70
+ throw new CodegenError([problem]);
71
+ };
72
+ const node = (value, service, where) => {
73
+ if (!object(value))
74
+ return fail(`${where}: expected a Discovery schema object`);
75
+ for (const key of Object.keys(value))
76
+ if (!DESCRIPTIVE.has(key) && !CONSTRAINTS.has(key))
77
+ fail(`${where}: unsupported schema keyword ${key}`);
78
+ if (value.format !== undefined && !FORMATS.has(String(value.format)))
79
+ fail(`${where}: unsupported format`);
80
+ const common = {
81
+ ...(typeof value.description === "string" ? { description: description(value.description) } : {}),
82
+ ...(value.nullable === true ? { nullable: true } : {}),
83
+ ...(value.deprecated === true ? { deprecated: true } : {}),
84
+ ...(value.default !== undefined ? { default: value.default } : {}),
85
+ };
86
+ if (typeof value.$ref === "string")
87
+ return { ...common, type: "ref", ref: `${service}_${value.$ref}` };
88
+ const bound = (key) => {
89
+ if (value[key] === undefined)
90
+ return {};
91
+ const n = Number(value[key]);
92
+ if (!Number.isFinite(n))
93
+ fail(`${where}: invalid numeric bound`);
94
+ return { [key]: n };
95
+ };
96
+ switch (value.type) {
97
+ case "any":
98
+ return { ...common, type: "unknown" };
99
+ case "string":
100
+ return {
101
+ ...common,
102
+ type: "string",
103
+ ...(Array.isArray(value.enum) ? { enum: value.enum.map(String) } : {}),
104
+ ...(typeof value.pattern === "string"
105
+ ? { pattern: value.pattern }
106
+ : value.format === "int64"
107
+ ? { pattern: "^-?\\d+$" }
108
+ : {}),
109
+ ...bound("minLength"),
110
+ ...bound("maxLength"),
111
+ };
112
+ case "integer":
113
+ return {
114
+ ...common,
115
+ type: "integer",
116
+ ...(value.format === "int64" ? { format: "int64" } : { format: "int32" }),
117
+ ...bound("minimum"),
118
+ ...bound("maximum"),
119
+ ...(Array.isArray(value.enum) ? { enum: value.enum.map(Number) } : {}),
120
+ };
121
+ case "number":
122
+ return { ...common, type: "number", ...bound("minimum"), ...bound("maximum") };
123
+ case "boolean":
124
+ return { ...common, type: "boolean" };
125
+ case "array":
126
+ return {
127
+ ...common,
128
+ type: "array",
129
+ items: node(value.items, service, `${where}/items`),
130
+ ...bound("minItems"),
131
+ ...bound("maxItems"),
132
+ };
133
+ case "object": {
134
+ const properties = object(value.properties) ? value.properties : {};
135
+ return {
136
+ ...common,
137
+ type: "object",
138
+ properties: Object.fromEntries(Object.entries(properties).map(([key, item]) => [key, node(item, service, `${where}/properties/${key}`)])),
139
+ required: Array.isArray(value.required) ? value.required.map(String) : [],
140
+ ...(object(value.additionalProperties)
141
+ ? { additionalProperties: node(value.additionalProperties, service, `${where}/additionalProperties`) }
142
+ : {}),
143
+ };
144
+ }
145
+ default:
146
+ return fail(`${where}: unsupported schema type`);
147
+ }
148
+ };
149
+ for (const [service, source] of Object.entries(documents)) {
150
+ if (!object(source) || source.name !== service || !object(source.schemas) || !object(source.resources))
151
+ fail(`invalid Discovery document: ${service}`);
152
+ const document = source;
153
+ if (typeof document.rootUrl !== "string" || typeof document.servicePath !== "string")
154
+ fail(`invalid Discovery document: ${service} has no rootUrl or servicePath`);
155
+ services[service] = { rootUrl: String(document.rootUrl), servicePath: String(document.servicePath) };
156
+ for (const [id, value] of Object.entries(document.schemas))
157
+ schemas.push({ id: `${service}_${id}`, schema: node(value, service, `/schemas/${id}`) });
158
+ const walk = (resources, path) => {
159
+ for (const [resource, value] of Object.entries(resources)) {
160
+ if (!object(value))
161
+ fail(`invalid resource: ${resource}`);
162
+ const resourceValue = value;
163
+ const location = `${path}/${resource}`;
164
+ if (object(resourceValue.methods))
165
+ for (const [name, raw] of Object.entries(resourceValue.methods)) {
166
+ if (!object(raw) || typeof raw.id !== "string" || typeof raw.path !== "string")
167
+ return fail(`invalid method at ${location}`);
168
+ if (exclude[raw.id] !== undefined) {
169
+ excluded.add(raw.id);
170
+ metadata.omissions.push(`${raw.id}: excluded — ${exclude[raw.id]}`);
171
+ continue;
172
+ }
173
+ const method = String(raw.httpMethod);
174
+ if (!METHODS.has(method))
175
+ fail(`unsupported HTTP method: ${raw.id}`);
176
+ const effect = method === "GET" || method === "HEAD" ? "read" : method === "DELETE" ? "destructive" : "write";
177
+ metadata.scopes[raw.id] = Array.isArray(raw.scopes) ? raw.scopes.map(String) : [];
178
+ const common = object(document.parameters) ? document.parameters : {};
179
+ const parameters = { ...common, ...(object(raw.parameters) ? raw.parameters : {}) };
180
+ const safe = Object.entries(parameters).filter(([key]) => !TRANSPORT_PARAMETERS.has(key));
181
+ const operation = {
182
+ id: raw.id,
183
+ binding: { kind: "http", method, path: raw.path },
184
+ effect,
185
+ summary: description(raw.description ?? raw.id).split("\n")[0],
186
+ description: description(raw.description),
187
+ tags: [service],
188
+ parameters: safe.map(([key, value]) => {
189
+ if (!object(value) || !["path", "query"].includes(String(value.location)))
190
+ return fail(`unsupported parameter: ${raw.id}/${key}`);
191
+ const schema = node(value, service, `${location}/methods/${name}/parameters/${key}`);
192
+ return {
193
+ name: key,
194
+ in: value.location,
195
+ required: value.required === true,
196
+ ...(typeof value.description === "string" ? { description: description(value.description) } : {}),
197
+ schema: value.repeated === true ? { type: "array", items: schema } : schema,
198
+ };
199
+ }),
200
+ source: { location: `${service}${location}/methods/${name}` },
201
+ ...(raw.deprecated === true ? { deprecated: true } : {}),
202
+ };
203
+ if (object(raw.request))
204
+ operation.requestBody = {
205
+ confidence: "contract",
206
+ required: raw.request.required === true,
207
+ schema: node(raw.request, service, `${location}/methods/${name}/request`),
208
+ };
209
+ if (object(raw.response))
210
+ operation.response = {
211
+ confidence: "contract",
212
+ schema: node(raw.response, service, `${location}/methods/${name}/response`),
213
+ required: false,
214
+ };
215
+ if (raw.supportsMediaUpload || raw.supportsMediaDownload)
216
+ metadata.omissions.push(`${raw.id}: JSON endpoint only; media transport is not generated`);
217
+ operations.push(operation);
218
+ }
219
+ if (object(resourceValue.resources))
220
+ walk(resourceValue.resources, `${location}/resources`);
221
+ }
222
+ };
223
+ walk(document.resources, "/resources");
224
+ }
225
+ const stale = Object.keys(exclude).filter((id) => !excluded.has(id));
226
+ if (stale.length > 0)
227
+ throw new CodegenError(stale.map((id) => `exclusion "${id}" matches no method — stale, or the id changed`));
228
+ return {
229
+ model: {
230
+ source: {
231
+ kind: "other",
232
+ sourceUrl: options.sourceUrl ?? "https://developers.google.com/discovery",
233
+ ...(options.sourceRevision !== undefined ? { sourceRevision: options.sourceRevision } : {}),
234
+ },
235
+ operations: operations.sort((a, b) => a.id.localeCompare(b.id)),
236
+ schemas,
237
+ },
238
+ metadata,
239
+ services,
240
+ };
241
+ };
242
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
243
+ /**
244
+ * Downloads each Discovery document byte for byte and records where it came from. Run by hand, then review
245
+ * the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
246
+ * the order of the generated schemas.
247
+ */
248
+ export const syncDiscovery = async (sources, options) => {
249
+ const fetch = options.fetch ?? globalThis.fetch;
250
+ const provenance = {};
251
+ const downloaded = {};
252
+ for (const [service, source] of Object.entries(sources)) {
253
+ const response = await fetch(source, { signal: AbortSignal.timeout(30_000), redirect: "error" });
254
+ if (!response.ok)
255
+ throw new Error(`Google Discovery fetch failed for ${service}: HTTP ${response.status}`);
256
+ const raw = await response.text();
257
+ const document = JSON.parse(raw);
258
+ if (!object(document) || document.name !== service)
259
+ throw new Error(`${source} is not the Discovery document of ${service}`);
260
+ const hash = sha256(raw);
261
+ downloaded[service] = raw;
262
+ provenance[service] = {
263
+ source,
264
+ revision: String(document.revision),
265
+ version: String(document.version),
266
+ sha256: hash,
267
+ sourceSha256: hash,
268
+ normalization: "none",
269
+ };
270
+ }
271
+ mkdirSync(options.dir, { recursive: true });
272
+ for (const [service, raw] of Object.entries(downloaded))
273
+ writeFileSync(join(options.dir, `${service}.json`), raw);
274
+ writeFileSync(join(options.dir, "provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`);
275
+ return provenance;
276
+ };
277
+ /** The snapshots `syncDiscovery` wrote, parsed, in `provenance.json` order. Throws if a file's hash changed. */
278
+ export const readDiscovery = (dir) => {
279
+ const provenance = JSON.parse(readFileSync(join(dir, "provenance.json"), "utf8"));
280
+ const documents = {};
281
+ for (const [service, entry] of Object.entries(provenance)) {
282
+ const raw = readFileSync(join(dir, `${service}.json`));
283
+ if (sha256(raw) !== entry.sha256)
284
+ throw new CodegenError([`Discovery hash mismatch: ${service}`]);
285
+ documents[service] = JSON.parse(raw.toString("utf8"));
286
+ }
287
+ return documents;
288
+ };
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Turns an official API description into committed code: types, Valibot schemas, an operation
3
3
  * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
4
- * adds no dependency. `adaptOpenApi` reads an OpenAPI document the consumer has parsed; an adapter from
5
- * Postman or anything else lives in the consumer.
4
+ * adds no dependency. `adaptOpenApi` reads an OpenAPI document and `adaptDiscovery` Google Discovery
5
+ * documents the consumer has parsed; an adapter from Postman or anything else lives in the consumer.
6
6
  */
7
+ export { adaptDiscovery, type DiscoveryMetadata, type DiscoveryOptions, type DiscoveryProvenance, type DiscoveryResult, type DiscoveryService, readDiscovery, type SyncDiscoveryOptions, syncDiscovery, } from "./discovery.js";
7
8
  export { type CoverageOptions, coverageGenerator, definitionsGenerator, type ManifestOperation, manifestGenerator, } from "./manifest.js";
8
9
  export type { ApiBody, ApiModel, ApiOperation, ApiParameter, ApiSchema, Binding, CodeGenerator, Confidence, Discriminator, Effect, GeneratedArtifact, HttpBinding, HttpMethod, Override, RpcBinding, SchemaNode, SourceInfo, SourceReference, } from "./model.js";
9
10
  export { identifier, kebab } from "./names.js";
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Turns an official API description into committed code: types, Valibot schemas, an operation
3
3
  * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
4
- * adds no dependency. `adaptOpenApi` reads an OpenAPI document the consumer has parsed; an adapter from
5
- * Postman or anything else lives in the consumer.
4
+ * adds no dependency. `adaptOpenApi` reads an OpenAPI document and `adaptDiscovery` Google Discovery
5
+ * documents the consumer has parsed; an adapter from Postman or anything else lives in the consumer.
6
6
  */
7
+ export { adaptDiscovery, readDiscovery, syncDiscovery, } from "./discovery.js";
7
8
  export { coverageGenerator, definitionsGenerator, manifestGenerator, } from "./manifest.js";
8
9
  export { identifier, kebab } from "./names.js";
9
10
  export { adaptOpenApi, OpenApiError } from "./openapi.js";
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The model every generator reads, whatever the source was: an OpenAPI file, a Postman collection,
3
3
  * a scraped reference page. An adapter produces this from a document already parsed into plain
4
- * values: `adaptOpenApi` for OpenAPI here, the rest in the consumer. Nothing here reads YAML or a file.
4
+ * values: `adaptOpenApi` for OpenAPI and `adaptDiscovery` for Google Discovery here, the rest in the
5
+ * consumer. Nothing here reads YAML or a file.
5
6
  */
6
7
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
7
8
  /** How much the source promises. A Postman example is not a contract and must never become one. */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The model every generator reads, whatever the source was: an OpenAPI file, a Postman collection,
3
3
  * a scraped reference page. An adapter produces this from a document already parsed into plain
4
- * values: `adaptOpenApi` for OpenAPI here, the rest in the consumer. Nothing here reads YAML or a file.
4
+ * values: `adaptOpenApi` for OpenAPI and `adaptDiscovery` for Google Discovery here, the rest in the
5
+ * consumer. Nothing here reads YAML or a file.
5
6
  */
6
7
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",