@wirecat/cli-base 0.62.0 → 0.64.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 +125 -0
- package/dist/codegen/discovery.d.ts +52 -0
- package/dist/codegen/discovery.js +297 -0
- package/dist/codegen/index.d.ts +6 -3
- package/dist/codegen/index.js +6 -3
- package/dist/codegen/json-schema.d.ts +8 -0
- package/dist/codegen/json-schema.js +78 -0
- package/dist/codegen/model.d.ts +2 -1
- package/dist/codegen/model.js +2 -1
- package/dist/commands/index.d.ts +1 -1
- package/dist/commands/index.js +1 -1
- package/dist/credentials.d.ts +1 -1
- package/dist/dev/bundle.d.ts +25 -0
- package/dist/dev/bundle.js +271 -0
- package/dist/dev/main.js +48 -1
- package/dist/skill/index.d.ts +1 -1
- package/dist/skill/index.js +2 -2
- package/dist/standards/audit.js +1 -1
- package/dist/standards/flags-calendar.d.ts +2 -0
- package/dist/standards/flags-calendar.js +59 -0
- package/dist/standards/flags-how.js +7 -1
- package/dist/standards/vocabulary.js +10 -1
- package/docs/standards/README.md +3 -3
- package/docs/standards/accounts.md +2 -2
- package/docs/standards/ci.md +33 -12
- package/docs/standards/commands.md +3 -2
- package/docs/standards/kit-surface.md +3 -3
- package/docs/standards/layout.md +32 -0
- package/docs/standards/new-cli.md +28 -14
- package/docs/standards/permissions.md +33 -3
- package/docs/standards/releases.md +13 -3
- package/docs/standards/security.md +4 -3
- package/docs/standards/store.md +1 -1
- package/docs/standards/testing.md +9 -3
- package/docs/standards/toolchain.md +17 -16
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,131 @@ 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.64.0 — 27.10.2026
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`cli-dev bundle --bin dist/bin/<cli>.js` builds the published bin as an esbuild bundle, the way tg-cli 0.100.0
|
|
16
|
+
ships.** It moves tg's `scripts/bundle.ts` into cli-base so every CLI builds the same way: code splitting that keeps
|
|
17
|
+
each lazy `import()` lazy; `import.meta.url` and run-time package lookups that stay at the original module's place
|
|
18
|
+
in the CLI, cli-core and cli-base; CommonJS named exports for a dynamic `import("yaml")`; kept names; native and
|
|
19
|
+
by-name packages left outside. The bin becomes a loader that answers `--version`, turns the compile cache on and,
|
|
20
|
+
under `serve`, `watch` and `mcp`, preloads every chunk so a running server survives an in-place upgrade. The build
|
|
21
|
+
fails when a bundled module would look in the wrong place. Add `esbuild` (an optional peer, `^0.28.0`) as a
|
|
22
|
+
devDependency and name the CLI's own outside packages with `--external`. The rule is
|
|
23
|
+
[WC-LAY-10](docs/standards/layout.md#wc-lay-10--ship-the-bin-as-a-bundle-that-cli-dev-bundle-builds).
|
|
24
|
+
- **WC-PERM-22 on the [Permissions](docs/standards/permissions.md) page: a CLI that is not a messenger declares its
|
|
25
|
+
own permission keys and defaults with cli-core's `commandPermissions`** (cli-core 0.450.0). It says how such a CLI
|
|
26
|
+
keys its commands, picks built-in defaults for what its writes do, and runs writes through the read-only lock and the
|
|
27
|
+
write guard. The example is a calendar: event writes ask, deleting a whole series and mailing attendees are denied
|
|
28
|
+
until the owner allows them. The page covered messenger CLIs only, and WC-PERM-6's "ask only before what cannot be
|
|
29
|
+
undone" read as if it applied to a calendar too; it now says it is the messengers' list. drive, zoom and memo do
|
|
30
|
+
not declare `commandPermissions` yet; the rule names them as known gaps.
|
|
31
|
+
- **`cli-dev audit` compares gcal (`@wirecat/gcal-cli`, the Google Calendar CLI) in the `all` parity group**, next to
|
|
32
|
+
tg, max, memo, drive, zm and wa, so its shared root commands are held to the same options and summaries. A run
|
|
33
|
+
without gcal's command tree skips it, as before; the weekly audit adds it once gcal is published on npm.
|
|
34
|
+
- **The command vocabulary has the words a calendar CLI needs.** Verbs `unsync` (stop syncing a thing; what is
|
|
35
|
+
stored stays), `review` (list what waits for the owner's decision) and `resolve` (settle one such thing with the
|
|
36
|
+
owner's choice); collections `calendars`, `events` and `people`. `events` was only a view word, so a command group
|
|
37
|
+
named `events` was reported by `unknown-resource`.
|
|
38
|
+
- **The vocabulary has the flags and the verb of calendar writes** (cli-core's `events create | update | delete |
|
|
39
|
+
respond`). Flags `--end-time`, `--start-date`, `--end-date`, `--location`, `--attendee` (repeat it for more),
|
|
40
|
+
`--id` (the id a create is made with) as `value`; `--calendar` and `--series` as `target`; `--no-notify` as `mode`.
|
|
41
|
+
`--notify` now means telling the people a change concerns — a chat's members or an event's attendees. Verb
|
|
42
|
+
`respond` (answer an invitation). Without them `option-role` and `unknown-verb` reported every calendar CLI's
|
|
43
|
+
write commands.
|
|
44
|
+
- **The vocabulary has the words of free/busy and repeating events** (cli-core's `calendars busy` and
|
|
45
|
+
`events create | update --repeat`). View `busy` (when calendars or people are taken, without what takes them);
|
|
46
|
+
flags `--repeat <rule>` (an RFC 5545 rule) as `value` and `--hours <from-to>` (the working hours to look in) as
|
|
47
|
+
`target`. gcal-cli overrides the four lint reports they cause until it takes this release.
|
|
48
|
+
|
|
49
|
+
- **`requestSchema(operation, definitions)` in `@wirecat/cli-base/codegen` builds the JSON Schema of a generated
|
|
50
|
+
operation's request body**, every referenced definition under `$defs`, or returns `null` when the operation takes
|
|
51
|
+
no body. drive-cli and gcal-cli each kept an identical copy for `api show`. Pass a manifest operation and the
|
|
52
|
+
`definitions` that `definitionsGenerator` wrote.
|
|
53
|
+
|
|
54
|
+
### Changed — may break callers
|
|
55
|
+
|
|
56
|
+
- **A lint baseline entry or override for a word that is now in the vocabulary goes stale, and `cli-dev lint` fails
|
|
57
|
+
on it.** This release adds `unsync`, `review`, `resolve`, `calendars`, `events` and `people`. zoom-cli lists
|
|
58
|
+
`events` in `lint/baseline/unknown-resource.json`; gcal-cli lists `calendars`, `events`, `people`,
|
|
59
|
+
`calendars unsync`, `links review` and `links resolve` in its baseline; drive-cli overrides `unknown-verb` for
|
|
60
|
+
`comments resolve`, and cli-memo for `search people`, in `lint/overrides.json`. Remove those entries in the same
|
|
61
|
+
change that takes this version.
|
|
62
|
+
- **The calendar write words make gcal-cli's write baseline stale too.** Its `lint/baseline/option-role.json` (20
|
|
63
|
+
entries under `events create | update | delete | respond`) and `events respond` in `unknown-verb.json` go stale
|
|
64
|
+
with this release; remove them when gcal takes it. No other CLI's baseline or overrides on `main` name these words.
|
|
65
|
+
- **The free/busy and repeat words make four of gcal-cli's overrides stale.** `lint/overrides.json` names
|
|
66
|
+
`calendars busy` (`unknown-verb`), `calendars busy --hours` and `events create | update --repeat` (`option-role`);
|
|
67
|
+
remove them when gcal takes this release.
|
|
68
|
+
|
|
69
|
+
- **`describeAlone` is no longer exported from `@wirecat/cli-base/commands`.** It was made public by accident in
|
|
70
|
+
0.62.0; it is a helper of the shared-command check and only works with a map kept for one check. No known caller
|
|
71
|
+
imports it. Describe a whole tree with `describeProgram` instead.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- **Handbook corrections from gcal-cli's first-release audit.**
|
|
76
|
+
- The Toolchain, CI, Security, Testing and Store pages listed "the ten repositories" and "the six CLIs" without
|
|
77
|
+
gcal. They now say "the WireCat repositories": every WireCat CLI, a new one from its first commit, and the shared
|
|
78
|
+
repositories. `gcal-cli` is a known repository name for "Enforced by".
|
|
79
|
+
- WC-SECR-4 said the keyring service is the command's name. It is the CLI's app name (`max-cli`, `gcal-cli`,
|
|
80
|
+
`cli-memo`), as cli-core passes it.
|
|
81
|
+
- WC-REL-16 says what a CLI's package `files` holds: `dist`, `skills`, `README.md`, `LICENSE` and what the CLI
|
|
82
|
+
reads or runs after install (`scenarios`, `install`, notices); `CHANGELOG.md` may ship. Never `src/`, tests,
|
|
83
|
+
scripts, workflows, `bin/release`, `config/`, `lint/`, `contracts/` or `template.json`.
|
|
84
|
+
- WC-REL-11 names its one exception: a package's first version, published by the owner with `bin/release --local`,
|
|
85
|
+
since npm sets a trusted publisher only for a package that exists. Step 2 of
|
|
86
|
+
[New CLI](docs/standards/new-cli.md) says the same.
|
|
87
|
+
- [New CLI](docs/standards/new-cli.md) says which copied files a CLI rewrites (seeds) and which it leaves to
|
|
88
|
+
cli-template (template-owned), and that a template-owned page wrong for the CLI is fixed in cli-template.
|
|
89
|
+
- "Done when" told authors to add a new CLI's shared audit findings to `audit/baseline`, which has not accepted
|
|
90
|
+
them since cli-base 0.51.0. It now says to fix each one, or declare an exact difference in the parity group.
|
|
91
|
+
- WC-CI-8 says the fresh-agent audit is given the pinned cli-base workflows.
|
|
92
|
+
- WC-TEST-12 has one exception: commands generated from an API description share one reason that names an
|
|
93
|
+
options-against-the-snapshot test and a test that drives a generated command through `run()`.
|
|
94
|
+
|
|
95
|
+
- **`syncDiscovery` writes the same snapshot for the same revision of a Google API.** Google serves one revision with
|
|
96
|
+
its object keys in a different order on each download, so two syncs gave different files, hashes and generated
|
|
97
|
+
code. The snapshot is now the parsed document with its keys sorted and 2-space indentation; values and array order
|
|
98
|
+
are unchanged. `sha256` is stable; `sourceSha256` is still the hash of the bytes Google served, so it can change
|
|
99
|
+
between syncs. The first sync after upgrading rewrites each snapshot once, and `pnpm generate` then reorders the
|
|
100
|
+
generated types, schemas and manifest once.
|
|
101
|
+
- **`skill show` now names every agent `skill install` serves: Claude Code, Codex, Gemini CLI, OpenClaw, and Hermes
|
|
102
|
+
on request.** Its help named only the first three, so OpenClaw and Hermes users could not tell the skill was meant
|
|
103
|
+
for them.
|
|
104
|
+
- **The [CI](docs/standards/ci.md) page lists the monthly cron minutes WireCat repositories already use**, under
|
|
105
|
+
WC-CI-9, and [New CLI](docs/standards/new-cli.md) step 3 points to it. Finding a free minute meant reading
|
|
106
|
+
`monthly.yml` in every repository. A new CLI adds its minute to the list in the same pull request that joins the
|
|
107
|
+
parity groups.
|
|
108
|
+
- **The [Kit surface](docs/standards/kit-surface.md) page lists every cli-core and cli-base export cli-template
|
|
109
|
+
0.7.0 imports**, among them the ones for MCP over HTTP: `mcpServerCommand`, `serveOverHttp`, `httpLockFile` and
|
|
110
|
+
the rest. The page left them out, so a change to them would not have been seen as one that reaches every new CLI.
|
|
111
|
+
- **[New CLI](docs/standards/new-cli.md) lets the fresh audit agent read the cli-base workflow files a CLI's
|
|
112
|
+
workflows pin.** WC-CI-8 tells an audit to read them, but the audit was limited to the handbook and the
|
|
113
|
+
repository. A release audit prompt kept in a CLI repository needs the same change.
|
|
114
|
+
- **WC-TEST-5 on the [Testing](docs/standards/testing.md) page says that a test may wait for a local process or
|
|
115
|
+
server it started**, polling with a timeout shorter than the test's own; a fixed sleep is still not allowed. It
|
|
116
|
+
was unclear whether such a wait counts as waiting for real time.
|
|
117
|
+
- **WC-CMD-6 on the [Commands](docs/standards/commands.md) page names `mcp --revoke` as an exception.** The rule says
|
|
118
|
+
an option never turns a command into a different action, and `--revoke` does: it forgets every browser login
|
|
119
|
+
instead of starting a server. The owner kept it an option on `mcp` because it belongs with the HTTP server's login
|
|
120
|
+
state. No other option gets this exception.
|
|
121
|
+
- **The handbook's examples start `gcal-cli` with the command `gcal`, not `cal`**, the system calendar program on macOS
|
|
122
|
+
and many Linux systems. A CLI named after it would shadow it.
|
|
123
|
+
|
|
124
|
+
## 0.63.0 — 27.10.2026
|
|
125
|
+
|
|
126
|
+
### Added
|
|
127
|
+
|
|
128
|
+
- **`@wirecat/cli-base/codegen` reads Google Discovery documents: `adaptDiscovery`, `syncDiscovery` and
|
|
129
|
+
`readDiscovery`.** A Google CLI no longer keeps its own copy of the Discovery adapter and the `spec:sync` script.
|
|
130
|
+
`adaptDiscovery` takes `exclude` (method id → reason) for methods a CLI cannot serve, such as a `watch` that needs a
|
|
131
|
+
public webhook, and returns each service's `rootUrl` and `servicePath` for a CLI that calls Google with `fetch`.
|
|
132
|
+
Effects come from the HTTP verb only: correct a POST that reads, or a method that deletes, with `generate`'s
|
|
133
|
+
`overrides`. `syncDiscovery` writes the downloaded bytes unchanged and every provenance field, including
|
|
134
|
+
`sourceSha256` and `normalization`.
|
|
135
|
+
|
|
11
136
|
## 0.62.0 — 26.10.2026
|
|
12
137
|
|
|
13
138
|
### 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. Google varies its key order, so this one can change while `sha256` does not. */
|
|
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, sorts its object keys so the same revision always gives the same file, and
|
|
47
|
+
* records where it came from. Run by hand, then review 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,297 @@
|
|
|
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
|
+
/** Google serves the same revision with its keys in a different order each time; arrays keep theirs. */
|
|
244
|
+
const sortKeys = (value) => Array.isArray(value)
|
|
245
|
+
? value.map(sortKeys)
|
|
246
|
+
: object(value)
|
|
247
|
+
? Object.fromEntries(Object.keys(value)
|
|
248
|
+
.sort()
|
|
249
|
+
.map((key) => [key, sortKeys(value[key])]))
|
|
250
|
+
: value;
|
|
251
|
+
const NORMALIZATION = "object keys sorted, 2-space JSON; values and array order unchanged";
|
|
252
|
+
/**
|
|
253
|
+
* Downloads each Discovery document, sorts its object keys so the same revision always gives the same file, and
|
|
254
|
+
* records where it came from. Run by hand, then review the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
|
|
255
|
+
* the order of the generated schemas.
|
|
256
|
+
*/
|
|
257
|
+
export const syncDiscovery = async (sources, options) => {
|
|
258
|
+
const fetch = options.fetch ?? globalThis.fetch;
|
|
259
|
+
const provenance = {};
|
|
260
|
+
const downloaded = {};
|
|
261
|
+
for (const [service, source] of Object.entries(sources)) {
|
|
262
|
+
const response = await fetch(source, { signal: AbortSignal.timeout(30_000), redirect: "error" });
|
|
263
|
+
if (!response.ok)
|
|
264
|
+
throw new Error(`Google Discovery fetch failed for ${service}: HTTP ${response.status}`);
|
|
265
|
+
const raw = await response.text();
|
|
266
|
+
const document = JSON.parse(raw);
|
|
267
|
+
if (!object(document) || document.name !== service)
|
|
268
|
+
throw new Error(`${source} is not the Discovery document of ${service}`);
|
|
269
|
+
const written = `${JSON.stringify(sortKeys(document), null, 2)}\n`;
|
|
270
|
+
downloaded[service] = written;
|
|
271
|
+
provenance[service] = {
|
|
272
|
+
source,
|
|
273
|
+
revision: String(document.revision),
|
|
274
|
+
version: String(document.version),
|
|
275
|
+
sha256: sha256(written),
|
|
276
|
+
sourceSha256: sha256(raw),
|
|
277
|
+
normalization: NORMALIZATION,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
mkdirSync(options.dir, { recursive: true });
|
|
281
|
+
for (const [service, text] of Object.entries(downloaded))
|
|
282
|
+
writeFileSync(join(options.dir, `${service}.json`), text);
|
|
283
|
+
writeFileSync(join(options.dir, "provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`);
|
|
284
|
+
return provenance;
|
|
285
|
+
};
|
|
286
|
+
/** The snapshots `syncDiscovery` wrote, parsed, in `provenance.json` order. Throws if a file's hash changed. */
|
|
287
|
+
export const readDiscovery = (dir) => {
|
|
288
|
+
const provenance = JSON.parse(readFileSync(join(dir, "provenance.json"), "utf8"));
|
|
289
|
+
const documents = {};
|
|
290
|
+
for (const [service, entry] of Object.entries(provenance)) {
|
|
291
|
+
const raw = readFileSync(join(dir, `${service}.json`));
|
|
292
|
+
if (sha256(raw) !== entry.sha256)
|
|
293
|
+
throw new CodegenError([`Discovery hash mismatch: ${service}`]);
|
|
294
|
+
documents[service] = JSON.parse(raw.toString("utf8"));
|
|
295
|
+
}
|
|
296
|
+
return documents;
|
|
297
|
+
};
|
package/dist/codegen/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turns an official API description into committed code: types, Valibot schemas, an operation
|
|
3
|
-
* manifest and a coverage page. Build-time
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* manifest and a coverage page. Build-time, apart from the small helpers a CLI reads the generated
|
|
4
|
+
* files with (`identifier`, `requestSchema`), and format-neutral: it parses no file format and
|
|
5
|
+
* adds no dependency. `adaptOpenApi` reads an OpenAPI document and `adaptDiscovery` Google Discovery
|
|
6
|
+
* documents the consumer has parsed; an adapter from Postman or anything else lives in the consumer.
|
|
6
7
|
*/
|
|
8
|
+
export { adaptDiscovery, type DiscoveryMetadata, type DiscoveryOptions, type DiscoveryProvenance, type DiscoveryResult, type DiscoveryService, readDiscovery, type SyncDiscoveryOptions, syncDiscovery, } from "./discovery.js";
|
|
9
|
+
export { requestSchema } from "./json-schema.js";
|
|
7
10
|
export { type CoverageOptions, coverageGenerator, definitionsGenerator, type ManifestOperation, manifestGenerator, } from "./manifest.js";
|
|
8
11
|
export type { ApiBody, ApiModel, ApiOperation, ApiParameter, ApiSchema, Binding, CodeGenerator, Confidence, Discriminator, Effect, GeneratedArtifact, HttpBinding, HttpMethod, Override, RpcBinding, SchemaNode, SourceInfo, SourceReference, } from "./model.js";
|
|
9
12
|
export { identifier, kebab } from "./names.js";
|
package/dist/codegen/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turns an official API description into committed code: types, Valibot schemas, an operation
|
|
3
|
-
* manifest and a coverage page. Build-time
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* manifest and a coverage page. Build-time, apart from the small helpers a CLI reads the generated
|
|
4
|
+
* files with (`identifier`, `requestSchema`), and format-neutral: it parses no file format and
|
|
5
|
+
* adds no dependency. `adaptOpenApi` reads an OpenAPI document and `adaptDiscovery` Google Discovery
|
|
6
|
+
* documents the consumer has parsed; an adapter from Postman or anything else lives in the consumer.
|
|
6
7
|
*/
|
|
8
|
+
export { adaptDiscovery, readDiscovery, syncDiscovery, } from "./discovery.js";
|
|
9
|
+
export { requestSchema } from "./json-schema.js";
|
|
7
10
|
export { coverageGenerator, definitionsGenerator, manifestGenerator, } from "./manifest.js";
|
|
8
11
|
export { identifier, kebab } from "./names.js";
|
|
9
12
|
export { adaptOpenApi, OpenApiError } from "./openapi.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ManifestOperation } from "./manifest.js";
|
|
2
|
+
import type { SchemaNode } from "./model.js";
|
|
3
|
+
/**
|
|
4
|
+
* The JSON Schema (draft 2020-12) of an operation's request body, built from the `definitions` that
|
|
5
|
+
* `definitionsGenerator` wrote, with every referenced definition under `$defs`; `null` when the operation takes no
|
|
6
|
+
* body. For a CLI's `api show`, so an agent sees what to send before it calls.
|
|
7
|
+
*/
|
|
8
|
+
export declare const requestSchema: (operation: Pick<ManifestOperation, "request">, definitions: Readonly<Record<string, SchemaNode>>) => Record<string, unknown> | null;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { identifier } from "./names.js";
|
|
2
|
+
/**
|
|
3
|
+
* The JSON Schema (draft 2020-12) of an operation's request body, built from the `definitions` that
|
|
4
|
+
* `definitionsGenerator` wrote, with every referenced definition under `$defs`; `null` when the operation takes no
|
|
5
|
+
* body. For a CLI's `api show`, so an agent sees what to send before it calls.
|
|
6
|
+
*/
|
|
7
|
+
export const requestSchema = (operation, definitions) => {
|
|
8
|
+
if (!operation.request?.schema)
|
|
9
|
+
return null;
|
|
10
|
+
const defs = {};
|
|
11
|
+
const visiting = new Set();
|
|
12
|
+
const node = (value) => {
|
|
13
|
+
const common = { ...(value.description ? { description: value.description } : {}) };
|
|
14
|
+
switch (value.type) {
|
|
15
|
+
case "ref": {
|
|
16
|
+
const name = identifier(value.ref);
|
|
17
|
+
add(name);
|
|
18
|
+
return { ...common, $ref: `#/$defs/${name}` };
|
|
19
|
+
}
|
|
20
|
+
case "unknown":
|
|
21
|
+
return common;
|
|
22
|
+
case "union":
|
|
23
|
+
return { ...common, anyOf: value.of.map(node) };
|
|
24
|
+
case "allOf":
|
|
25
|
+
return { ...common, allOf: value.of.map(node) };
|
|
26
|
+
case "object":
|
|
27
|
+
return {
|
|
28
|
+
...common,
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: Object.fromEntries(Object.entries(value.properties).map(([key, item]) => [key, node(item)])),
|
|
31
|
+
required: value.required,
|
|
32
|
+
additionalProperties: value.additionalProperties ? node(value.additionalProperties) : true,
|
|
33
|
+
};
|
|
34
|
+
case "array":
|
|
35
|
+
return {
|
|
36
|
+
...common,
|
|
37
|
+
type: "array",
|
|
38
|
+
items: node(value.items),
|
|
39
|
+
...(value.minItems === undefined ? {} : { minItems: value.minItems }),
|
|
40
|
+
...(value.maxItems === undefined ? {} : { maxItems: value.maxItems }),
|
|
41
|
+
};
|
|
42
|
+
case "string":
|
|
43
|
+
return {
|
|
44
|
+
...common,
|
|
45
|
+
type: "string",
|
|
46
|
+
...(value.enum ? { enum: value.enum } : {}),
|
|
47
|
+
...(value.pattern ? { pattern: value.pattern } : {}),
|
|
48
|
+
...(value.minLength === undefined ? {} : { minLength: value.minLength }),
|
|
49
|
+
...(value.maxLength === undefined ? {} : { maxLength: value.maxLength }),
|
|
50
|
+
};
|
|
51
|
+
case "boolean":
|
|
52
|
+
return { ...common, type: "boolean" };
|
|
53
|
+
case "integer":
|
|
54
|
+
case "number":
|
|
55
|
+
return {
|
|
56
|
+
...common,
|
|
57
|
+
type: value.type,
|
|
58
|
+
...(value.minimum === undefined ? {} : { minimum: value.minimum }),
|
|
59
|
+
...(value.maximum === undefined ? {} : { maximum: value.maximum }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const add = (name) => {
|
|
64
|
+
if (visiting.has(name))
|
|
65
|
+
return;
|
|
66
|
+
visiting.add(name);
|
|
67
|
+
const value = definitions[name];
|
|
68
|
+
if (!value)
|
|
69
|
+
throw new Error(`generated schema reference is missing: ${name}`);
|
|
70
|
+
defs[name] = node(value);
|
|
71
|
+
};
|
|
72
|
+
add(operation.request.schema);
|
|
73
|
+
return {
|
|
74
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
75
|
+
$ref: `#/$defs/${operation.request.schema}`,
|
|
76
|
+
$defs: defs,
|
|
77
|
+
};
|
|
78
|
+
};
|
package/dist/codegen/model.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/codegen/model.js
CHANGED
|
@@ -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
|
|
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/dist/commands/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { Command } from "commander";
|
|
13
13
|
export { type CommandsDocument, type CommandsDocumentAbout, commandsDocument, commandsDocumentJsonSchema, commandsDocumentSchema, parseCommandsDocument, } from "./document.js";
|
|
14
14
|
export { COMMANDS_PAGE_LABELS, type CommandsPage, type CommandsPageLabels, type CommandsPageText, commandsPage, } from "./page.js";
|
|
15
|
-
export
|
|
15
|
+
export { type ArgumentInfo, annotate, annotateOption, type CommandInfo, type CommandLifetime, type CommandMeta, type CommandOutput, type CommandReach, type CommandState, describeOptions, describeProgram, flatten, metaOf, type OptionInfo, type OptionMeta, type Origin, optionMetaOf, stepTargets, } from "./registry.js";
|
|
16
16
|
/**
|
|
17
17
|
* Refuses a command path that arrived as one argument — `"projects add" --help`, what a zsh loop over
|
|
18
18
|
* `$c` passes. Commander settles `--help` for an unknown word before any hook runs and prints the root
|
package/dist/commands/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "../errors.js";
|
|
2
2
|
export { commandsDocument, commandsDocumentJsonSchema, commandsDocumentSchema, parseCommandsDocument, } from "./document.js";
|
|
3
3
|
export { COMMANDS_PAGE_LABELS, commandsPage, } from "./page.js";
|
|
4
|
-
export
|
|
4
|
+
export { annotate, annotateOption, describeOptions, describeProgram, flatten, metaOf, optionMetaOf, stepTargets, } from "./registry.js";
|
|
5
5
|
/**
|
|
6
6
|
* Refuses a command path that arrived as one argument — `"projects add" --help`, what a zsh loop over
|
|
7
7
|
* `$c` passes. Commander settles `--help` for an unknown word before any hook runs and prints the root
|