@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.
- package/README.md +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- 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
|
+
}
|
package/src/cc/client.ts
ADDED
|
@@ -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
|
+
}
|