@m6d/cortex-cli 1.0.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 +135 -0
- package/bin/cortex.js +52 -0
- package/package.json +37 -0
- package/src/cli.ts +63 -0
- package/src/commands/graph.ts +253 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +285 -0
- package/src/config/load.ts +76 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/README.md +23 -0
- package/src/contracts/graph/embed.ts +50 -0
- package/src/contracts/graph/helpers.ts +63 -0
- package/src/contracts/graph/neo4j.ts +97 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +131 -0
- package/src/contracts/graph.ts +36 -0
- package/src/contracts/runtime.ts +208 -0
- package/src/contracts/wire.ts +143 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +227 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +189 -0
- package/src/scaffold/files.ts +568 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/swagger/extract-endpoints.ts +258 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +149 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +102 -0
- package/src/ui/report.ts +102 -0
- package/tsconfig.json +45 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { Endpoint, HttpMethod, Prop } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
dedupe,
|
|
6
|
+
deref,
|
|
7
|
+
isObj,
|
|
8
|
+
obj,
|
|
9
|
+
pickContent,
|
|
10
|
+
schemaType,
|
|
11
|
+
toProp,
|
|
12
|
+
toProps,
|
|
13
|
+
} from "./openapi-schema";
|
|
14
|
+
import { parseResponse } from "./parse-response";
|
|
15
|
+
import { withGeneratedBlock } from "./generated-block";
|
|
16
|
+
|
|
17
|
+
export type ExtractEndpointsOptions = {
|
|
18
|
+
swaggerUrls: string[];
|
|
19
|
+
domainsDir: string;
|
|
20
|
+
write?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const METHODS: Array<[string, HttpMethod]> = [
|
|
24
|
+
["get", "GET"],
|
|
25
|
+
["post", "POST"],
|
|
26
|
+
["put", "PUT"],
|
|
27
|
+
["delete", "DELETE"],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const toCamelCase = (s: string) =>
|
|
31
|
+
s
|
|
32
|
+
.split(".")
|
|
33
|
+
.map((seg) => seg.replace(/^[A-Z]/, (c) => c.toLowerCase()))
|
|
34
|
+
.join(".");
|
|
35
|
+
|
|
36
|
+
function normalizePath(raw: string) {
|
|
37
|
+
let p = raw.startsWith("/") ? raw : `/${raw}`;
|
|
38
|
+
p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(/^\/api(?=\/|$)/i, "") || "/";
|
|
39
|
+
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function walkFiles(dir: string, accept: (filePath: string) => boolean) {
|
|
43
|
+
if (!fs.existsSync(dir)) return [];
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
function walk(d: string) {
|
|
46
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
47
|
+
const p = path.join(d, e.name);
|
|
48
|
+
if (e.isDirectory()) walk(p);
|
|
49
|
+
else if (accept(p)) out.push(p);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
walk(dir);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function endpointFiles(root: string) {
|
|
57
|
+
const out = new Map<string, string>();
|
|
58
|
+
for (const filePath of walkFiles(root, (p) => p.endsWith(".endpoint.ts"))) {
|
|
59
|
+
const c = fs.readFileSync(filePath, "utf8");
|
|
60
|
+
const method = c.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2];
|
|
61
|
+
const route = c.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2];
|
|
62
|
+
if (method && route) out.set(`${method}:${normalizePath(route)}`, filePath);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractEndpoint(
|
|
68
|
+
doc: unknown,
|
|
69
|
+
route: string,
|
|
70
|
+
method: HttpMethod,
|
|
71
|
+
pathItemIn: unknown,
|
|
72
|
+
operationIn: unknown,
|
|
73
|
+
) {
|
|
74
|
+
const pathItem = obj(pathItemIn);
|
|
75
|
+
const operation = obj(operationIn);
|
|
76
|
+
|
|
77
|
+
const rawParams = [
|
|
78
|
+
...(Array.isArray(pathItem.parameters) ? (pathItem.parameters as unknown[]) : []),
|
|
79
|
+
...(Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []),
|
|
80
|
+
]
|
|
81
|
+
.map((x) => deref(doc, x))
|
|
82
|
+
.filter((x) => typeof x.in === "string" && ["path", "query"].includes(x.in))
|
|
83
|
+
.filter((x) => typeof x.name === "string");
|
|
84
|
+
|
|
85
|
+
// Collapse JsonNode-expanded dot-notation query params into a single
|
|
86
|
+
// object param. Swagger expands JsonNode properties (Options, Parent,
|
|
87
|
+
// Root, …) into many individual params like "CustomFilter.Parent.Root".
|
|
88
|
+
const jsonNodeRoots = new Set<string>();
|
|
89
|
+
for (const x of rawParams) {
|
|
90
|
+
const name = String(x.name);
|
|
91
|
+
if (!name.includes(".")) continue;
|
|
92
|
+
const root = name.split(".")[0]!;
|
|
93
|
+
const rest = name.slice(root.length + 1);
|
|
94
|
+
if (/^(Options|Parent|Root)\b/.test(rest)) jsonNodeRoots.add(root);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const params = dedupe(
|
|
98
|
+
rawParams
|
|
99
|
+
.filter((x) => {
|
|
100
|
+
const name = String(x.name);
|
|
101
|
+
if (!name.includes(".")) return true;
|
|
102
|
+
const root = name.split(".")[0]!;
|
|
103
|
+
return !jsonNodeRoots.has(root);
|
|
104
|
+
})
|
|
105
|
+
.map((x) =>
|
|
106
|
+
toProp(
|
|
107
|
+
doc,
|
|
108
|
+
toCamelCase(String(x.name)),
|
|
109
|
+
x.schema,
|
|
110
|
+
String(x.in) === "path" || x.required === true,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
.concat(
|
|
114
|
+
[...jsonNodeRoots].map((root) => ({
|
|
115
|
+
name: toCamelCase(root),
|
|
116
|
+
required: false,
|
|
117
|
+
type: "object",
|
|
118
|
+
})),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const body: Prop[] = [];
|
|
123
|
+
const requestBody = deref(doc, operation.requestBody);
|
|
124
|
+
const requestContent = pickContent(requestBody.content);
|
|
125
|
+
if (requestContent) {
|
|
126
|
+
const s = deref(doc, requestContent.schema);
|
|
127
|
+
const t = schemaType(s);
|
|
128
|
+
if (t === "object") body.push(...toProps(doc, s));
|
|
129
|
+
else if (t === "array") body.push(toProp(doc, "items", s, requestBody.required === true));
|
|
130
|
+
else body.push(toProp(doc, "body", s, requestBody.required === true));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parsed = parseResponse(doc, operation.responses);
|
|
134
|
+
const normalizedPath = normalizePath(route);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
key: `${method}:${normalizedPath}`,
|
|
138
|
+
method,
|
|
139
|
+
path: normalizedPath,
|
|
140
|
+
params,
|
|
141
|
+
body: dedupe(body),
|
|
142
|
+
response: dedupe(parsed.response),
|
|
143
|
+
responseKind: parsed.responseKind,
|
|
144
|
+
successStatus: parsed.successStatus,
|
|
145
|
+
errorStatuses: parsed.errorStatuses,
|
|
146
|
+
} satisfies Endpoint;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseSwaggerEndpoints(swagger: unknown) {
|
|
150
|
+
const out = new Map<string, Endpoint>();
|
|
151
|
+
const doc = obj(swagger);
|
|
152
|
+
for (const [route, pathItem] of Object.entries(obj(doc.paths))) {
|
|
153
|
+
for (const [openApiMethod, method] of METHODS) {
|
|
154
|
+
const operation = obj(pathItem)[openApiMethod];
|
|
155
|
+
if (!isObj(operation)) continue;
|
|
156
|
+
const endpoint = extractEndpoint(doc, route, method, pathItem, operation);
|
|
157
|
+
out.set(endpoint.key, endpoint);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatGeneratedFiles(filePaths: string[]) {
|
|
164
|
+
if (!filePaths.length) return;
|
|
165
|
+
try {
|
|
166
|
+
// Absolute paths: a relative domainsDir yields paths relative to the
|
|
167
|
+
// process cwd, which a prettier run elsewhere would resolve wrongly.
|
|
168
|
+
Bun.spawnSync(["bunx", "prettier", "--write", ...filePaths.map((p) => path.resolve(p))], {
|
|
169
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// Formatting is cosmetic; a missing prettier must not fail the run.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function fetchJson(url: string) {
|
|
177
|
+
const response = await fetch(url, {
|
|
178
|
+
headers: { Accept: "application/json" },
|
|
179
|
+
});
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw new Error(`Swagger request failed (${response.status})`);
|
|
182
|
+
}
|
|
183
|
+
return response.json();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveEndpoint(key: string, extracted: Map<string, Endpoint>) {
|
|
187
|
+
const [method, route] = key.split(":");
|
|
188
|
+
if (!method || !route) return undefined;
|
|
189
|
+
return (
|
|
190
|
+
extracted.get(key) ??
|
|
191
|
+
extracted.get(`${method}:${normalizePath(`/api${route}`)}`) ??
|
|
192
|
+
(route.startsWith("/api/")
|
|
193
|
+
? extracted.get(`${method}:${normalizePath(route.slice(4))}`)
|
|
194
|
+
: undefined)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The post-write prettier pass reformats generated blocks, so an exact string
|
|
199
|
+
// compare would report freshly synced files as drift forever. Compare modulo
|
|
200
|
+
// the axes prettier varies: quote style, whitespace, and trailing commas.
|
|
201
|
+
function sameModuloFormatting(a: string, b: string) {
|
|
202
|
+
function norm(s: string) {
|
|
203
|
+
return s
|
|
204
|
+
.replace(/['"]/g, '"')
|
|
205
|
+
.replace(/\s+/g, "")
|
|
206
|
+
.replace(/,(?=[}\])])/g, "");
|
|
207
|
+
}
|
|
208
|
+
return norm(a) === norm(b);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function extractEndpoints(options: ExtractEndpointsOptions) {
|
|
212
|
+
const { swaggerUrls, domainsDir, write = false } = options;
|
|
213
|
+
|
|
214
|
+
// Merge every document first: a file missing from one swagger doc may be
|
|
215
|
+
// matched by another. Last write wins on duplicate keys, so per-agent docs
|
|
216
|
+
// deliberately override the server-level one (collectSwaggerUrls order).
|
|
217
|
+
const extracted = new Map<string, Endpoint>();
|
|
218
|
+
for (const swaggerUrl of swaggerUrls) {
|
|
219
|
+
for (const [key, endpoint] of parseSwaggerEndpoints(await fetchJson(swaggerUrl))) {
|
|
220
|
+
extracted.set(key, endpoint);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let matched = 0;
|
|
225
|
+
const missing: string[] = [];
|
|
226
|
+
const drift: string[] = [];
|
|
227
|
+
const updated: string[] = [];
|
|
228
|
+
const updatedFiles: string[] = [];
|
|
229
|
+
|
|
230
|
+
for (const [key, filePath] of endpointFiles(domainsDir).entries()) {
|
|
231
|
+
const relativePath = path.relative(domainsDir, filePath);
|
|
232
|
+
const endpoint = resolveEndpoint(key, extracted);
|
|
233
|
+
if (!endpoint) {
|
|
234
|
+
missing.push(relativePath);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const current = fs.readFileSync(filePath, "utf8");
|
|
239
|
+
const next = withGeneratedBlock(current, endpoint);
|
|
240
|
+
if (next === null || next === current || sameModuloFormatting(next, current)) {
|
|
241
|
+
matched++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!write) {
|
|
246
|
+
drift.push(relativePath);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fs.writeFileSync(filePath, next);
|
|
251
|
+
updatedFiles.push(filePath);
|
|
252
|
+
updated.push(relativePath);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (write) formatGeneratedFiles(updatedFiles);
|
|
256
|
+
|
|
257
|
+
return { matched, missing, drift, updated };
|
|
258
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an OpenAPI document: following $ref, deciding a schema's type, and turning
|
|
3
|
+
* a schema into the flat property list the generator emits. Nothing here knows what
|
|
4
|
+
* the properties are for.
|
|
5
|
+
*/
|
|
6
|
+
import type { Obj, Prop } from "./types";
|
|
7
|
+
|
|
8
|
+
const MAX_DEPTH = 8;
|
|
9
|
+
|
|
10
|
+
export const isObj = (v: unknown): v is Obj =>
|
|
11
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
export const obj = (v: unknown) => (isObj(v) ? v : {});
|
|
13
|
+
const strings = (v: unknown) =>
|
|
14
|
+
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
15
|
+
export const dedupe = (items: Prop[]) => {
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
return items.filter((x) => (seen.has(x.name) ? false : (seen.add(x.name), true)));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function resolvePointer(doc: unknown, ref: string) {
|
|
21
|
+
if (!ref.startsWith("#/")) return null;
|
|
22
|
+
let cur: unknown = doc;
|
|
23
|
+
for (const part of ref.slice(2).split("/")) {
|
|
24
|
+
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
25
|
+
if (!isObj(cur) || !(key in cur)) return null;
|
|
26
|
+
cur = cur[key];
|
|
27
|
+
}
|
|
28
|
+
return cur;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function deref(doc: unknown, v: unknown) {
|
|
32
|
+
if (!isObj(v)) return {};
|
|
33
|
+
let x = v;
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
while (typeof x.$ref === "string") {
|
|
36
|
+
if (seen.has(x.$ref)) return {};
|
|
37
|
+
seen.add(x.$ref);
|
|
38
|
+
x = obj(resolvePointer(doc, x.$ref));
|
|
39
|
+
}
|
|
40
|
+
return x;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function schemaType(s: Obj) {
|
|
44
|
+
const t = Array.isArray(s.type)
|
|
45
|
+
? (s.type as unknown[]).find((x) => typeof x === "string" && x !== "null")
|
|
46
|
+
: s.type;
|
|
47
|
+
if (typeof t === "string") return t;
|
|
48
|
+
if (isObj(s.properties)) return "object";
|
|
49
|
+
if (isObj(s.items)) return "array";
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isJsonNodeRef = (v: unknown) =>
|
|
54
|
+
isObj(v) && typeof v.$ref === "string" && /\/System\.Text\.Json\.Nodes\.JsonNode$/.test(v.$ref);
|
|
55
|
+
|
|
56
|
+
function isJsonNodeSchema(s: Obj) {
|
|
57
|
+
const p = obj(s.properties);
|
|
58
|
+
return (
|
|
59
|
+
schemaType(s) === "object" &&
|
|
60
|
+
"options" in p &&
|
|
61
|
+
"parent" in p &&
|
|
62
|
+
"root" in p &&
|
|
63
|
+
isJsonNodeRef(p.parent) &&
|
|
64
|
+
isJsonNodeRef(p.root)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function scalarType(s: Obj) {
|
|
69
|
+
if (Array.isArray(s.enum) && s.enum.every((v) => typeof v === "string")) {
|
|
70
|
+
return s.enum.map((v) => `'${String(v).replace(/'/g, "\\'")}'`).join(" | ");
|
|
71
|
+
}
|
|
72
|
+
const t = schemaType(s);
|
|
73
|
+
const f = typeof s.format === "string" ? s.format : "";
|
|
74
|
+
if (f === "uuid") return "uuid";
|
|
75
|
+
if (f === "date") return "date";
|
|
76
|
+
if (f === "date-time") return "datetime";
|
|
77
|
+
if (t === "integer" || t === "number") return "number";
|
|
78
|
+
if (t === "boolean") return "boolean";
|
|
79
|
+
if (t === "string") return "string";
|
|
80
|
+
if (t === "object") return "object";
|
|
81
|
+
return "unknown";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function toProps(doc: unknown, schemaIn: unknown, depth = 0) {
|
|
85
|
+
if (depth > MAX_DEPTH) return [];
|
|
86
|
+
const s = deref(doc, schemaIn);
|
|
87
|
+
if (isJsonNodeRef(schemaIn) || isJsonNodeSchema(s)) return [];
|
|
88
|
+
const req = new Set(strings(s.required));
|
|
89
|
+
return dedupe(
|
|
90
|
+
Object.entries(obj(s.properties)).map(([name, child]) =>
|
|
91
|
+
toProp(doc, name, child, req.has(name), depth + 1),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function toProp(
|
|
97
|
+
doc: unknown,
|
|
98
|
+
name: string,
|
|
99
|
+
schemaIn: unknown,
|
|
100
|
+
required: boolean,
|
|
101
|
+
depth = 0,
|
|
102
|
+
// eslint-disable-next-line m6d/no-explicit-return-type -- mutually recursive with toProps, so the return type cannot be inferred
|
|
103
|
+
): Prop {
|
|
104
|
+
if (isJsonNodeRef(schemaIn)) return { name, required, type: "object" };
|
|
105
|
+
|
|
106
|
+
const s = deref(doc, schemaIn);
|
|
107
|
+
const req = required && s.nullable !== true;
|
|
108
|
+
|
|
109
|
+
if (isJsonNodeSchema(s)) return { name, required: req, type: "object" };
|
|
110
|
+
|
|
111
|
+
const t = schemaType(s);
|
|
112
|
+
if (t === "array") {
|
|
113
|
+
const item = deref(doc, s.items);
|
|
114
|
+
if (schemaType(item) === "object") {
|
|
115
|
+
const properties = toProps(doc, item, depth + 1);
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
required: req,
|
|
119
|
+
type: "object",
|
|
120
|
+
isArray: true,
|
|
121
|
+
...(properties.length ? { properties } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { name, required: req, type: scalarType(item), isArray: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (t === "object") {
|
|
128
|
+
const properties = toProps(doc, s, depth + 1);
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
required: req,
|
|
132
|
+
type: "object",
|
|
133
|
+
...(properties.length ? { properties } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { name, required: req, type: scalarType(s) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function pickContent(contentIn: unknown) {
|
|
141
|
+
const content = obj(contentIn);
|
|
142
|
+
const mimes = Object.keys(content);
|
|
143
|
+
if (!mimes.length) return null;
|
|
144
|
+
const mime =
|
|
145
|
+
mimes.find((x) => x.toLowerCase() === "application/json") ??
|
|
146
|
+
mimes.find((x) => x.toLowerCase().includes("json")) ??
|
|
147
|
+
mimes[0];
|
|
148
|
+
return { schema: obj(content[mime!]).schema, mimes };
|
|
149
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding what an endpoint answers with: which statuses it uses, and what shape the
|
|
3
|
+
* success body takes. The shape is what drives code generation, so a paged envelope
|
|
4
|
+
* is reported as the rows it wraps rather than as its own page fields.
|
|
5
|
+
*/
|
|
6
|
+
import type { Obj, Prop, ResponseKind } from "./types";
|
|
7
|
+
import { deref, obj, pickContent, schemaType, toProp, toProps } from "./openapi-schema";
|
|
8
|
+
|
|
9
|
+
type ResponseBody = { responseKind: ResponseKind; response: Prop[] };
|
|
10
|
+
|
|
11
|
+
const NO_BODY: ResponseBody = { responseKind: "none", response: [] };
|
|
12
|
+
|
|
13
|
+
const FILE_MIME = /(application\/octet-stream|application\/pdf|application\/vnd\.|text\/csv)/i;
|
|
14
|
+
|
|
15
|
+
/** An object contributes its own fields; anything else stands as a single `item`. */
|
|
16
|
+
function itemProps(doc: unknown, item: Obj) {
|
|
17
|
+
return schemaType(item) === "object" ? toProps(doc, item) : [toProp(doc, "item", item, true)];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isFileResponse(mimes: string[], schema: Obj) {
|
|
21
|
+
if (mimes.some((mime) => FILE_MIME.test(mime))) return true;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
schemaType(schema) === "string" &&
|
|
25
|
+
typeof schema.format === "string" &&
|
|
26
|
+
["binary", "base64"].includes(schema.format)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A paged envelope is described by the items it wraps, not by its own page fields. */
|
|
31
|
+
function parseObjectBody(doc: unknown, schema: Obj) {
|
|
32
|
+
const props = obj(schema.properties);
|
|
33
|
+
const hasPaging = ["totalCount", "pageNumber", "pageSize", "totalPages"].some(
|
|
34
|
+
(k) => k in props,
|
|
35
|
+
);
|
|
36
|
+
const list = deref(doc, props.items ?? props.data ?? props.results);
|
|
37
|
+
|
|
38
|
+
if (hasPaging && schemaType(list) === "array") {
|
|
39
|
+
return {
|
|
40
|
+
responseKind: "paginated",
|
|
41
|
+
response: itemProps(doc, deref(doc, list.items)),
|
|
42
|
+
} satisfies ResponseBody;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { responseKind: "object", response: toProps(doc, schema) } satisfies ResponseBody;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseResponseBody(doc: unknown, schema: Obj, mimes: string[]) {
|
|
49
|
+
if (isFileResponse(mimes, schema))
|
|
50
|
+
return { responseKind: "file", response: [] } satisfies ResponseBody;
|
|
51
|
+
|
|
52
|
+
const type = schemaType(schema);
|
|
53
|
+
if (type === "array") {
|
|
54
|
+
return {
|
|
55
|
+
responseKind: "array",
|
|
56
|
+
response: itemProps(doc, deref(doc, schema.items)),
|
|
57
|
+
} satisfies ResponseBody;
|
|
58
|
+
}
|
|
59
|
+
if (type === "object") return parseObjectBody(doc, schema);
|
|
60
|
+
if (Object.keys(schema).length) {
|
|
61
|
+
return {
|
|
62
|
+
responseKind: "object",
|
|
63
|
+
response: [toProp(doc, "value", schema, true)],
|
|
64
|
+
} satisfies ResponseBody;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return NO_BODY;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseResponse(doc: unknown, responsesIn: unknown) {
|
|
71
|
+
const responses = obj(responsesIn);
|
|
72
|
+
const statuses = Object.keys(responses)
|
|
73
|
+
.map(Number)
|
|
74
|
+
.filter(Number.isInteger)
|
|
75
|
+
.sort((a, b) => a - b);
|
|
76
|
+
const successStatus = statuses.find((s) => s >= 200 && s < 300) ?? 200;
|
|
77
|
+
const errorStatuses = [...new Set(statuses.filter((s) => s >= 400))];
|
|
78
|
+
|
|
79
|
+
// 204 says there is no body at all, so there is nothing further to read.
|
|
80
|
+
if (successStatus === 204) return { successStatus, errorStatuses, ...NO_BODY };
|
|
81
|
+
|
|
82
|
+
const success = deref(doc, responses[String(successStatus)]);
|
|
83
|
+
const content = pickContent(success.content);
|
|
84
|
+
if (!content) return { successStatus, errorStatuses, ...NO_BODY };
|
|
85
|
+
|
|
86
|
+
const body = parseResponseBody(doc, deref(doc, content.schema), content.mimes);
|
|
87
|
+
return { successStatus, errorStatuses, ...body };
|
|
88
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes shared across endpoint extraction: what an OpenAPI document looks like
|
|
3
|
+
* on the way in, and what one generated endpoint looks like on the way out.
|
|
4
|
+
*/
|
|
5
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
|
6
|
+
export type ResponseKind = "object" | "array" | "paginated" | "file" | "none";
|
|
7
|
+
export type Obj = Record<string, unknown>;
|
|
8
|
+
export type Prop = {
|
|
9
|
+
name: string;
|
|
10
|
+
required: boolean;
|
|
11
|
+
type: string;
|
|
12
|
+
isArray?: boolean;
|
|
13
|
+
properties?: Prop[];
|
|
14
|
+
};
|
|
15
|
+
export type Endpoint = {
|
|
16
|
+
key: string;
|
|
17
|
+
method: HttpMethod;
|
|
18
|
+
path: string;
|
|
19
|
+
params: Prop[];
|
|
20
|
+
body: Prop[];
|
|
21
|
+
response: Prop[];
|
|
22
|
+
responseKind: ResponseKind;
|
|
23
|
+
successStatus: number;
|
|
24
|
+
errorStatuses: number[];
|
|
25
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only file that imports `@clack/prompts`. Clack signals Ctrl-C by
|
|
3
|
+
* *returning* a cancel symbol rather than throwing, so an unwrapped call reads
|
|
4
|
+
* an abandoned prompt as an answer and scaffolds the project the user just
|
|
5
|
+
* tried to escape. Every prompt goes through `orExit`, and the rest of the CLI
|
|
6
|
+
* reaches the library through this module or not at all.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
cancel,
|
|
11
|
+
intro,
|
|
12
|
+
isCancel,
|
|
13
|
+
log,
|
|
14
|
+
multiselect,
|
|
15
|
+
outro,
|
|
16
|
+
select,
|
|
17
|
+
spinner,
|
|
18
|
+
text,
|
|
19
|
+
} from "@clack/prompts";
|
|
20
|
+
|
|
21
|
+
import { FEATURES, type Feature } from "@/scaffold/features";
|
|
22
|
+
import type { Database } from "@/scaffold/files";
|
|
23
|
+
|
|
24
|
+
export { intro, log, outro };
|
|
25
|
+
|
|
26
|
+
async function orExit<T>(answer: Promise<T | symbol>) {
|
|
27
|
+
const value = await answer;
|
|
28
|
+
|
|
29
|
+
if (isCancel(value)) {
|
|
30
|
+
cancel("Cancelled — nothing was written.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Prompted only when the positional is omitted; it is the directory name too.
|
|
39
|
+
* What makes a name usable is the `new` command's to say — this asks, re-asks
|
|
40
|
+
* while `check` returns a complaint, and knows nothing else about names.
|
|
41
|
+
*/
|
|
42
|
+
export function askName(check: (name: string) => string | undefined) {
|
|
43
|
+
return orExit(
|
|
44
|
+
text({
|
|
45
|
+
message: "Project name",
|
|
46
|
+
placeholder: "my-app",
|
|
47
|
+
validate: function (value) {
|
|
48
|
+
const candidate = (value ?? "").trim();
|
|
49
|
+
if (candidate.length === 0) return "A project name is required.";
|
|
50
|
+
return check(candidate);
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The one structural pick: it changes a `cortex.config.ts` line and decides
|
|
58
|
+
* whether the generated compose file has a database service at all.
|
|
59
|
+
*/
|
|
60
|
+
export function askDatabase() {
|
|
61
|
+
return orExit(
|
|
62
|
+
select<Database>({
|
|
63
|
+
message: "Database",
|
|
64
|
+
initialValue: "postgres",
|
|
65
|
+
options: [
|
|
66
|
+
{ value: "postgres", hint: "docker-compose.yml runs one for you" },
|
|
67
|
+
{ value: "mssql", hint: "normally an existing external instance" },
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One multi-select for the whole catalogue — minus anything a flag settled. */
|
|
74
|
+
export function askFeatures(undecided: Feature[]) {
|
|
75
|
+
return orExit(
|
|
76
|
+
multiselect<Feature>({
|
|
77
|
+
message: "Which optional features?",
|
|
78
|
+
required: false,
|
|
79
|
+
options: undecided.map((feature) => ({ value: feature, hint: FEATURES[feature].hint })),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Step = { title: string; run: () => string | undefined | Promise<string | undefined> };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write, install, git init. The spinner only runs on a TTY: it redraws in place
|
|
88
|
+
* with cursor escapes, which is noise in a piped log, and the non-interactive
|
|
89
|
+
* transcript is plain `◇` lines. A step that returns nothing — `git init` is
|
|
90
|
+
* silent by design — leaves none.
|
|
91
|
+
*/
|
|
92
|
+
export async function runSteps(steps: Step[]) {
|
|
93
|
+
for (const step of steps) {
|
|
94
|
+
// `withGuide: false` because `log.step` writes the `│` between steps; the
|
|
95
|
+
// spinner adding its own leaves a doubled bar behind every silent step.
|
|
96
|
+
const spin = process.stdout.isTTY === true ? spinner({ withGuide: false }) : undefined;
|
|
97
|
+
spin?.start(step.title);
|
|
98
|
+
const done = await step.run();
|
|
99
|
+
spin?.clear();
|
|
100
|
+
if (done !== undefined) log.step(done);
|
|
101
|
+
}
|
|
102
|
+
}
|