@m6d/cortex-cli 0.2.0 → 0.4.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/package.json +1 -1
- package/src/cc/client.ts +58 -90
- package/src/cc/flags.ts +8 -11
- package/src/cc/output.ts +3 -6
- package/src/cc/profiles.ts +8 -11
- package/src/commands/cc.ts +18 -11
- package/src/commands/graph.ts +17 -18
- package/src/commands/new.ts +5 -6
- package/src/commands/swagger.ts +35 -49
- package/src/config/load.ts +6 -6
- package/src/config/validate.ts +2 -2
- package/src/contracts/charts/chart-spec.ts +9 -6
- package/src/contracts/credentials/index.ts +183 -0
- package/src/contracts/egress/index.ts +8 -62
- package/src/contracts/egress/scrub.ts +3 -1
- package/src/contracts/egress/url.ts +109 -0
- package/src/contracts/graph/helpers.ts +8 -10
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/runtime/api.ts +2 -2
- package/src/contracts/runtime/dataset-sql.ts +49 -70
- package/src/contracts/runtime/datasets.ts +33 -43
- package/src/contracts/runtime/grammar.ts +12 -11
- package/src/contracts/runtime/signature.ts +7 -8
- package/src/contracts/runtime/sync.ts +13 -2
- package/src/contracts/runtime/triggers.ts +4 -1
- package/src/contracts/wire/index.ts +12 -5
- package/src/graph/expand-domains.ts +7 -9
- package/src/graph/generate-cypher.ts +1 -1
- package/src/graph/seed.ts +6 -14
- package/src/scaffold/files.ts +23 -28
- package/src/swagger/extract-endpoints.ts +79 -76
- package/src/swagger/openapi-schema.ts +21 -14
- package/src/swagger/parse-response.ts +1 -1
- package/src/ui/report.ts +1 -1
|
@@ -32,9 +32,11 @@ export const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
|
32
32
|
export const MAX_ATTACHMENTS_PER_MESSAGE = 5;
|
|
33
33
|
|
|
34
34
|
export const ATTACHMENT_STATUSES = ["pending", "described", "failed", "skipped"] as const;
|
|
35
|
+
export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number];
|
|
35
36
|
|
|
36
37
|
/** Who put the file in the thread: the user through the composer, or a tool mid-turn. */
|
|
37
38
|
export const ATTACHMENT_ORIGINS = ["upload", "tool"] as const;
|
|
39
|
+
export type AttachmentOrigin = (typeof ATTACHMENT_ORIGINS)[number];
|
|
38
40
|
|
|
39
41
|
export function checkAttachmentPolicy(contentType: string, sizeBytes: number) {
|
|
40
42
|
if (!ATTACHMENT_MIME_TYPES.some((type) => type === contentType)) return "type";
|
|
@@ -42,10 +44,6 @@ export function checkAttachmentPolicy(contentType: string, sizeBytes: number) {
|
|
|
42
44
|
return "ok";
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number];
|
|
46
|
-
|
|
47
|
-
export type AttachmentOrigin = (typeof ATTACHMENT_ORIGINS)[number];
|
|
48
|
-
|
|
49
47
|
export type AttachmentSummary = {
|
|
50
48
|
id: string;
|
|
51
49
|
filename: string;
|
|
@@ -190,7 +188,7 @@ export function toUiMessage<TPart>(message: CortexMessage<TPart>) {
|
|
|
190
188
|
* parked client tool calls on a continuation request. The server owns the
|
|
191
189
|
* transcript (a continuation sends `messages: []`), so the answered assistant
|
|
192
190
|
* message itself never crosses the wire — the SDK's park-boundary snapshot
|
|
193
|
-
* does not preserve its id, and round-tripping it
|
|
191
|
+
* does not preserve its id, and round-tripping it would duplicate the stored
|
|
194
192
|
* message.
|
|
195
193
|
*/
|
|
196
194
|
export const CLIENT_TOOL_ANSWERS_KEY = "clientToolAnswers";
|
|
@@ -206,6 +204,15 @@ export type ClientToolAnswer = {
|
|
|
206
204
|
state: "complete" | "error" | "approved" | "denied";
|
|
207
205
|
};
|
|
208
206
|
|
|
207
|
+
/**
|
|
208
|
+
* The id a parked tool call's decision is filed under: the one on its
|
|
209
|
+
* approval record, or `approval_<toolCallId>`, which is how the SDK names
|
|
210
|
+
* every approval it requests, for a part that carries no record.
|
|
211
|
+
*/
|
|
212
|
+
export function approvalIdOf(part: { id: string; approval?: { id: string } }) {
|
|
213
|
+
return part.approval?.id ?? `approval_${part.id}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
209
216
|
/**
|
|
210
217
|
* Response of `POST /chat/:threadId/tools/:toolCallId/initiate`. `embed:
|
|
211
218
|
* false` means the pending call is not a CC client tool — the widget
|
|
@@ -84,12 +84,12 @@ export function expandDomains(domains: Record<string, DomainDef>) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function createOwnershipMaps(domains: Record<string, DomainDef>) {
|
|
87
|
-
const ownership
|
|
87
|
+
const ownership = {
|
|
88
88
|
concepts: new WeakMap<ConceptDef, string>(),
|
|
89
89
|
endpoints: new WeakMap<EndpointDef, string>(),
|
|
90
90
|
services: new WeakMap<ServiceDef, string>(),
|
|
91
91
|
rules: new WeakMap<RuleDef, string>(),
|
|
92
|
-
};
|
|
92
|
+
} satisfies OwnershipMaps;
|
|
93
93
|
|
|
94
94
|
for (const [domainKey, domain] of Object.entries(domains)) {
|
|
95
95
|
for (const concept of domain.concepts ?? []) {
|
|
@@ -138,7 +138,7 @@ function includeConcept(options: {
|
|
|
138
138
|
const { domainKey, concept, ownership, collections, pendingConcepts, pendingEndpoints } =
|
|
139
139
|
options;
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
includeRules(domainKey, concept.governedBy ?? [], ownership, collections);
|
|
142
142
|
|
|
143
143
|
if (shouldAttach(ownership.concepts, concept, domainKey)) {
|
|
144
144
|
pushUnique(collections.concepts, concept);
|
|
@@ -182,7 +182,7 @@ function includeEndpoint(options: {
|
|
|
182
182
|
pushUnique(collections.endpoints, endpoint);
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
includeRules(domainKey, endpoint.governedBy ?? [], ownership, collections);
|
|
186
186
|
|
|
187
187
|
// An endpoint reaches concepts three ways and other endpoints one way, but the
|
|
188
188
|
// handling is identical: take what this domain owns, and queue whatever is new.
|
|
@@ -240,7 +240,7 @@ function includeService(options: {
|
|
|
240
240
|
pushUnique(collections.services, service);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
includeRules(domainKey, service.governedBy ?? [], ownership, collections);
|
|
244
244
|
|
|
245
245
|
if (
|
|
246
246
|
shouldAttach(ownership.concepts, service.belongsTo, domainKey) &&
|
|
@@ -250,7 +250,7 @@ function includeService(options: {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
function
|
|
253
|
+
function includeRules(
|
|
254
254
|
domainKey: string,
|
|
255
255
|
rules: readonly RuleDef[],
|
|
256
256
|
ownership: OwnershipMaps,
|
|
@@ -281,8 +281,6 @@ function referencesConcept(endpoint: EndpointDef, concept: ConceptDef) {
|
|
|
281
281
|
return (
|
|
282
282
|
endpoint.queries?.includes(concept) ||
|
|
283
283
|
endpoint.mutates?.includes(concept) ||
|
|
284
|
-
endpoint.returns?.some(
|
|
285
|
-
return returned.concept === concept;
|
|
286
|
-
})
|
|
284
|
+
endpoint.returns?.some((returned) => returned.concept === concept)
|
|
287
285
|
);
|
|
288
286
|
}
|
package/src/graph/seed.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "@cortex/contracts/graph";
|
|
13
13
|
|
|
14
14
|
import { expandDomains } from "./expand-domains";
|
|
15
|
-
import { generateCypher
|
|
15
|
+
import { generateCypher } from "./generate-cypher";
|
|
16
16
|
import { validateDomain } from "./validate";
|
|
17
17
|
|
|
18
18
|
type SeedEmbedding = {
|
|
@@ -95,7 +95,6 @@ async function generateEmbeddings(
|
|
|
95
95
|
embeddingConfig: SeedEmbedding,
|
|
96
96
|
s: GraphSchema,
|
|
97
97
|
) {
|
|
98
|
-
// 1. Create the vector index (idempotent)
|
|
99
98
|
await client.query(
|
|
100
99
|
`CREATE VECTOR INDEX ${s.conceptEmbeddingsIndex} IF NOT EXISTS
|
|
101
100
|
FOR (c:${s.label.concept}) ON (c.${s.prop.embedding})
|
|
@@ -105,7 +104,6 @@ async function generateEmbeddings(
|
|
|
105
104
|
}}`,
|
|
106
105
|
);
|
|
107
106
|
|
|
108
|
-
// 2. Fetch all concepts
|
|
109
107
|
const concepts = (await client.query(
|
|
110
108
|
`MATCH (c:${s.label.concept}) RETURN c.${s.prop.name} AS name, c.${s.prop.description} AS description`,
|
|
111
109
|
)) as { name: string; description: string }[];
|
|
@@ -114,10 +112,8 @@ async function generateEmbeddings(
|
|
|
114
112
|
return { concepts: 0, dimension: embeddingConfig.dimension };
|
|
115
113
|
}
|
|
116
114
|
|
|
117
|
-
// 3. Generate embeddings for every concept in one request
|
|
118
115
|
const embeddings = await embeddingConfig.embed(concepts.map((c) => c.description));
|
|
119
116
|
|
|
120
|
-
// 4. Store the embeddings in the graph
|
|
121
117
|
await Promise.all(
|
|
122
118
|
concepts.map((concept, idx) =>
|
|
123
119
|
client.query(
|
|
@@ -134,7 +130,6 @@ async function generateEmbeddings(
|
|
|
134
130
|
export async function seedGraph(config: SeedGraphConfig, domains: Record<string, DomainDef>) {
|
|
135
131
|
const expandedDomains = expandDomains(domains);
|
|
136
132
|
|
|
137
|
-
// Validate all domains
|
|
138
133
|
const validationErrors = Object.entries(expandedDomains).flatMap(([name, domain]) =>
|
|
139
134
|
validateDomain(domain).map((err) => `[${name}] ${err}`),
|
|
140
135
|
);
|
|
@@ -148,11 +143,9 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
|
|
|
148
143
|
const client = config.neo4j;
|
|
149
144
|
const targets = Object.entries(expandedDomains);
|
|
150
145
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
generateCypher(data, config.schema),
|
|
155
|
-
]);
|
|
146
|
+
const allCypher = targets.map(
|
|
147
|
+
([name, data]) => [name, generateCypher(data, config.schema)] as const,
|
|
148
|
+
);
|
|
156
149
|
|
|
157
150
|
// Stamped before anything else is written, so an interrupted seed still
|
|
158
151
|
// leaves the graph labelled with the schema it was written against.
|
|
@@ -169,10 +162,9 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
|
|
|
169
162
|
const allEdges = allCypher.flatMap(([domain, c]) => tag(domain, c.edges));
|
|
170
163
|
const edges = await runPhase(client, allEdges);
|
|
171
164
|
|
|
172
|
-
// Embeddings for every concept.
|
|
173
165
|
const embeddings = await generateEmbeddings(client, config.embedding, config.schema);
|
|
174
166
|
|
|
175
|
-
const domainResults
|
|
167
|
+
const domainResults = Object.fromEntries(
|
|
176
168
|
targets.map(([name]) => {
|
|
177
169
|
const failures = [
|
|
178
170
|
...(nodes.failures.get(name) ?? []),
|
|
@@ -185,7 +177,7 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
|
|
|
185
177
|
edges: edges.succeeded.get(name) ?? 0,
|
|
186
178
|
failed: failures.length,
|
|
187
179
|
failures,
|
|
188
|
-
},
|
|
180
|
+
} satisfies DomainSeedResult,
|
|
189
181
|
];
|
|
190
182
|
}),
|
|
191
183
|
);
|
package/src/scaffold/files.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type Database = "postgres" | "mssql";
|
|
|
27
27
|
// sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
|
|
28
28
|
// them to that.
|
|
29
29
|
export const DEPENDENCIES = {
|
|
30
|
-
"@m6d/cortex-server": "~0.
|
|
30
|
+
"@m6d/cortex-server": "~0.4.0",
|
|
31
31
|
"@tanstack/ai": "^0.55.0",
|
|
32
32
|
"@tanstack/ai-openai": "^0.22.8",
|
|
33
33
|
hono: "^4.12.3",
|
|
@@ -50,10 +50,10 @@ const DEV_DEPENDENCIES = {
|
|
|
50
50
|
|
|
51
51
|
// Postgres is the one we run, so it gets a scaffolded port. MSSQL is normally an
|
|
52
52
|
// existing instance nobody moved, which is why it keeps 1433 and has no service.
|
|
53
|
-
const CONNECTION_STRING
|
|
53
|
+
const CONNECTION_STRING = {
|
|
54
54
|
postgres: "postgres://postgres:postgres@localhost:{{port.postgres}}/cortex",
|
|
55
55
|
mssql: "Server=localhost,1433;Database=cortex;User Id=sa;Password=your-password;Encrypt=false",
|
|
56
|
-
}
|
|
56
|
+
} satisfies Record<Database, string>;
|
|
57
57
|
|
|
58
58
|
function packageJson(name: string) {
|
|
59
59
|
return `${JSON.stringify(
|
|
@@ -89,7 +89,7 @@ function packageJson(name: string) {
|
|
|
89
89
|
* defaulted, and the other two are what you reach for once a real problem shows
|
|
90
90
|
* up — noisy graph retrieval, a cheap-model path — not on day one.
|
|
91
91
|
*/
|
|
92
|
-
const NEVER_SCAFFOLDED
|
|
92
|
+
const NEVER_SCAFFOLDED = [
|
|
93
93
|
[
|
|
94
94
|
"A cheaper, faster model for auxiliary passes. Same shape as `model`.",
|
|
95
95
|
`fastModel: {
|
|
@@ -112,7 +112,7 @@ const NEVER_SCAFFOLDED: [note: string, config: string][] = [
|
|
|
112
112
|
recentMessagesToKeep: 6,
|
|
113
113
|
},`,
|
|
114
114
|
],
|
|
115
|
-
];
|
|
115
|
+
] satisfies [note: string, config: string][];
|
|
116
116
|
|
|
117
117
|
/** Fragments are authored at zero indentation and placed by these two. */
|
|
118
118
|
function indented(block: string) {
|
|
@@ -132,9 +132,7 @@ function cortexConfig(database: Database, features: Feature[]) {
|
|
|
132
132
|
const fragment = (feature: Feature) =>
|
|
133
133
|
block(FEATURES[feature].note, FEATURES[feature].config, features.includes(feature));
|
|
134
134
|
|
|
135
|
-
const on =
|
|
136
|
-
.map(fragment)
|
|
137
|
-
.join("");
|
|
135
|
+
const on = features.map(fragment).join("");
|
|
138
136
|
const off = FEATURE_NAMES.filter((feature) => !features.includes(feature))
|
|
139
137
|
.map(fragment)
|
|
140
138
|
.join("");
|
|
@@ -167,9 +165,10 @@ ${on === "" ? "" : `${on}\n`} agents: {
|
|
|
167
165
|
assistant: defineAgent({
|
|
168
166
|
systemPrompt: "You are a helpful assistant.",
|
|
169
167
|
// Applications that serve datasets for grouped questions (see the
|
|
170
|
-
// README's "Dataset hosts"): the host applies its own access rules
|
|
171
|
-
// the
|
|
172
|
-
//
|
|
168
|
+
// README's "Dataset hosts"): the host applies its own access rules
|
|
169
|
+
// under the run's token the list below forwards, the agent only
|
|
170
|
+
// picks which datasets to expose and pin.
|
|
171
|
+
// datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", credentials: [{ header: "X-End-User-Token", credential: { kind: "run-token" } }], pinned: ["orders"] }],
|
|
173
172
|
}),
|
|
174
173
|
},
|
|
175
174
|
|
|
@@ -191,9 +190,7 @@ function hasServices(database: Database, features: Feature[]) {
|
|
|
191
190
|
}
|
|
192
191
|
|
|
193
192
|
function env(database: Database, features: Feature[]) {
|
|
194
|
-
const blocks =
|
|
195
|
-
(feature) => `\n${FEATURES[feature].env}\n`,
|
|
196
|
-
);
|
|
193
|
+
const blocks = features.map((feature) => `\n${FEATURES[feature].env}\n`);
|
|
197
194
|
|
|
198
195
|
// Only the URLs pointing at scaffolded services carry a chosen port, and those
|
|
199
196
|
// are exactly the ones docker-compose.yml publishes.
|
|
@@ -247,7 +244,7 @@ function dockerCompose(name: string, database: Database, features: Feature[]) {
|
|
|
247
244
|
volumes.push("postgres-data");
|
|
248
245
|
}
|
|
249
246
|
|
|
250
|
-
for (const feature of
|
|
247
|
+
for (const feature of features) {
|
|
251
248
|
const { compose, volume } = FEATURES[feature];
|
|
252
249
|
if (compose === undefined) continue;
|
|
253
250
|
services.push(compose);
|
|
@@ -278,12 +275,6 @@ ${services.join("\n\n")}
|
|
|
278
275
|
${volumeBlock}`;
|
|
279
276
|
}
|
|
280
277
|
|
|
281
|
-
/**
|
|
282
|
-
* `--knowledge` scaffolds the directory `domainsDir` defaults to, which is what
|
|
283
|
-
* makes the convention true by construction: `cortex swagger sync` finds it with
|
|
284
|
-
* no config key.
|
|
285
|
-
*/
|
|
286
|
-
|
|
287
278
|
function cspellConfig() {
|
|
288
279
|
return `${JSON.stringify(
|
|
289
280
|
{
|
|
@@ -336,12 +327,10 @@ function cspellConfig() {
|
|
|
336
327
|
}
|
|
337
328
|
|
|
338
329
|
function readme(name: string, database: Database, features: Feature[], compose: boolean) {
|
|
339
|
-
const enabled = FEATURE_NAMES.filter((feature) => features.includes(feature));
|
|
340
|
-
|
|
341
330
|
return `# ${name}
|
|
342
331
|
|
|
343
332
|
A Cortex server: one agent, a ${database} database, and an OpenAI-compatible model.${
|
|
344
|
-
|
|
333
|
+
features.length === 0 ? "" : ` Also on: ${features.join(", ")}.`
|
|
345
334
|
}
|
|
346
335
|
|
|
347
336
|
## Getting started
|
|
@@ -381,12 +370,16 @@ pre-commit hook or CI job should run. Each is also available on its own: \`forma
|
|
|
381
370
|
`;
|
|
382
371
|
}
|
|
383
372
|
|
|
384
|
-
/**
|
|
385
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Relative path → contents. Nothing here touches the filesystem. Every
|
|
375
|
+
* generator lists features in catalogue order, whatever order they came in.
|
|
376
|
+
*/
|
|
377
|
+
export function scaffoldFiles(name: string, database: Database, selected: Feature[], ports: Ports) {
|
|
378
|
+
const features = FEATURE_NAMES.filter((feature) => selected.includes(feature));
|
|
386
379
|
const envFile = env(database, features);
|
|
387
380
|
const compose = dockerCompose(name, database, features);
|
|
388
381
|
|
|
389
|
-
const files
|
|
382
|
+
const files = {
|
|
390
383
|
"cortex.config.ts": cortexConfig(database, features),
|
|
391
384
|
"index.ts": indexTs,
|
|
392
385
|
"package.json": packageJson(name),
|
|
@@ -400,8 +393,10 @@ export function scaffoldFiles(name: string, database: Database, features: Featur
|
|
|
400
393
|
"cspell.json": cspellConfig(),
|
|
401
394
|
".fallowrc.jsonc": fallowrc,
|
|
402
395
|
...(compose === undefined ? {} : { "docker-compose.yml": compose }),
|
|
396
|
+
// The directory `domainsDir` defaults to, which makes the convention true
|
|
397
|
+
// by construction: `cortex swagger sync` finds it with no config key.
|
|
403
398
|
...(features.includes("knowledge") ? { "src/domains/README.md": domainsReadme } : {}),
|
|
404
|
-
}
|
|
399
|
+
} satisfies Record<string, string>;
|
|
405
400
|
|
|
406
401
|
// One pass at the end, over everything: no generator holds a port number of
|
|
407
402
|
// its own, so compose, .env and the README cannot disagree about one.
|
|
@@ -1,31 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
deref,
|
|
7
|
-
isObj,
|
|
8
|
-
obj,
|
|
9
|
-
pickContent,
|
|
10
|
-
schemaType,
|
|
11
|
-
toProp,
|
|
12
|
-
toProps,
|
|
13
|
-
} from "./openapi-schema";
|
|
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";
|
|
14
6
|
import { parseResponse } from "./parse-response";
|
|
15
7
|
import { withGeneratedBlock } from "./generated-block";
|
|
16
8
|
|
|
17
9
|
type ExtractEndpointsOptions = {
|
|
18
10
|
swaggerUrls: string[];
|
|
19
11
|
domainsDir: string;
|
|
20
|
-
write
|
|
12
|
+
write: boolean;
|
|
21
13
|
};
|
|
22
14
|
|
|
23
|
-
const METHODS
|
|
24
|
-
["get", "GET"],
|
|
25
|
-
["post", "POST"],
|
|
26
|
-
["put", "PUT"],
|
|
27
|
-
["delete", "DELETE"],
|
|
28
|
-
];
|
|
15
|
+
const METHODS = ["GET", "POST", "PUT", "DELETE"] as const satisfies readonly HttpMethod[];
|
|
29
16
|
|
|
30
17
|
/**
|
|
31
18
|
* ASP.NET conventions the extractor normalizes away so an `.endpoint.ts` path
|
|
@@ -44,78 +31,89 @@ function toCamelCase(s: string) {
|
|
|
44
31
|
.join(".");
|
|
45
32
|
}
|
|
46
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
|
+
|
|
47
40
|
function normalizePath(raw: string) {
|
|
48
41
|
let p = raw.startsWith("/") ? raw : `/${raw}`;
|
|
49
42
|
p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(API_PREFIX, "") || "/";
|
|
50
43
|
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
function walkFiles(dir: string
|
|
46
|
+
function walkFiles(dir: string) {
|
|
54
47
|
if (!fs.existsSync(dir)) return [];
|
|
55
48
|
const out: string[] = [];
|
|
56
49
|
function walk(d: string) {
|
|
57
50
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
58
51
|
const p = path.join(d, e.name);
|
|
59
52
|
if (e.isDirectory()) walk(p);
|
|
60
|
-
else
|
|
53
|
+
else out.push(p);
|
|
61
54
|
}
|
|
62
55
|
}
|
|
63
56
|
walk(dir);
|
|
64
57
|
return out;
|
|
65
58
|
}
|
|
66
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
|
+
|
|
67
71
|
function endpointFiles(root: string) {
|
|
68
72
|
const out = new Map<string, string>();
|
|
69
|
-
for (const filePath of walkFiles(root
|
|
70
|
-
const
|
|
71
|
-
const method = c.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2];
|
|
72
|
-
const route = c.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2];
|
|
73
|
+
for (const filePath of walkFiles(root).filter((p) => p.endsWith(".endpoint.ts"))) {
|
|
74
|
+
const { method, path: route } = declaredRoute(fs.readFileSync(filePath, "utf8"));
|
|
73
75
|
if (method && route) out.set(`${method}:${normalizePath(route)}`, filePath);
|
|
74
76
|
}
|
|
75
77
|
return out;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
operationIn: unknown,
|
|
84
|
-
) {
|
|
85
|
-
const pathItem = obj(pathItemIn);
|
|
86
|
-
const operation = obj(operationIn);
|
|
87
|
-
|
|
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) {
|
|
88
85
|
const rawParams = [
|
|
89
86
|
...(Array.isArray(pathItem.parameters) ? (pathItem.parameters as unknown[]) : []),
|
|
90
87
|
...(Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []),
|
|
91
88
|
]
|
|
92
|
-
.map((
|
|
93
|
-
.filter(
|
|
94
|
-
|
|
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
|
+
);
|
|
95
96
|
|
|
96
97
|
const jsonNodeRoots = new Set<string>();
|
|
97
|
-
for (const
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (JSON_NODE_MEMBER.test(rest)) jsonNodeRoots.add(root);
|
|
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
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
return dedupe(
|
|
106
106
|
rawParams
|
|
107
|
-
.filter((
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const root = name.split(".")[0]!;
|
|
111
|
-
return !jsonNodeRoots.has(root);
|
|
107
|
+
.filter((param) => {
|
|
108
|
+
const root = dottedRoot(param.name);
|
|
109
|
+
return root === undefined || !jsonNodeRoots.has(root);
|
|
112
110
|
})
|
|
113
|
-
.map((
|
|
111
|
+
.map((param) =>
|
|
114
112
|
toProp(
|
|
115
113
|
doc,
|
|
116
|
-
toCamelCase(
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
toCamelCase(param.name),
|
|
115
|
+
param.schema,
|
|
116
|
+
param.in === "path" || param.required === true,
|
|
119
117
|
),
|
|
120
118
|
)
|
|
121
119
|
.concat(
|
|
@@ -126,41 +124,46 @@ function extractEndpoint(
|
|
|
126
124
|
})),
|
|
127
125
|
),
|
|
128
126
|
);
|
|
127
|
+
}
|
|
129
128
|
|
|
130
|
-
|
|
129
|
+
/** An object body contributes its own fields; anything else is one `items` or `body` field. */
|
|
130
|
+
function extractBody(doc: unknown, operation: Obj) {
|
|
131
131
|
const requestBody = deref(doc, operation.requestBody);
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
140
|
|
|
141
|
-
|
|
141
|
+
function extractEndpoint(
|
|
142
|
+
doc: unknown,
|
|
143
|
+
route: string,
|
|
144
|
+
method: HttpMethod,
|
|
145
|
+
pathItem: Obj,
|
|
146
|
+
operation: Obj,
|
|
147
|
+
) {
|
|
142
148
|
const normalizedPath = normalizePath(route);
|
|
143
|
-
|
|
144
149
|
return {
|
|
145
150
|
key: `${method}:${normalizedPath}`,
|
|
146
151
|
method,
|
|
147
152
|
path: normalizedPath,
|
|
148
|
-
params,
|
|
149
|
-
body:
|
|
150
|
-
|
|
151
|
-
responseKind: parsed.responseKind,
|
|
152
|
-
successStatus: parsed.successStatus,
|
|
153
|
-
errorStatuses: parsed.errorStatuses,
|
|
153
|
+
params: extractParams(doc, pathItem, operation),
|
|
154
|
+
body: extractBody(doc, operation),
|
|
155
|
+
...parseResponse(doc, operation.responses),
|
|
154
156
|
} satisfies Endpoint;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function parseSwaggerEndpoints(swagger: unknown) {
|
|
158
160
|
const out = new Map<string, Endpoint>();
|
|
159
161
|
const doc = obj(swagger);
|
|
160
|
-
for (const [route,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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;
|
|
164
167
|
const endpoint = extractEndpoint(doc, route, method, pathItem, operation);
|
|
165
168
|
out.set(endpoint.key, endpoint);
|
|
166
169
|
}
|
|
@@ -217,11 +220,11 @@ function sameModuloFormatting(a: string, b: string) {
|
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
export async function extractEndpoints(options: ExtractEndpointsOptions) {
|
|
220
|
-
const { swaggerUrls, domainsDir, write
|
|
223
|
+
const { swaggerUrls, domainsDir, write } = options;
|
|
221
224
|
|
|
222
225
|
// Merge every document first: a file missing from one swagger doc may be
|
|
223
226
|
// matched by another. Last write wins on duplicate keys, so per-agent docs
|
|
224
|
-
// deliberately override the server-level one (
|
|
227
|
+
// deliberately override the server-level one (`swaggerUrls` lists it first).
|
|
225
228
|
const extracted = new Map<string, Endpoint>();
|
|
226
229
|
for (const swaggerUrl of swaggerUrls) {
|
|
227
230
|
for (const [key, endpoint] of parseSwaggerEndpoints(await fetchJson(swaggerUrl))) {
|
|
@@ -3,25 +3,27 @@
|
|
|
3
3
|
* a schema into the flat property list the generator emits. Nothing here knows what
|
|
4
4
|
* the properties are for.
|
|
5
5
|
*/
|
|
6
|
+
import { isRecord } from "@cortex/contracts/json";
|
|
6
7
|
import type { Obj, Prop } from "./types";
|
|
7
8
|
|
|
8
9
|
const MAX_DEPTH = 8;
|
|
9
10
|
|
|
10
|
-
export function isObj(v: unknown): v is Obj {
|
|
11
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
11
|
export function obj(v: unknown) {
|
|
15
|
-
return
|
|
12
|
+
return isRecord(v) ? v : {};
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
function strings(v: unknown) {
|
|
19
16
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
20
17
|
}
|
|
21
18
|
|
|
19
|
+
/** The first prop of each name. */
|
|
22
20
|
export function dedupe(items: Prop[]) {
|
|
23
21
|
const seen = new Set<string>();
|
|
24
|
-
return items.filter((
|
|
22
|
+
return items.filter((prop) => {
|
|
23
|
+
if (seen.has(prop.name)) return false;
|
|
24
|
+
seen.add(prop.name);
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
function resolvePointer(doc: unknown, ref: string) {
|
|
@@ -29,14 +31,14 @@ function resolvePointer(doc: unknown, ref: string) {
|
|
|
29
31
|
let cur: unknown = doc;
|
|
30
32
|
for (const part of ref.slice(2).split("/")) {
|
|
31
33
|
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
32
|
-
if (!
|
|
34
|
+
if (!isRecord(cur) || !(key in cur)) return null;
|
|
33
35
|
cur = cur[key];
|
|
34
36
|
}
|
|
35
37
|
return cur;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export function deref(doc: unknown, v: unknown) {
|
|
39
|
-
if (!
|
|
41
|
+
if (!isRecord(v)) return {};
|
|
40
42
|
let x = v;
|
|
41
43
|
const seen = new Set<string>();
|
|
42
44
|
while (typeof x.$ref === "string") {
|
|
@@ -52,14 +54,14 @@ export function schemaType(s: Obj) {
|
|
|
52
54
|
? (s.type as unknown[]).find((x) => typeof x === "string" && x !== "null")
|
|
53
55
|
: s.type;
|
|
54
56
|
if (typeof t === "string") return t;
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
57
|
+
if (isRecord(s.properties)) return "object";
|
|
58
|
+
if (isRecord(s.items)) return "array";
|
|
57
59
|
return "";
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
function isJsonNodeRef(v: unknown) {
|
|
61
63
|
return (
|
|
62
|
-
|
|
64
|
+
isRecord(v) &&
|
|
63
65
|
typeof v.$ref === "string" &&
|
|
64
66
|
/\/System\.Text\.Json\.Nodes\.JsonNode$/.test(v.$ref)
|
|
65
67
|
);
|
|
@@ -149,13 +151,18 @@ export function toProp(
|
|
|
149
151
|
return { name, required: req, type: scalarType(s) };
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The schema under `application/json`, else any JSON type, else the first
|
|
156
|
+
* type, along with every type offered.
|
|
157
|
+
*/
|
|
152
158
|
export function pickContent(contentIn: unknown) {
|
|
153
159
|
const content = obj(contentIn);
|
|
154
160
|
const mimes = Object.keys(content);
|
|
155
|
-
|
|
161
|
+
const [first] = mimes;
|
|
162
|
+
if (first === undefined) return null;
|
|
156
163
|
const mime =
|
|
157
164
|
mimes.find((x) => x.toLowerCase() === "application/json") ??
|
|
158
165
|
mimes.find((x) => x.toLowerCase().includes("json")) ??
|
|
159
|
-
|
|
160
|
-
return { schema: obj(content[mime
|
|
166
|
+
first;
|
|
167
|
+
return { schema: obj(content[mime]).schema, mimes };
|
|
161
168
|
}
|