@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.
Files changed (76) hide show
  1. package/README.md +193 -128
  2. package/package.json +16 -5
  3. package/src/cli.ts +19 -12
  4. package/src/commands/apply.ts +186 -0
  5. package/src/commands/{cc.ts → management.ts} +173 -112
  6. package/src/commands/new.ts +83 -84
  7. package/src/commands/setup.ts +610 -0
  8. package/src/contracts/charts/chart-spec.ts +1 -1
  9. package/src/contracts/client-tools/index.ts +3 -3
  10. package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
  11. package/src/contracts/datasets/index.ts +3 -0
  12. package/src/contracts/egress-policy/index.ts +16 -0
  13. package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
  14. package/src/contracts/{egress → egress-policy}/url.ts +22 -9
  15. package/src/contracts/errors/index.ts +38 -0
  16. package/src/contracts/json/index.ts +5 -0
  17. package/src/contracts/manifest/index.ts +973 -0
  18. package/src/contracts/manifest-file/index.ts +16 -0
  19. package/src/contracts/markdown/index.ts +1 -1
  20. package/src/contracts/pattern/index.ts +276 -0
  21. package/src/contracts/server/index.ts +349 -0
  22. package/src/contracts/server/reserved-names.fixtures.json +18 -0
  23. package/src/contracts/triggers/index.ts +112 -0
  24. package/src/contracts/wire/index.ts +67 -33
  25. package/src/{cc → management}/client.ts +33 -7
  26. package/src/{cc → management}/flags.ts +2 -1
  27. package/src/{cc → management}/profiles.ts +10 -8
  28. package/src/scaffold/files.ts +448 -284
  29. package/src/scaffold/ports.ts +12 -24
  30. package/src/scaffold/templates/cspell.json.txt +41 -0
  31. package/src/scaffold/templates/datasets.ts.txt +52 -0
  32. package/src/scaffold/templates/dockerignore.txt +6 -0
  33. package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
  34. package/src/scaffold/templates/gitignore.txt +1 -1
  35. package/src/scaffold/templates/server.ts.txt +40 -0
  36. package/src/scaffold/templates/setup.ts.txt +44 -0
  37. package/src/ui/prompts.ts +24 -38
  38. package/src/ui/report.ts +5 -15
  39. package/tsconfig.json +2 -3
  40. package/src/commands/graph.ts +0 -228
  41. package/src/commands/swagger.ts +0 -247
  42. package/src/config/load.ts +0 -122
  43. package/src/config/validate.ts +0 -46
  44. package/src/contracts/credentials/index.ts +0 -183
  45. package/src/contracts/egress/index.ts +0 -120
  46. package/src/contracts/graph/clients/embed.ts +0 -62
  47. package/src/contracts/graph/clients/neo4j.ts +0 -85
  48. package/src/contracts/graph/helpers.ts +0 -55
  49. package/src/contracts/graph/index.ts +0 -30
  50. package/src/contracts/graph/schema.ts +0 -65
  51. package/src/contracts/graph/types.ts +0 -125
  52. package/src/contracts/rich-text/index.ts +0 -220
  53. package/src/contracts/runtime/api.ts +0 -345
  54. package/src/contracts/runtime/auth.ts +0 -41
  55. package/src/contracts/runtime/grammar.ts +0 -169
  56. package/src/contracts/runtime/index.ts +0 -15
  57. package/src/contracts/runtime/models.ts +0 -50
  58. package/src/contracts/runtime/signature.ts +0 -255
  59. package/src/contracts/runtime/sync.ts +0 -196
  60. package/src/contracts/runtime/triggers.ts +0 -267
  61. package/src/graph/expand-domains.ts +0 -286
  62. package/src/graph/generate-cypher.ts +0 -201
  63. package/src/graph/seed.ts +0 -199
  64. package/src/graph/validate.ts +0 -78
  65. package/src/scaffold/features.ts +0 -190
  66. package/src/scaffold/templates/domains-README.md.txt +0 -16
  67. package/src/scaffold/templates/index.ts.txt +0 -18
  68. package/src/swagger/extract-endpoints.ts +0 -269
  69. package/src/swagger/generated-block.ts +0 -74
  70. package/src/swagger/openapi-schema.ts +0 -168
  71. package/src/swagger/parse-response.ts +0 -88
  72. package/src/swagger/types.ts +0 -25
  73. /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
  74. /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
  75. /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
  76. /package/src/{cc → management}/output.ts +0 -0
@@ -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
- }
@@ -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
- }