@m6d/cortex-cli 0.12.1 → 0.14.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 +193 -128
- package/package.json +16 -5
- package/src/cli.ts +19 -12
- package/src/commands/apply.ts +186 -0
- package/src/commands/{cc.ts → management.ts} +173 -112
- package/src/commands/new.ts +83 -84
- package/src/commands/setup.ts +610 -0
- package/src/contracts/charts/chart-spec.ts +1 -1
- package/src/contracts/client-tools/index.ts +3 -3
- package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
- package/src/contracts/datasets/index.ts +3 -0
- package/src/contracts/egress-policy/index.ts +16 -0
- package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
- package/src/contracts/{egress → egress-policy}/url.ts +22 -9
- package/src/contracts/errors/index.ts +38 -0
- package/src/contracts/json/index.ts +5 -0
- package/src/contracts/manifest/index.ts +973 -0
- package/src/contracts/manifest-file/index.ts +16 -0
- package/src/contracts/markdown/index.ts +1 -1
- package/src/contracts/pattern/index.ts +276 -0
- package/src/contracts/server/index.ts +349 -0
- package/src/contracts/server/reserved-names.fixtures.json +18 -0
- package/src/contracts/triggers/index.ts +112 -0
- package/src/contracts/wire/index.ts +67 -33
- package/src/{cc → management}/client.ts +33 -7
- package/src/{cc → management}/flags.ts +2 -1
- package/src/{cc → management}/profiles.ts +10 -8
- package/src/scaffold/files.ts +448 -284
- package/src/scaffold/ports.ts +12 -24
- package/src/scaffold/templates/cspell.json.txt +41 -0
- package/src/scaffold/templates/datasets.ts.txt +52 -0
- package/src/scaffold/templates/dockerignore.txt +6 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
- package/src/scaffold/templates/gitignore.txt +1 -1
- package/src/scaffold/templates/server.ts.txt +40 -0
- package/src/scaffold/templates/setup.ts.txt +44 -0
- package/src/ui/prompts.ts +24 -38
- package/src/ui/report.ts +5 -15
- package/tsconfig.json +2 -3
- package/src/commands/graph.ts +0 -228
- package/src/commands/swagger.ts +0 -247
- package/src/config/load.ts +0 -122
- package/src/config/validate.ts +0 -46
- package/src/contracts/credentials/index.ts +0 -183
- package/src/contracts/egress/index.ts +0 -120
- package/src/contracts/graph/clients/embed.ts +0 -62
- package/src/contracts/graph/clients/neo4j.ts +0 -85
- package/src/contracts/graph/helpers.ts +0 -55
- package/src/contracts/graph/index.ts +0 -30
- package/src/contracts/graph/schema.ts +0 -65
- package/src/contracts/graph/types.ts +0 -125
- package/src/contracts/rich-text/index.ts +0 -220
- package/src/contracts/runtime/api.ts +0 -345
- package/src/contracts/runtime/auth.ts +0 -41
- package/src/contracts/runtime/grammar.ts +0 -169
- package/src/contracts/runtime/index.ts +0 -15
- package/src/contracts/runtime/models.ts +0 -50
- package/src/contracts/runtime/signature.ts +0 -255
- package/src/contracts/runtime/sync.ts +0 -196
- package/src/contracts/runtime/triggers.ts +0 -267
- package/src/graph/expand-domains.ts +0 -286
- package/src/graph/generate-cypher.ts +0 -201
- package/src/graph/seed.ts +0 -199
- package/src/graph/validate.ts +0 -78
- package/src/scaffold/features.ts +0 -190
- package/src/scaffold/templates/domains-README.md.txt +0 -16
- package/src/scaffold/templates/index.ts.txt +0 -18
- package/src/swagger/extract-endpoints.ts +0 -269
- package/src/swagger/generated-block.ts +0 -74
- package/src/swagger/openapi-schema.ts +0 -168
- package/src/swagger/parse-response.ts +0 -88
- package/src/swagger/types.ts +0 -25
- /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
- /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
- /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
- /package/src/{cc → management}/output.ts +0 -0
package/src/graph/validate.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { ConceptDef, DomainDef, RuleDef } from "@cortex/contracts/graph";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Checks a domain for the two mistakes that survive the type system: a name used
|
|
5
|
-
* twice, and an entity nothing points at.
|
|
6
|
-
*
|
|
7
|
-
* Cross-domain references are allowed — a concept, endpoint or service may be
|
|
8
|
-
* defined in another module — so local membership is deliberately not enforced.
|
|
9
|
-
*/
|
|
10
|
-
export function validateDomain(data: DomainDef) {
|
|
11
|
-
const referenced = collectReferences(data);
|
|
12
|
-
|
|
13
|
-
return [
|
|
14
|
-
...duplicateNames(data.concepts, "concept"),
|
|
15
|
-
...duplicateNames(data.endpoints, "endpoint"),
|
|
16
|
-
...duplicateNames(data.services, "service"),
|
|
17
|
-
...duplicateNames(data.rules, "rule"),
|
|
18
|
-
...unreferenced(
|
|
19
|
-
data.concepts,
|
|
20
|
-
referenced.concepts,
|
|
21
|
-
(name) => `Concept "${name}" is never referenced by any endpoint or service`,
|
|
22
|
-
),
|
|
23
|
-
...unreferenced(
|
|
24
|
-
data.rules,
|
|
25
|
-
referenced.rules,
|
|
26
|
-
(name) => `Rule "${name}" is never referenced by any endpoint, service, or concept`,
|
|
27
|
-
),
|
|
28
|
-
];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function duplicateNames(items: { name: string }[] | undefined, kind: string) {
|
|
32
|
-
const seen = new Set<string>();
|
|
33
|
-
|
|
34
|
-
return (items ?? []).flatMap((item) => {
|
|
35
|
-
const duplicate = seen.has(item.name);
|
|
36
|
-
seen.add(item.name);
|
|
37
|
-
|
|
38
|
-
return duplicate ? [`Duplicate ${kind} name: "${item.name}"`] : [];
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function unreferenced(
|
|
43
|
-
items: { name: string }[] | undefined,
|
|
44
|
-
referenced: Set<string>,
|
|
45
|
-
message: (name: string) => string,
|
|
46
|
-
) {
|
|
47
|
-
return (items ?? [])
|
|
48
|
-
.filter((item) => !referenced.has(item.name))
|
|
49
|
-
.map((item) => message(item.name));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Gathers every name pointed at from anywhere in the domain. */
|
|
53
|
-
function collectReferences(data: DomainDef) {
|
|
54
|
-
const concepts = new Set<string>();
|
|
55
|
-
const rules = new Set<string>();
|
|
56
|
-
const addRules = (governedBy: RuleDef[] | undefined) =>
|
|
57
|
-
governedBy?.forEach((rule) => rules.add(rule.name));
|
|
58
|
-
const addConcept = (concept: ConceptDef | undefined) => concept && concepts.add(concept.name);
|
|
59
|
-
|
|
60
|
-
for (const endpoint of data.endpoints ?? []) {
|
|
61
|
-
endpoint.queries?.forEach(addConcept);
|
|
62
|
-
endpoint.mutates?.forEach(addConcept);
|
|
63
|
-
endpoint.returns?.forEach((returned) => addConcept(returned.concept));
|
|
64
|
-
addRules(endpoint.governedBy);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
for (const service of data.services ?? []) {
|
|
68
|
-
addConcept(service.belongsTo);
|
|
69
|
-
addRules(service.governedBy);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
for (const concept of data.concepts ?? []) {
|
|
73
|
-
addConcept(concept.parentConcept);
|
|
74
|
-
addRules(concept.governedBy);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { concepts, rules };
|
|
78
|
-
}
|
package/src/scaffold/features.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The five features `cortex new` toggles. A feature is not a
|
|
3
|
-
* config key: it is a bundle of a `cortex.config.ts` fragment, a block of env
|
|
4
|
-
* keys, and sometimes a compose service. Fragments concatenate — there is no
|
|
5
|
-
* merge engine — so every field here is plain text that gets joined.
|
|
6
|
-
*
|
|
7
|
-
* The grouping is what the server code already enforces. `vision` is inert
|
|
8
|
-
* without `storage`, so attachments writes both; `seedGraph` throws unless it
|
|
9
|
-
* has all of `neo4j`, `embedding` and `knowledge.domains`, so knowledge writes
|
|
10
|
-
* all three plus the `src/domains/` tree the domainsDir convention assumes.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export type Feature = "attachments" | "redis" | "auth" | "knowledge" | "controlCenter";
|
|
14
|
-
|
|
15
|
-
type Definition = {
|
|
16
|
-
/** One line, shown in `--help` and next to the option in the multi-select. */
|
|
17
|
-
hint: string;
|
|
18
|
-
/** Sits above the fragment in the generated config, commented either way. */
|
|
19
|
-
note: string;
|
|
20
|
-
/**
|
|
21
|
-
* The `cortex.config.ts` fragment, written at zero indentation: the assembler
|
|
22
|
-
* indents it into the object literal, or comments it out for a feature that
|
|
23
|
-
* was not selected. Conditional spread throughout, so an unfilled env var
|
|
24
|
-
* leaves the section *absent* rather than present-and-undefined.
|
|
25
|
-
*/
|
|
26
|
-
config: string;
|
|
27
|
-
/** The `.env` block, identical in `.env` and `.env.example`. */
|
|
28
|
-
env: string;
|
|
29
|
-
/** The `docker-compose.yml` service, indented under `services:`. */
|
|
30
|
-
compose?: string;
|
|
31
|
-
/** The named volume that service mounts. */
|
|
32
|
-
volume?: string;
|
|
33
|
-
/** What `cd`-then-edit has to fill in before the project boots. */
|
|
34
|
-
secrets?: string;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export const FEATURES: Record<Feature, Definition> = {
|
|
38
|
-
attachments: {
|
|
39
|
-
hint: "MinIO storage for uploads, described by a vision model",
|
|
40
|
-
note: "Attachments: an S3-compatible bucket for uploads, plus an image-capable\nmodel that describes them. Vision does nothing without storage, which is\nwhy one toggle writes both.",
|
|
41
|
-
config: `...(process.env["CORTEX_MINIO_ENDPOINT"]
|
|
42
|
-
? {
|
|
43
|
-
storage: {
|
|
44
|
-
endPoint: process.env["CORTEX_MINIO_ENDPOINT"],
|
|
45
|
-
port: Number(process.env["CORTEX_MINIO_PORT"] ?? {{port.minio}}),
|
|
46
|
-
useSSL: process.env["CORTEX_MINIO_USE_SSL"] === "true",
|
|
47
|
-
accessKey: process.env["CORTEX_MINIO_ACCESS_KEY"]!,
|
|
48
|
-
secretKey: process.env["CORTEX_MINIO_SECRET_KEY"]!,
|
|
49
|
-
},
|
|
50
|
-
vision: {
|
|
51
|
-
baseURL: process.env["CORTEX_VISION_URL"]!,
|
|
52
|
-
apiKey: process.env["CORTEX_VISION_KEY"]!,
|
|
53
|
-
modelName: process.env["CORTEX_VISION_MODEL"]!,
|
|
54
|
-
},
|
|
55
|
-
}
|
|
56
|
-
: {}),`,
|
|
57
|
-
env: `# --- Attachments -------------------------------------------------------------
|
|
58
|
-
# S3-compatible object storage. docker-compose.yml runs MinIO on these values.
|
|
59
|
-
CORTEX_MINIO_ENDPOINT=localhost
|
|
60
|
-
CORTEX_MINIO_PORT={{port.minio}}
|
|
61
|
-
CORTEX_MINIO_USE_SSL=false
|
|
62
|
-
CORTEX_MINIO_ACCESS_KEY=minio
|
|
63
|
-
CORTEX_MINIO_SECRET_KEY=minio-secret-key
|
|
64
|
-
|
|
65
|
-
# An image-capable, OpenAI-compatible model. It describes uploaded images so the
|
|
66
|
-
# agent can read them.
|
|
67
|
-
CORTEX_VISION_URL=https://api.openai.com/v1
|
|
68
|
-
CORTEX_VISION_KEY=your-vision-api-key
|
|
69
|
-
CORTEX_VISION_MODEL=your-vision-model`,
|
|
70
|
-
compose: ` minio:
|
|
71
|
-
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
|
72
|
-
command: server /data --console-address ":9001"
|
|
73
|
-
environment:
|
|
74
|
-
MINIO_ROOT_USER: minio
|
|
75
|
-
MINIO_ROOT_PASSWORD: minio-secret-key
|
|
76
|
-
ports:
|
|
77
|
-
- "{{port.minio}}:9000"
|
|
78
|
-
- "{{port.minioConsole}}:9001"
|
|
79
|
-
volumes:
|
|
80
|
-
- minio-data:/data`,
|
|
81
|
-
volume: "minio-data",
|
|
82
|
-
secrets: "CORTEX_VISION_*",
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
redis: {
|
|
86
|
-
hint: "resumable streams across restarts",
|
|
87
|
-
note: "Redis makes live runs survive a restart and reach every instance. Without\nit, an in-memory fallback covers this single process.",
|
|
88
|
-
config: `...(process.env["CORTEX_REDIS_URL"] ? { redis: { url: process.env["CORTEX_REDIS_URL"] } } : {}),`,
|
|
89
|
-
env: `# --- Redis -------------------------------------------------------------------
|
|
90
|
-
# Resumable streams. docker-compose.yml runs one on this URL.
|
|
91
|
-
CORTEX_REDIS_URL=redis://localhost:{{port.redis}}`,
|
|
92
|
-
compose: ` redis:
|
|
93
|
-
image: redis:7-alpine
|
|
94
|
-
ports:
|
|
95
|
-
- "{{port.redis}}:6379"`,
|
|
96
|
-
},
|
|
97
|
-
|
|
98
|
-
auth: {
|
|
99
|
-
hint: "JWT verification via JWKS",
|
|
100
|
-
note: "Bearer tokens verified against an identity provider's JWKS endpoint. The\nprovider is external, so there is no service for it.",
|
|
101
|
-
config: `...(process.env["CORTEX_AUTH_JWKS_URI"]
|
|
102
|
-
? {
|
|
103
|
-
auth: {
|
|
104
|
-
kind: "jwks" as const,
|
|
105
|
-
jwksUri: process.env["CORTEX_AUTH_JWKS_URI"],
|
|
106
|
-
issuer: process.env["CORTEX_AUTH_ISSUER"]!,
|
|
107
|
-
},
|
|
108
|
-
}
|
|
109
|
-
: {}),`,
|
|
110
|
-
env: `# --- Auth --------------------------------------------------------------------
|
|
111
|
-
# Your identity provider's JWKS endpoint, and the issuer it stamps on tokens.
|
|
112
|
-
CORTEX_AUTH_JWKS_URI=https://your-idp.example.com/.well-known/jwks.json
|
|
113
|
-
CORTEX_AUTH_ISSUER=https://your-idp.example.com/`,
|
|
114
|
-
secrets: "CORTEX_AUTH_*",
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
knowledge: {
|
|
118
|
-
hint: "Swagger + domains + Neo4j",
|
|
119
|
-
note: "The knowledge graph, which is three keys rather than one: Neo4j stores it,\nthe embedding endpoint makes it searchable, and the domain definitions under\nsrc/domains/ are what `cortex graph seed` loads into it.",
|
|
120
|
-
config: `...(process.env["CORTEX_NEO4J_URL"]
|
|
121
|
-
? {
|
|
122
|
-
neo4j: {
|
|
123
|
-
url: process.env["CORTEX_NEO4J_URL"],
|
|
124
|
-
user: process.env["CORTEX_NEO4J_USER"]!,
|
|
125
|
-
password: process.env["CORTEX_NEO4J_PASSWORD"]!,
|
|
126
|
-
},
|
|
127
|
-
embedding: {
|
|
128
|
-
baseURL: process.env["CORTEX_EMBEDDING_URL"]!,
|
|
129
|
-
apiKey: process.env["CORTEX_EMBEDDING_KEY"]!,
|
|
130
|
-
modelName: process.env["CORTEX_EMBEDDING_MODEL"]!,
|
|
131
|
-
dimension: Number(process.env["CORTEX_EMBEDDING_DIMENSION"] ?? 1024),
|
|
132
|
-
},
|
|
133
|
-
knowledge: {
|
|
134
|
-
// swagger: { url: "https://api.example.com/swagger/v1/swagger.json" },
|
|
135
|
-
// Import the domains you write under src/domains/ and list them here.
|
|
136
|
-
domains: {},
|
|
137
|
-
},
|
|
138
|
-
}
|
|
139
|
-
: {}),`,
|
|
140
|
-
env: `# --- Knowledge graph ---------------------------------------------------------
|
|
141
|
-
# Neo4j. docker-compose.yml runs one with exactly these credentials. The URL is
|
|
142
|
-
# the HTTP endpoint, not the bolt one: the server talks to Neo4j over its HTTP
|
|
143
|
-
# transaction API.
|
|
144
|
-
CORTEX_NEO4J_URL=http://localhost:{{port.neo4j}}
|
|
145
|
-
CORTEX_NEO4J_USER=neo4j
|
|
146
|
-
CORTEX_NEO4J_PASSWORD=cortex-dev-password
|
|
147
|
-
|
|
148
|
-
# An OpenAI-compatible embeddings endpoint — external, like the main model.
|
|
149
|
-
# CORTEX_EMBEDDING_DIMENSION has to match what the model returns.
|
|
150
|
-
CORTEX_EMBEDDING_URL=https://api.openai.com/v1
|
|
151
|
-
CORTEX_EMBEDDING_KEY=your-embedding-api-key
|
|
152
|
-
CORTEX_EMBEDDING_MODEL=your-embedding-model
|
|
153
|
-
CORTEX_EMBEDDING_DIMENSION=1024`,
|
|
154
|
-
compose: ` neo4j:
|
|
155
|
-
image: neo4j:5-community
|
|
156
|
-
environment:
|
|
157
|
-
NEO4J_AUTH: neo4j/cortex-dev-password
|
|
158
|
-
# Neo4j Browser, served on the HTTP port, opens bolt at whatever this
|
|
159
|
-
# advertises. Without it the browser would offer 7687, which is the
|
|
160
|
-
# container's port and not the one published below.
|
|
161
|
-
NEO4J_server_bolt_advertised__address: ":{{port.neo4jBolt}}"
|
|
162
|
-
ports:
|
|
163
|
-
- "{{port.neo4j}}:7474"
|
|
164
|
-
- "{{port.neo4jBolt}}:7687"
|
|
165
|
-
volumes:
|
|
166
|
-
- neo4j-data:/data`,
|
|
167
|
-
volume: "neo4j-data",
|
|
168
|
-
secrets: "CORTEX_EMBEDDING_*",
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
controlCenter: {
|
|
172
|
-
hint: "serve agents published on a Control Center instance",
|
|
173
|
-
note: "The Control Center you point this server at. External, so no service here.",
|
|
174
|
-
config: `...(process.env["CORTEX_CONTROL_CENTER_URL"]
|
|
175
|
-
? {
|
|
176
|
-
controlCenter: {
|
|
177
|
-
url: process.env["CORTEX_CONTROL_CENTER_URL"],
|
|
178
|
-
apiKey: process.env["CORTEX_CONTROL_CENTER_API_KEY"]!,
|
|
179
|
-
},
|
|
180
|
-
}
|
|
181
|
-
: {}),`,
|
|
182
|
-
env: `# --- Control Center ----------------------------------------------------------
|
|
183
|
-
CORTEX_CONTROL_CENTER_URL=http://localhost:20000
|
|
184
|
-
CORTEX_CONTROL_CENTER_API_KEY=cc_live_...`,
|
|
185
|
-
secrets: "CORTEX_CONTROL_CENTER_API_KEY",
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
/** Catalogue order, which is the order every generated file lists them in. */
|
|
190
|
-
export const FEATURE_NAMES = Object.keys(FEATURES) as Feature[];
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# Domains
|
|
2
|
-
|
|
3
|
-
Domain definitions live here, one directory each, and are what `cortex graph seed` loads into Neo4j.
|
|
4
|
-
|
|
5
|
-
```
|
|
6
|
-
src/domains/
|
|
7
|
-
leaves/
|
|
8
|
-
index.ts defineDomain({ concepts, endpoints, rules, services })
|
|
9
|
-
concepts/leave.concept.ts
|
|
10
|
-
endpoints/listLeaves.endpoint.ts
|
|
11
|
-
services/leaveRequests.service.ts
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Export each domain from its `index.ts`, then list it under `knowledge.domains` in
|
|
15
|
-
`cortex.config.ts`. `cortex swagger sync` fills in the params, body, and response of every
|
|
16
|
-
`.endpoint.ts` from your Swagger spec.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { createCortex } from "@m6d/cortex-server";
|
|
2
|
-
import config from "./cortex.config";
|
|
3
|
-
|
|
4
|
-
const cortex = createCortex(config);
|
|
5
|
-
|
|
6
|
-
const server = await cortex.serve();
|
|
7
|
-
|
|
8
|
-
// Your own routes hang off the same Hono app the agent runtime serves from:
|
|
9
|
-
// server.app.get("/hello", function (c) {
|
|
10
|
-
// return c.text("hello");
|
|
11
|
-
// });
|
|
12
|
-
|
|
13
|
-
export default {
|
|
14
|
-
port: server.port,
|
|
15
|
-
fetch: server.fetch,
|
|
16
|
-
websocket: server.websocket,
|
|
17
|
-
idleTimeout: 0,
|
|
18
|
-
};
|
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import { isRecord } from "@cortex/contracts/json";
|
|
4
|
-
import type { Endpoint, HttpMethod, Obj } from "./types";
|
|
5
|
-
import { dedupe, deref, obj, pickContent, schemaType, toProp, toProps } from "./openapi-schema";
|
|
6
|
-
import { parseResponse } from "./parse-response";
|
|
7
|
-
import { withGeneratedBlock } from "./generated-block";
|
|
8
|
-
|
|
9
|
-
type ExtractEndpointsOptions = {
|
|
10
|
-
swaggerUrls: string[];
|
|
11
|
-
domainsDir: string;
|
|
12
|
-
write: boolean;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const METHODS = ["GET", "POST", "PUT", "DELETE"] as const satisfies readonly HttpMethod[];
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* ASP.NET conventions the extractor normalizes away so an `.endpoint.ts` path
|
|
19
|
-
* matches the spec whichever way the API was mounted: routes are keyed without
|
|
20
|
-
* the `/api` prefix (either side may carry it), and `JsonNode`-typed query
|
|
21
|
-
* parameters, which Swagger expands into `Root.Options`, `Root.Parent` and
|
|
22
|
-
* `Root.Root` dot-paths, collapse back to one object parameter.
|
|
23
|
-
*/
|
|
24
|
-
const API_PREFIX = /^\/api(?=\/|$)/i;
|
|
25
|
-
const JSON_NODE_MEMBER = /^(Options|Parent|Root)\b/;
|
|
26
|
-
|
|
27
|
-
function toCamelCase(s: string) {
|
|
28
|
-
return s
|
|
29
|
-
.split(".")
|
|
30
|
-
.map((seg) => seg.replace(/^[A-Z]/, (c) => c.toLowerCase()))
|
|
31
|
-
.join(".");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** `Root` for a dotted parameter like `Root.Options`; undefined for a plain one. */
|
|
35
|
-
function dottedRoot(name: string) {
|
|
36
|
-
const dot = name.indexOf(".");
|
|
37
|
-
return dot < 0 ? undefined : name.slice(0, dot);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function normalizePath(raw: string) {
|
|
41
|
-
let p = raw.startsWith("/") ? raw : `/${raw}`;
|
|
42
|
-
p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(API_PREFIX, "") || "/";
|
|
43
|
-
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function walkFiles(dir: string) {
|
|
47
|
-
if (!fs.existsSync(dir)) return [];
|
|
48
|
-
const out: string[] = [];
|
|
49
|
-
function walk(d: string) {
|
|
50
|
-
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
51
|
-
const p = path.join(d, e.name);
|
|
52
|
-
if (e.isDirectory()) walk(p);
|
|
53
|
-
else out.push(p);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
walk(dir);
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The `method` and `path` an endpoint file declares, read the way the server's
|
|
62
|
-
* own file walk reads them.
|
|
63
|
-
*/
|
|
64
|
-
export function declaredRoute(source: string) {
|
|
65
|
-
return {
|
|
66
|
-
method: source.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2],
|
|
67
|
-
path: source.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2],
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function endpointFiles(root: string) {
|
|
72
|
-
const out = new Map<string, string>();
|
|
73
|
-
for (const filePath of walkFiles(root).filter((p) => p.endsWith(".endpoint.ts"))) {
|
|
74
|
-
const { method, path: route } = declaredRoute(fs.readFileSync(filePath, "utf8"));
|
|
75
|
-
if (method && route) out.set(`${method}:${normalizePath(route)}`, filePath);
|
|
76
|
-
}
|
|
77
|
-
return out;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Path and query parameters, path-level first, with each `JsonNode` parameter
|
|
82
|
-
* collapsed back to one object.
|
|
83
|
-
*/
|
|
84
|
-
function extractParams(doc: unknown, pathItem: Obj, operation: Obj) {
|
|
85
|
-
const rawParams = [
|
|
86
|
-
...(Array.isArray(pathItem.parameters) ? (pathItem.parameters as unknown[]) : []),
|
|
87
|
-
...(Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []),
|
|
88
|
-
]
|
|
89
|
-
.map((param) => deref(doc, param))
|
|
90
|
-
.filter(
|
|
91
|
-
(param): param is Obj & { name: string; in: string } =>
|
|
92
|
-
typeof param.name === "string" &&
|
|
93
|
-
typeof param.in === "string" &&
|
|
94
|
-
["path", "query"].includes(param.in),
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const jsonNodeRoots = new Set<string>();
|
|
98
|
-
for (const { name } of rawParams) {
|
|
99
|
-
const root = dottedRoot(name);
|
|
100
|
-
if (root !== undefined && JSON_NODE_MEMBER.test(name.slice(root.length + 1))) {
|
|
101
|
-
jsonNodeRoots.add(root);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return dedupe(
|
|
106
|
-
rawParams
|
|
107
|
-
.filter((param) => {
|
|
108
|
-
const root = dottedRoot(param.name);
|
|
109
|
-
return root === undefined || !jsonNodeRoots.has(root);
|
|
110
|
-
})
|
|
111
|
-
.map((param) =>
|
|
112
|
-
toProp(
|
|
113
|
-
doc,
|
|
114
|
-
toCamelCase(param.name),
|
|
115
|
-
param.schema,
|
|
116
|
-
param.in === "path" || param.required === true,
|
|
117
|
-
),
|
|
118
|
-
)
|
|
119
|
-
.concat(
|
|
120
|
-
[...jsonNodeRoots].map((root) => ({
|
|
121
|
-
name: toCamelCase(root),
|
|
122
|
-
required: false,
|
|
123
|
-
type: "object",
|
|
124
|
-
})),
|
|
125
|
-
),
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** An object body contributes its own fields; anything else is one `items` or `body` field. */
|
|
130
|
-
function extractBody(doc: unknown, operation: Obj) {
|
|
131
|
-
const requestBody = deref(doc, operation.requestBody);
|
|
132
|
-
const content = pickContent(requestBody.content);
|
|
133
|
-
if (!content) return [];
|
|
134
|
-
const schema = deref(doc, content.schema);
|
|
135
|
-
const type = schemaType(schema);
|
|
136
|
-
if (type === "object") return toProps(doc, schema);
|
|
137
|
-
const name = type === "array" ? "items" : "body";
|
|
138
|
-
return [toProp(doc, name, schema, requestBody.required === true)];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function extractEndpoint(
|
|
142
|
-
doc: unknown,
|
|
143
|
-
route: string,
|
|
144
|
-
method: HttpMethod,
|
|
145
|
-
pathItem: Obj,
|
|
146
|
-
operation: Obj,
|
|
147
|
-
) {
|
|
148
|
-
const normalizedPath = normalizePath(route);
|
|
149
|
-
return {
|
|
150
|
-
key: `${method}:${normalizedPath}`,
|
|
151
|
-
method,
|
|
152
|
-
path: normalizedPath,
|
|
153
|
-
params: extractParams(doc, pathItem, operation),
|
|
154
|
-
body: extractBody(doc, operation),
|
|
155
|
-
...parseResponse(doc, operation.responses),
|
|
156
|
-
} satisfies Endpoint;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function parseSwaggerEndpoints(swagger: unknown) {
|
|
160
|
-
const out = new Map<string, Endpoint>();
|
|
161
|
-
const doc = obj(swagger);
|
|
162
|
-
for (const [route, pathItemIn] of Object.entries(obj(doc.paths))) {
|
|
163
|
-
const pathItem = obj(pathItemIn);
|
|
164
|
-
for (const method of METHODS) {
|
|
165
|
-
const operation = pathItem[method.toLowerCase()];
|
|
166
|
-
if (!isRecord(operation)) continue;
|
|
167
|
-
const endpoint = extractEndpoint(doc, route, method, pathItem, operation);
|
|
168
|
-
out.set(endpoint.key, endpoint);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function formatGeneratedFiles(filePaths: string[]) {
|
|
175
|
-
if (!filePaths.length) return;
|
|
176
|
-
try {
|
|
177
|
-
// Absolute paths: a relative domainsDir yields paths relative to the
|
|
178
|
-
// process cwd, which a prettier run elsewhere would resolve wrongly.
|
|
179
|
-
Bun.spawnSync(["bunx", "prettier", "--write", ...filePaths.map((p) => path.resolve(p))], {
|
|
180
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
181
|
-
});
|
|
182
|
-
} catch {
|
|
183
|
-
// Formatting is cosmetic; a missing prettier must not fail the run.
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function fetchJson(url: string) {
|
|
188
|
-
const response = await fetch(url, {
|
|
189
|
-
headers: { Accept: "application/json" },
|
|
190
|
-
});
|
|
191
|
-
if (!response.ok) {
|
|
192
|
-
throw new Error(`Swagger request failed (${response.status})`);
|
|
193
|
-
}
|
|
194
|
-
return response.json();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function resolveEndpoint(key: string, extracted: Map<string, Endpoint>) {
|
|
198
|
-
const [method, route] = key.split(":");
|
|
199
|
-
if (!method || !route) return undefined;
|
|
200
|
-
return (
|
|
201
|
-
extracted.get(key) ??
|
|
202
|
-
extracted.get(`${method}:${normalizePath(`/api${route}`)}`) ??
|
|
203
|
-
(route.startsWith("/api/")
|
|
204
|
-
? extracted.get(`${method}:${normalizePath(route.slice(4))}`)
|
|
205
|
-
: undefined)
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// The post-write prettier pass reformats generated blocks, so an exact string
|
|
210
|
-
// compare would report freshly synced files as drift forever. Compare modulo
|
|
211
|
-
// the axes prettier varies: quote style, whitespace, and trailing commas.
|
|
212
|
-
function sameModuloFormatting(a: string, b: string) {
|
|
213
|
-
function norm(s: string) {
|
|
214
|
-
return s
|
|
215
|
-
.replace(/['"]/g, '"')
|
|
216
|
-
.replace(/\s+/g, "")
|
|
217
|
-
.replace(/,(?=[}\])])/g, "");
|
|
218
|
-
}
|
|
219
|
-
return norm(a) === norm(b);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export async function extractEndpoints(options: ExtractEndpointsOptions) {
|
|
223
|
-
const { swaggerUrls, domainsDir, write } = options;
|
|
224
|
-
|
|
225
|
-
// Merge every document first: a file missing from one swagger doc may be
|
|
226
|
-
// matched by another. Last write wins on duplicate keys, so per-agent docs
|
|
227
|
-
// deliberately override the server-level one (`swaggerUrls` lists it first).
|
|
228
|
-
const extracted = new Map<string, Endpoint>();
|
|
229
|
-
for (const swaggerUrl of swaggerUrls) {
|
|
230
|
-
for (const [key, endpoint] of parseSwaggerEndpoints(await fetchJson(swaggerUrl))) {
|
|
231
|
-
extracted.set(key, endpoint);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
let matched = 0;
|
|
236
|
-
const missing: string[] = [];
|
|
237
|
-
const drift: string[] = [];
|
|
238
|
-
const updated: string[] = [];
|
|
239
|
-
const updatedFiles: string[] = [];
|
|
240
|
-
|
|
241
|
-
for (const [key, filePath] of endpointFiles(domainsDir).entries()) {
|
|
242
|
-
const relativePath = path.relative(domainsDir, filePath);
|
|
243
|
-
const endpoint = resolveEndpoint(key, extracted);
|
|
244
|
-
if (!endpoint) {
|
|
245
|
-
missing.push(relativePath);
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const current = fs.readFileSync(filePath, "utf8");
|
|
250
|
-
const next = withGeneratedBlock(current, endpoint);
|
|
251
|
-
if (next === null || next === current || sameModuloFormatting(next, current)) {
|
|
252
|
-
matched++;
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (!write) {
|
|
257
|
-
drift.push(relativePath);
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
fs.writeFileSync(filePath, next);
|
|
262
|
-
updatedFiles.push(filePath);
|
|
263
|
-
updated.push(relativePath);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (write) formatGeneratedFiles(updatedFiles);
|
|
267
|
-
|
|
268
|
-
return { matched, missing, drift, updated };
|
|
269
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Splicing the generated block into an endpoint definition the developer also edits
|
|
3
|
-
* by hand. The markers are the contract: everything between them belongs to the
|
|
4
|
-
* generator, everything outside is the developer's.
|
|
5
|
-
*/
|
|
6
|
-
import type { Endpoint, Prop } from "./types";
|
|
7
|
-
|
|
8
|
-
const AUTO_START = "// @auto-generated-start";
|
|
9
|
-
const AUTO_END = "// @auto-generated-end";
|
|
10
|
-
|
|
11
|
-
function serializeProps(props: Prop[], depth: number) {
|
|
12
|
-
return props
|
|
13
|
-
.map((p) => {
|
|
14
|
-
const indent = " ".repeat(depth);
|
|
15
|
-
const childIndent = " ".repeat(depth + 1);
|
|
16
|
-
const typeLiteral = p.type.includes("'")
|
|
17
|
-
? `"${p.type.replace(/"/g, '\\"')}"`
|
|
18
|
-
: `'${p.type}'`;
|
|
19
|
-
|
|
20
|
-
const parts = [`name: '${p.name}'`, `required: ${p.required}`, `type: ${typeLiteral}`];
|
|
21
|
-
if (p.isArray) parts.push("isArray: true");
|
|
22
|
-
if (p.properties?.length)
|
|
23
|
-
parts.push(
|
|
24
|
-
`properties: [\n${serializeProps(p.properties, depth + 1)}\n${childIndent}]`,
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
return `${indent} { ${parts.join(", ")} },`;
|
|
28
|
-
})
|
|
29
|
-
.join("\n");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function blockFor(endpoint: Endpoint) {
|
|
33
|
-
return ` autoGenerated: {
|
|
34
|
-
params: [
|
|
35
|
-
${serializeProps(endpoint.params, 2)}
|
|
36
|
-
] as const,
|
|
37
|
-
body: [
|
|
38
|
-
${serializeProps(endpoint.body, 2)}
|
|
39
|
-
] as const,
|
|
40
|
-
response: [
|
|
41
|
-
${serializeProps(endpoint.response, 2)}
|
|
42
|
-
] as const,
|
|
43
|
-
successStatus: ${endpoint.successStatus},
|
|
44
|
-
errorStatuses: [${endpoint.errorStatuses.join(", ")}],
|
|
45
|
-
responseKind: '${endpoint.responseKind}',
|
|
46
|
-
} as const,`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Replaces the marked region when it exists, otherwise inserts one just inside the
|
|
51
|
-
* closing `});`. Returns null when there is nowhere to put it, which means the file
|
|
52
|
-
* is not shaped like an endpoint definition.
|
|
53
|
-
*/
|
|
54
|
-
export function withGeneratedBlock(current: string, endpoint: Endpoint) {
|
|
55
|
-
const start = current.indexOf(AUTO_START);
|
|
56
|
-
const end = current.indexOf(AUTO_END);
|
|
57
|
-
|
|
58
|
-
if (start >= 0 && end > start) {
|
|
59
|
-
return (
|
|
60
|
-
current.slice(0, start + AUTO_START.length) +
|
|
61
|
-
`\n${blockFor(endpoint)}\n ` +
|
|
62
|
-
current.slice(end)
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const closingIndex = current.lastIndexOf("});");
|
|
67
|
-
if (closingIndex < 0) return null;
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
current.slice(0, closingIndex) +
|
|
71
|
-
`\n ${AUTO_START}\n${blockFor(endpoint)}\n ${AUTO_END}\n});` +
|
|
72
|
-
current.slice(closingIndex + 3)
|
|
73
|
-
);
|
|
74
|
-
}
|