@m6d/cortex-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +175 -0
  2. package/bin/cortex.js +52 -0
  3. package/package.json +41 -0
  4. package/src/cc/client.ts +295 -0
  5. package/src/cc/flags.ts +135 -0
  6. package/src/cc/output.ts +77 -0
  7. package/src/cc/profiles.ts +105 -0
  8. package/src/cli.ts +56 -0
  9. package/src/commands/cc.ts +504 -0
  10. package/src/commands/graph.ts +229 -0
  11. package/src/commands/new.ts +333 -0
  12. package/src/commands/swagger.ts +261 -0
  13. package/src/config/load.ts +122 -0
  14. package/src/config/validate.ts +46 -0
  15. package/src/contracts/charts/chart-spec.ts +201 -0
  16. package/src/contracts/charts/index.ts +7 -0
  17. package/src/contracts/client-tools/index.ts +82 -0
  18. package/src/contracts/egress/index.ts +161 -0
  19. package/src/contracts/egress/scrub.ts +59 -0
  20. package/src/contracts/graph/clients/embed.ts +45 -0
  21. package/src/contracts/graph/clients/neo4j.ts +85 -0
  22. package/src/contracts/graph/helpers.ts +57 -0
  23. package/src/contracts/graph/index.ts +30 -0
  24. package/src/contracts/graph/schema.ts +65 -0
  25. package/src/contracts/graph/types.ts +125 -0
  26. package/src/contracts/markdown/index.ts +39 -0
  27. package/src/contracts/rich-text/index.ts +220 -0
  28. package/src/contracts/runtime/api.ts +328 -0
  29. package/src/contracts/runtime/auth.ts +41 -0
  30. package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
  31. package/src/contracts/runtime/dataset-sql.ts +2522 -0
  32. package/src/contracts/runtime/datasets.fixtures.json +2849 -0
  33. package/src/contracts/runtime/datasets.ts +1391 -0
  34. package/src/contracts/runtime/grammar.ts +161 -0
  35. package/src/contracts/runtime/index.ts +14 -0
  36. package/src/contracts/runtime/signature.ts +237 -0
  37. package/src/contracts/runtime/sync.ts +172 -0
  38. package/src/contracts/runtime/triggers.ts +224 -0
  39. package/src/contracts/wire/index.ts +264 -0
  40. package/src/graph/expand-domains.ts +288 -0
  41. package/src/graph/generate-cypher.ts +201 -0
  42. package/src/graph/seed.ts +207 -0
  43. package/src/graph/validate.ts +78 -0
  44. package/src/scaffold/features.ts +190 -0
  45. package/src/scaffold/files.ts +411 -0
  46. package/src/scaffold/ports.ts +86 -0
  47. package/src/scaffold/templates/domains-README.md.txt +16 -0
  48. package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
  49. package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
  50. package/src/scaffold/templates/gitignore.txt +19 -0
  51. package/src/scaffold/templates/index.ts.txt +18 -0
  52. package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
  53. package/src/scaffold/templates/tsconfig.json.txt +25 -0
  54. package/src/swagger/extract-endpoints.ts +266 -0
  55. package/src/swagger/generated-block.ts +74 -0
  56. package/src/swagger/openapi-schema.ts +161 -0
  57. package/src/swagger/parse-response.ts +88 -0
  58. package/src/swagger/types.ts +25 -0
  59. package/src/ui/prompts.ts +125 -0
  60. package/src/ui/report.ts +107 -0
  61. package/tsconfig.json +46 -0
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # @m6d/cortex-cli
2
+
3
+ The `cortex` command line: scaffold Cortex servers, and run the generators that otherwise need a
4
+ hand-written script.
5
+
6
+ ```sh
7
+ bunx @m6d/cortex-cli new my-app # from nowhere — scaffolds a project
8
+ bunx cortex swagger sync # inside a project — reads its cortex.config.ts
9
+ bunx cortex graph seed
10
+ ```
11
+
12
+ Commands are noun-first — `cortex <resource> <verb>` — except `new`, which acts on no existing
13
+ resource. Run `cortex --help`, then `cortex <resource> --help`.
14
+
15
+ ```sh
16
+ bun add -d @m6d/cortex-cli
17
+ ```
18
+
19
+ Requires [Bun](https://bun.sh). The binary re-execs itself under Bun when Node started it, and tells
20
+ you how to install Bun when it is genuinely absent.
21
+
22
+ ## `cortex new`
23
+
24
+ Scaffolds `./<name>`: a standalone project with `cortex.config.ts`, `index.ts`, `.env` and
25
+ `.env.example`, inlined eslint/prettier/cspell/fallow config, and a `docker-compose.yml` when a
26
+ selection contributes a service. It then runs `bun install`, and `git init` unless the target is
27
+ already inside a work tree. An existing non-empty directory is an error.
28
+
29
+ ```sh
30
+ bunx @m6d/cortex-cli new widget-backend --knowledge --redis
31
+ ```
32
+
33
+ On a TTY it asks only what the flags left open — the name, the database, and one multi-select over
34
+ the undecided features. With no TTY it never prompts and behaves as `--yes`, so a missing name is a
35
+ hard error rather than a hang.
36
+
37
+ | Flag | Default | Writes |
38
+ | ------------------------------------------ | ---------- | ------------------------------------------------------------------------- |
39
+ | `--database <postgres\|mssql>` | `postgres` | The `database` block. `postgres` also gets a compose service; `mssql` not |
40
+ | `--attachments` / `--no-attachments` | off | `storage` + `vision` — MinIO uploads, described by a vision model |
41
+ | `--redis` / `--no-redis` | off | `redis` — resumable streams across restarts |
42
+ | `--auth` / `--no-auth` | off | `auth` — JWT verification via JWKS |
43
+ | `--knowledge` / `--no-knowledge` | off | `neo4j` + `embedding` + `knowledge`, plus a `src/domains/` tree |
44
+ | `--control-center` / `--no-control-center` | off | `controlCenter` |
45
+ | `--yes` | | Accept the defaults instead of asking |
46
+
47
+ Unselected features are still written to `cortex.config.ts`, commented out, alongside `fastModel`,
48
+ `reranker`, and `context` — the generated config documents what else exists. Every credential and
49
+ URL is a placeholder in `.env`; none of them is prompted for.
50
+
51
+ ## `cortex swagger sync` / `check`
52
+
53
+ Fetches every Swagger URL the config names — server-level and per-agent, deduped — and reconciles
54
+ the `.endpoint.ts` files under the domains directory against it.
55
+
56
+ ```sh
57
+ bunx cortex swagger sync
58
+ bunx cortex swagger check
59
+ bunx cortex swagger sync --domains-dir ./server/src/domains
60
+ ```
61
+
62
+ `sync` rewrites each endpoint's params, body, and response schemas in place and prints only the
63
+ files it changed; unchanged ones live in the footer count. `check` writes nothing, reports the drift
64
+ it found, and exits 2 — which is what makes it a CI step.
65
+
66
+ An endpoint file pointing at a route the spec no longer serves is `MISSING`. `sync` prints those
67
+ last, so they are the thing you read, and still exits 0: writing is its job. `check` counts them as
68
+ a finding, same as drift.
69
+
70
+ `-d, --domains-dir <path>` is declared on the `swagger` group, so it works on either side of the
71
+ verb. It defaults to `src/domains`, resolved relative to the config file.
72
+
73
+ ## `cortex graph seed`
74
+
75
+ Loads every domain the config declares — server-level `knowledge.domains` and per-agent ones,
76
+ deduped — and seeds Neo4j with the concepts, endpoints, services, and rules they declare, then
77
+ embeds them. Reports per domain, because a domain is the unit you edit. No
78
+ flags.
79
+
80
+ ```sh
81
+ bunx cortex graph seed
82
+ ```
83
+
84
+ Seeding is `MERGE`-based, so re-running is safe. If any statement failed, the graph is partially
85
+ seeded and the command exits 1 — a half-seeded graph should not pass as success.
86
+
87
+ ## `cortex cc`
88
+
89
+ Manage a Control Center without its console. The commands come from the
90
+ Control Center's own management API (`/api/management/openapi.json`), fetched
91
+ on each run and cached, so the tree always matches the deployment you point
92
+ at and a new procedure needs no CLI release.
93
+
94
+ ```sh
95
+ cortex cc login # asks for the URL and a management key
96
+ cortex cc status
97
+ cortex cc agents list
98
+ cortex cc agents create --slug support --name Support --serverId <uuid>
99
+ cortex cc tools create --json tool.json --publish
100
+ cortex cc tools list -o json | jq '.items[].name'
101
+ cortex cc logout
102
+ ```
103
+
104
+ Profiles live in `~/.cortex/cc.json` (owner-only). `--profile <name>` picks
105
+ one; `CORTEX_CC_URL` and `CORTEX_CC_KEY`, both set, win over the file for CI.
106
+ `CORTEX_HOME` relocates the directory.
107
+
108
+ The grammar is `cortex cc <router> <procedure> [id] [flags]`. Each top-level
109
+ input field is a flag typed from the API's schema; nested values take JSON,
110
+ and `--json <file|->` supplies a whole body that flags then override. A
111
+ required field left out is asked for on a terminal and named as the missing
112
+ flag otherwise. Listings take `--pageIndex`, `--pageSize`, `--q`, and `--all`
113
+ to walk every page. `-o json` prints the raw result for pipes.
114
+
115
+ Writes to agents, tools, services, skills, triggers, and dataset hosts save
116
+ drafts.
117
+ `--publish` on a write the API marks publishable (`update`, and a `create`
118
+ whose result is complete enough) runs that entity's publish procedure right
119
+ after; without it the command says so and names the command. Live fields bind
120
+ on the server's next config revalidation without a publish: a trigger's
121
+ `credential` (a secret, so it is prompted for or piped in with `--json -`
122
+ rather than passed as a flag) and a dataset host's url, access fields, and
123
+ timeout. `cortex cc dataset-hosts sync <id>` reads a host's manifest, and a
124
+ draft host publishes only after `dataset-hosts test` reached it once.
125
+
126
+ ## Global options
127
+
128
+ | Option | Description |
129
+ | --------------- | -------------------------------------------- |
130
+ | `-v, --verbose` | Print full errors and the commands being run |
131
+ | `-V, --version` | Output the version number |
132
+ | `-h, --help` | Display help for command |
133
+
134
+ `swagger` and `graph` read the project and take `-c, --config <path>` (default
135
+ `./cortex.config.ts`) on the group or the verb. `new` and `cc` do not touch a
136
+ project and have no such flag.
137
+
138
+ Config discovery is that exact filename in the directory you run in, with no upward search. There is
139
+ no `--cwd`: Bun auto-loads `.env` relative to the process cwd before any of our code runs, so
140
+ reading another directory's config from here would pair it with the wrong environment. `cd` first,
141
+ or point `--config` at the file.
142
+
143
+ The config is inert data — a plain object literal the CLI imports. Any key that is present but
144
+ evaluates to `undefined`, usually a missing environment variable, is listed by path and stops the
145
+ run.
146
+
147
+ `graph seed` runs standalone: it never constructs or serves your `@m6d/cortex-server` — it only
148
+ imports your `cortex.config.ts` (whose own imports may pull the server's modules in, inertly).
149
+ Its Neo4j client, its embedder and its `GRAPH_SCHEMA` come from the graph contract the server
150
+ reads the graph back with (each package vendors its own copy at pack time; the server re-exports
151
+ it) — one contract, shared from both ends. Seeding stamps the schema
152
+ version it wrote onto the graph; if that graph already carries a different one, the CLI says so and
153
+ seeds anyway, noting that nodes from the old schema are not removed. Skew is caught on the reading
154
+ side, where it matters: the server checks the stamp on its first resolve and refuses a graph it
155
+ cannot read.
156
+
157
+ `swagger` writes `.endpoint.ts` source whose generated block has to satisfy the server's own
158
+ `EndpointDef`, so a codegen that outran the installed version is a loud type error in your
159
+ `bun run check`.
160
+
161
+ Your `cortex.config.ts` imports `@m6d/cortex-server` for `defineAgent`, so a project without it
162
+ installed fails on the import — reported as an unloadable config, with the missing package named.
163
+
164
+ ## Exit codes
165
+
166
+ | Command | 0 | 1 | 2 |
167
+ | --------------- | --------------------------------------------- | ----------------------------------- | ---------------------- |
168
+ | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled | — |
169
+ | `swagger sync` | Wrote what it could, `MISSING` files included | Couldn't run | — |
170
+ | `swagger check` | Every endpoint file matched and in sync | Couldn't run | Drift and/or `MISSING` |
171
+ | `graph seed` | Everything seeded | A statement failed, or couldn't run | — |
172
+
173
+ _Couldn't run_ means no config file, an unloadable config (a missing `@m6d/cortex-server` in the
174
+ project reads as one), undefined values in it, an absent domains directory, or an unreachable
175
+ Swagger host or Neo4j.
package/bin/cortex.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Plain JavaScript, and a Node shebang, on purpose: `bunx` honours the shebang
4
+ // unless you pass `--bun`, so Node parses this file on the normal path. A
5
+ // `#!/usr/bin/env bun` shebang would give a user without Bun
6
+ // `env: bun: No such file or directory` — unrecoverable, because none of our
7
+ // code ever runs.
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ if (process.versions.bun) {
13
+ await import("../src/cli");
14
+ } else {
15
+ const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
16
+ const args = [cli, ...process.argv.slice(2)];
17
+ let result = spawnSync("bun", args, { stdio: "inherit" });
18
+
19
+ // Node's CVE-2024-27980 fix refuses to spawn a `.cmd` file directly, which is
20
+ // exactly what an npm-installed Bun is on Windows: the user has Bun and still
21
+ // gets EINVAL. `shell: true` is the documented way through; every argument is
22
+ // quoted because cmd.exe splits on the spaces in a Windows path otherwise.
23
+ if (result.error?.code === "EINVAL" && process.platform === "win32") {
24
+ result = spawnSync("bun", args.map(quote), { stdio: "inherit", shell: true });
25
+ }
26
+
27
+ // Any spawn error at all means Bun did not run, and the reason matters less
28
+ // than the fix — a raw `Error: spawnSync bun EACCES` helps nobody.
29
+ if (result.error) {
30
+ process.stderr.write(
31
+ "\n■ cortex needs Bun.\n" +
32
+ (result.error.code === "ENOENT" ? "" : `\n ${result.error.message}\n`) +
33
+ "\n curl -fsSL https://bun.sh/install | bash\n" +
34
+ "\n Then: bunx @m6d/cortex-cli new\n\n",
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ // A signal-killed child reports `status: null`. Exiting 1 would turn the
40
+ // user's Ctrl-C into a generic failure, so the signal is re-raised on
41
+ // ourselves and the shell sees the 130/143 it expects. If the signal is
42
+ // ignored or blocked and we survive it, the exit below still runs.
43
+ if (result.signal) {
44
+ process.kill(process.pid, result.signal);
45
+ }
46
+
47
+ process.exit(result.status ?? 1);
48
+ }
49
+
50
+ function quote(argument) {
51
+ return `"${argument}"`;
52
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@m6d/cortex-cli",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold and operate Cortex servers",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "cortex": "./bin/cortex.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "tsconfig.json",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "check": "tsc --noEmit",
19
+ "prepack": "rm -rf src/contracts && cp -R ../../internal/contracts/src src/contracts",
20
+ "postpack": "rm -rf src/contracts",
21
+ "test": "bun test"
22
+ },
23
+ "dependencies": {
24
+ "@clack/prompts": "^1.7.0",
25
+ "@commander-js/extra-typings": "^15.0.0",
26
+ "commander": "^15.0.0",
27
+ "picocolors": "^1.1.1"
28
+ },
29
+ "devDependencies": {
30
+ "@cortex/contracts": "workspace:*",
31
+ "@types/bun": "1.3.14",
32
+ "typescript": "~6.0.3"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "releaseWatchPaths": [
38
+ "internal/contracts",
39
+ "packages/cortex-server"
40
+ ]
41
+ }
@@ -0,0 +1,295 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { profilesPath } from "./profiles";
6
+
7
+ export type OpenApiOperation = {
8
+ operationId: string;
9
+ summary?: string;
10
+ tags?: string[];
11
+ /** The Control Center's own semantics, so nothing here is inferred from names or prose. */
12
+ "x-destructive"?: true;
13
+ "x-publish-procedure"?: string;
14
+ "x-live-fields"?: string[];
15
+ /** A query's input, one parameter per field; a field the URL cannot carry as a scalar has JSON `content` instead of a `schema`. */
16
+ parameters?: Array<{
17
+ name: string;
18
+ in: string;
19
+ required?: boolean;
20
+ schema?: JsonSchema;
21
+ content?: Record<string, { schema: JsonSchema }>;
22
+ }>;
23
+ requestBody?: { content: Record<string, { schema: JsonSchema }> };
24
+ };
25
+ export type JsonSchema = {
26
+ type?: string | string[];
27
+ properties?: Record<string, JsonSchema>;
28
+ required?: string[];
29
+ enum?: unknown[];
30
+ items?: JsonSchema;
31
+ anyOf?: JsonSchema[];
32
+ default?: unknown;
33
+ description?: string;
34
+ format?: string;
35
+ $ref?: string;
36
+ };
37
+ export type OpenApiDocument = {
38
+ info: { version: string };
39
+ paths: Record<string, Record<string, OpenApiOperation>>;
40
+ components?: { schemas?: Record<string, JsonSchema> };
41
+ };
42
+
43
+ type Cached = { etag: string; document: OpenApiDocument };
44
+
45
+ /**
46
+ * One cache entry per Control Center base URL (scheme, host, port, path),
47
+ * so an http and an https profile, or two deployments under one host, never
48
+ * read each other's document. The host keeps the file recognizable; a hash
49
+ * of the whole URL keeps two URLs from ever sharing a name.
50
+ */
51
+ function cachePath(url: string) {
52
+ const { host } = new URL(url);
53
+ const digest = createHash("sha256").update(url).digest("base64url").slice(0, 16);
54
+ return join(
55
+ dirname(profilesPath()),
56
+ "cache",
57
+ `${host.replace(/[^\w.-]+/g, "_")}-${digest}.json`,
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Enough of an OpenAPI document to build commands from: `info.version`, a
63
+ * `paths` object whose every operation has an `operationId` and, where
64
+ * present, object-shaped `parameters`, `requestBody`, and `components`.
65
+ * Deeper schema shapes are read defensively by the flag builder.
66
+ */
67
+ function isOpenApiDocument(value: unknown): value is OpenApiDocument {
68
+ if (!isRecord(value)) return false;
69
+ const { info, paths, components } = value;
70
+ if (!isRecord(info) || typeof info["version"] !== "string") return false;
71
+ if (components !== undefined) {
72
+ if (!isRecord(components)) return false;
73
+ const { schemas } = components;
74
+ if (
75
+ schemas !== undefined &&
76
+ !(isRecord(schemas) && Object.values(schemas).every(isJsonSchema))
77
+ )
78
+ return false;
79
+ }
80
+ return (
81
+ isRecord(paths) &&
82
+ Object.values(paths).every(
83
+ (methods) => isRecord(methods) && Object.values(methods).every(isOperation),
84
+ )
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The schema subset the flag builder reads: `type`, `properties`, `required`,
90
+ * `enum`, `items`, `anyOf`, `description`, `format`, `$ref`, each of its
91
+ * declared shape when present. Checked recursively so a nested `null` never
92
+ * reaches it.
93
+ */
94
+ function isJsonSchema(value: unknown): value is JsonSchema {
95
+ return isRecord(value) && Object.entries(SCHEMA_FIELDS).every(([key, ok]) => ok(value[key]));
96
+ }
97
+
98
+ const isString = (value: unknown) => typeof value === "string";
99
+ const isStrings = (value: unknown) => Array.isArray(value) && value.every(isString);
100
+ const optional = (check: (value: unknown) => boolean) => (value: unknown) =>
101
+ value === undefined || check(value);
102
+
103
+ /** One check per field the flag builder reads; anything else in the schema is ignored. */
104
+ const SCHEMA_FIELDS = {
105
+ type: optional((v) => isString(v) || isStrings(v)),
106
+ properties: optional((v) => isRecord(v) && Object.values(v).every(isJsonSchema)),
107
+ required: optional(isStrings),
108
+ enum: optional(Array.isArray),
109
+ items: optional(isJsonSchema),
110
+ anyOf: optional((v) => Array.isArray(v) && v.every(isJsonSchema)),
111
+ description: optional(isString),
112
+ format: optional(isString),
113
+ $ref: optional(isString),
114
+ } satisfies Record<string, (value: unknown) => boolean>;
115
+
116
+ function isContent(value: unknown): value is Record<string, { schema: JsonSchema }> {
117
+ return (
118
+ isRecord(value) &&
119
+ Object.values(value).every((media) => isRecord(media) && isJsonSchema(media["schema"]))
120
+ );
121
+ }
122
+
123
+ function isOperation(value: unknown): value is OpenApiOperation {
124
+ if (!isRecord(value) || typeof value["operationId"] !== "string") return false;
125
+ const { summary, tags, parameters, requestBody } = value;
126
+ const publishProcedure = value["x-publish-procedure"];
127
+ const liveFields = value["x-live-fields"];
128
+ if (summary !== undefined && typeof summary !== "string") return false;
129
+ if (publishProcedure !== undefined && typeof publishProcedure !== "string") return false;
130
+ if (
131
+ liveFields !== undefined &&
132
+ !(Array.isArray(liveFields) && liveFields.every((f) => typeof f === "string"))
133
+ )
134
+ return false;
135
+ if (tags !== undefined && !(Array.isArray(tags) && tags.every((t) => typeof t === "string")))
136
+ return false;
137
+ if (
138
+ parameters !== undefined &&
139
+ !(
140
+ Array.isArray(parameters) &&
141
+ parameters.every(
142
+ (p) =>
143
+ isRecord(p) &&
144
+ typeof p["name"] === "string" &&
145
+ typeof p["in"] === "string" &&
146
+ (p["required"] === undefined || typeof p["required"] === "boolean") &&
147
+ (p["schema"] === undefined || isJsonSchema(p["schema"])) &&
148
+ (p["content"] === undefined || isContent(p["content"])),
149
+ )
150
+ )
151
+ )
152
+ return false;
153
+ return (
154
+ requestBody === undefined || (isRecord(requestBody) && isContent(requestBody["content"]))
155
+ );
156
+ }
157
+
158
+ function isCached(value: unknown): value is Cached {
159
+ return (
160
+ isRecord(value) && typeof value["etag"] === "string" && isOpenApiDocument(value["document"])
161
+ );
162
+ }
163
+
164
+ export function isRecord(value: unknown): value is Record<string, unknown> {
165
+ return typeof value === "object" && value !== null && !Array.isArray(value);
166
+ }
167
+
168
+ /** A cache entry that fails to parse or has the wrong shape is treated as absent. */
169
+ function readCache(path: string) {
170
+ if (!existsSync(path)) return undefined;
171
+ try {
172
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
173
+ return isCached(parsed) ? parsed : undefined;
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The management API's OpenAPI document, which is what the command tree is
181
+ * built from. Revalidated with `If-None-Match` on every run and cached beside
182
+ * the profiles, so a second run costs one 304 and an offline run still has
183
+ * commands. `null` when neither the network nor the cache can supply it.
184
+ */
185
+ export async function loadOpenApi(url: string) {
186
+ const path = cachePath(url);
187
+ const cached = readCache(path);
188
+ try {
189
+ const response = await fetch(`${url}/api/management/openapi.json`, {
190
+ headers: cached ? { "If-None-Match": cached.etag } : {},
191
+ });
192
+ if (response.status === 304 && cached)
193
+ return { document: cached.document, from: "cache" as const };
194
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
195
+ const document: unknown = await response.json();
196
+ if (!isOpenApiDocument(document)) throw new Error("not an OpenAPI document");
197
+ writeCache(path, { etag: response.headers.get("etag") ?? "", document });
198
+ return { document, from: "network" as const };
199
+ } catch (error) {
200
+ if (cached) return { document: cached.document, from: "stale" as const, error };
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ /** Best-effort: a cache that cannot be written costs the next run a fetch, nothing more. */
206
+ function writeCache(path: string, entry: Cached) {
207
+ try {
208
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
209
+ writeFileSync(path, JSON.stringify(entry));
210
+ } catch {
211
+ // The document in hand is still good; the next run refetches.
212
+ }
213
+ }
214
+
215
+ /**
216
+ * The input schema of one operation as a single object schema, whichever way
217
+ * the document spells it: a query's parameters are folded back into the
218
+ * object they were spread from, a mutation's request body is taken as is.
219
+ */
220
+ export function inputSchemaOf(operation: OpenApiOperation) {
221
+ if (operation.requestBody) {
222
+ return operation.requestBody.content["application/json"]?.schema;
223
+ }
224
+ const parameters = operation.parameters?.filter((p) => p.in === "query") ?? [];
225
+ if (parameters.length === 0) return undefined;
226
+ const properties: Record<string, JsonSchema> = {};
227
+ const required: string[] = [];
228
+ for (const parameter of parameters) {
229
+ const schema = parameter.schema ?? parameter.content?.["application/json"]?.schema;
230
+ if (!schema) continue;
231
+ properties[parameter.name] = schema;
232
+ if (parameter.required) required.push(parameter.name);
233
+ }
234
+ return { type: "object", properties, required } satisfies JsonSchema;
235
+ }
236
+
237
+ /**
238
+ * One management call. A query's input object is spread over the query
239
+ * string, one parameter per field, with a scalar written plainly and
240
+ * anything else JSON-encoded, which is how the document declares it. A
241
+ * mutation posts its input as the JSON body. The error body's message on
242
+ * failure.
243
+ */
244
+ export async function callManagement(
245
+ profile: { url: string; key: string },
246
+ method: "get" | "post",
247
+ path: string,
248
+ input: unknown,
249
+ ) {
250
+ const base = `${profile.url}/api/management${path}`;
251
+ const url = method === "get" && isRecord(input) ? `${base}${queryString(input)}` : base;
252
+ const response = await fetch(url, {
253
+ method: method.toUpperCase(),
254
+ headers: {
255
+ Authorization: `Bearer ${profile.key}`,
256
+ ...(method === "post" ? { "Content-Type": "application/json" } : {}),
257
+ },
258
+ body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
259
+ });
260
+ const body: unknown = await response.json().catch(() => null);
261
+ if (!response.ok) {
262
+ const { code, message } = (body ?? {}) as { code?: string; message?: string };
263
+ throw new ManagementError(response.status, code ?? "error", message ?? response.statusText);
264
+ }
265
+ return body;
266
+ }
267
+
268
+ function isScalar(value: unknown): value is string | number | boolean | null {
269
+ return value === null || ["string", "number", "boolean"].includes(typeof value);
270
+ }
271
+
272
+ /** A scalar as itself, an array of scalars as a repeated parameter, anything else as JSON, matching what the document declares. */
273
+ function queryString(input: Record<string, unknown>) {
274
+ const params = new URLSearchParams();
275
+ for (const [name, value] of Object.entries(input)) {
276
+ if (value === undefined) continue;
277
+ if (isScalar(value)) params.set(name, String(value));
278
+ else if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) {
279
+ for (const item of value) params.append(name, String(item));
280
+ } else params.set(name, JSON.stringify(value));
281
+ }
282
+ const encoded = params.toString();
283
+ return encoded === "" ? "" : `?${encoded}`;
284
+ }
285
+
286
+ export class ManagementError extends Error {
287
+ public constructor(
288
+ public readonly status: number,
289
+ public readonly code: string,
290
+ message: string,
291
+ ) {
292
+ super(message);
293
+ this.name = "ManagementError";
294
+ }
295
+ }