@m6d/cortex-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +175 -0
  2. package/bin/cortex.js +52 -0
  3. package/package.json +41 -0
  4. package/src/cc/client.ts +295 -0
  5. package/src/cc/flags.ts +135 -0
  6. package/src/cc/output.ts +77 -0
  7. package/src/cc/profiles.ts +105 -0
  8. package/src/cli.ts +56 -0
  9. package/src/commands/cc.ts +504 -0
  10. package/src/commands/graph.ts +229 -0
  11. package/src/commands/new.ts +333 -0
  12. package/src/commands/swagger.ts +261 -0
  13. package/src/config/load.ts +122 -0
  14. package/src/config/validate.ts +46 -0
  15. package/src/contracts/charts/chart-spec.ts +201 -0
  16. package/src/contracts/charts/index.ts +7 -0
  17. package/src/contracts/client-tools/index.ts +82 -0
  18. package/src/contracts/egress/index.ts +161 -0
  19. package/src/contracts/egress/scrub.ts +59 -0
  20. package/src/contracts/graph/clients/embed.ts +45 -0
  21. package/src/contracts/graph/clients/neo4j.ts +85 -0
  22. package/src/contracts/graph/helpers.ts +57 -0
  23. package/src/contracts/graph/index.ts +30 -0
  24. package/src/contracts/graph/schema.ts +65 -0
  25. package/src/contracts/graph/types.ts +125 -0
  26. package/src/contracts/markdown/index.ts +39 -0
  27. package/src/contracts/rich-text/index.ts +220 -0
  28. package/src/contracts/runtime/api.ts +328 -0
  29. package/src/contracts/runtime/auth.ts +41 -0
  30. package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
  31. package/src/contracts/runtime/dataset-sql.ts +2522 -0
  32. package/src/contracts/runtime/datasets.fixtures.json +2849 -0
  33. package/src/contracts/runtime/datasets.ts +1391 -0
  34. package/src/contracts/runtime/grammar.ts +161 -0
  35. package/src/contracts/runtime/index.ts +14 -0
  36. package/src/contracts/runtime/signature.ts +237 -0
  37. package/src/contracts/runtime/sync.ts +172 -0
  38. package/src/contracts/runtime/triggers.ts +224 -0
  39. package/src/contracts/wire/index.ts +264 -0
  40. package/src/graph/expand-domains.ts +288 -0
  41. package/src/graph/generate-cypher.ts +201 -0
  42. package/src/graph/seed.ts +207 -0
  43. package/src/graph/validate.ts +78 -0
  44. package/src/scaffold/features.ts +190 -0
  45. package/src/scaffold/files.ts +411 -0
  46. package/src/scaffold/ports.ts +86 -0
  47. package/src/scaffold/templates/domains-README.md.txt +16 -0
  48. package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
  49. package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
  50. package/src/scaffold/templates/gitignore.txt +19 -0
  51. package/src/scaffold/templates/index.ts.txt +18 -0
  52. package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
  53. package/src/scaffold/templates/tsconfig.json.txt +25 -0
  54. package/src/swagger/extract-endpoints.ts +266 -0
  55. package/src/swagger/generated-block.ts +74 -0
  56. package/src/swagger/openapi-schema.ts +161 -0
  57. package/src/swagger/parse-response.ts +88 -0
  58. package/src/swagger/types.ts +25 -0
  59. package/src/ui/prompts.ts +125 -0
  60. package/src/ui/report.ts +107 -0
  61. package/tsconfig.json +46 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Host ports for the services a generated project runs, chosen once at scaffold
3
+ * time.
4
+ *
5
+ * The canonical ports — 5432, 6379, 9000/9001, 7474/7687 — are precisely the ones
6
+ * a developer already running Postgres or Redis has bound, so a scaffold that
7
+ * hardcoded them collides on its first `docker compose up -d`. Only the host side
8
+ * moves: container ports stay canonical, so images, health checks and anything
9
+ * inside the compose network are unaffected. The server's own port is not one of
10
+ * these — it is the project's to choose, and stays the documented default.
11
+ *
12
+ * Every generated file spells its ports `{{port.<name>}}` and gets them filled in
13
+ * by `applyPorts`. That is the point of the token: compose and `.env` cannot
14
+ * drift apart, because neither holds a number of its own.
15
+ */
16
+
17
+ /** High, unprivileged, and clear of the ephemeral range macOS and Linux hand out. */
18
+ const FIRST = 20_000;
19
+ const LAST = 39_999;
20
+
21
+ /** Enough tries to step over a busy port. Not a retry framework. */
22
+ const ATTEMPTS = 20;
23
+
24
+ /**
25
+ * A bind that succeeds is the only honest answer about a port, and it costs one
26
+ * syscall. Anything that stops the bind from being meaningful — no socket
27
+ * permission, a host that is not Bun — reads as "not free" and leaves the caller
28
+ * on its random pick, which is the fallback either way.
29
+ */
30
+ function isFree(port: number) {
31
+ try {
32
+ Bun.listen({ hostname: "0.0.0.0", port, socket: { data() {} } }).stop(true);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /** A random free port, or — if nothing probes free — the first one we picked. */
40
+ function allocate(taken: Set<number>) {
41
+ let first: number | undefined;
42
+
43
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
44
+ const port = FIRST + Math.floor(Math.random() * (LAST - FIRST + 1));
45
+ if (taken.has(port)) continue;
46
+
47
+ first ??= port;
48
+ if (isFree(port)) return port;
49
+ }
50
+
51
+ return first ?? FIRST;
52
+ }
53
+
54
+ /**
55
+ * Every port a generated project can need, allocated together so they are
56
+ * distinct within the project. Features that were not selected simply never
57
+ * mention theirs.
58
+ */
59
+ export function allocatePorts() {
60
+ const taken = new Set<number>();
61
+
62
+ function next() {
63
+ const port = allocate(taken);
64
+ taken.add(port);
65
+ return port;
66
+ }
67
+
68
+ return {
69
+ postgres: next(),
70
+ redis: next(),
71
+ minio: next(),
72
+ minioConsole: next(),
73
+ neo4j: next(),
74
+ neo4jBolt: next(),
75
+ };
76
+ }
77
+
78
+ export type Ports = ReturnType<typeof allocatePorts>;
79
+
80
+ /** Fills in `{{port.postgres}}` and friends. Applied to every file, once, at the end. */
81
+ export function applyPorts(contents: string, ports: Ports) {
82
+ return Object.entries(ports).reduce(
83
+ (text, [name, port]) => text.replaceAll(`{{port.${name}}}`, String(port)),
84
+ contents,
85
+ );
86
+ }
@@ -0,0 +1,16 @@
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.
@@ -0,0 +1,20 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import prettierConfig from "eslint-config-prettier";
3
+ import prettierPlugin from "eslint-plugin-prettier";
4
+ import tseslint from "typescript-eslint";
5
+
6
+ export default defineConfig([
7
+ globalIgnores(["**/node_modules/**", "**/dist/**", "**/.fallow/**"]),
8
+ prettierConfig,
9
+ {
10
+ plugins: { prettier: prettierPlugin },
11
+ rules: { "prettier/prettier": "error" },
12
+ },
13
+ {
14
+ files: ["**/*.ts"],
15
+ extends: [tseslint.configs.recommendedTypeChecked],
16
+ languageOptions: {
17
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
18
+ },
19
+ },
20
+ ]);
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
3
+
4
+ // The single root everything else hangs off. Left to auto-detection, framework
5
+ // heuristics mark far too much as reachable and unused exports stop being
6
+ // reportable at all.
7
+ "entry": ["index.ts"],
8
+
9
+ // These are @m6d/cortex-server's peer dependencies. The server needs them
10
+ // resolvable and it is this package.json's job to install them, but nothing
11
+ // here imports them until you write the code that does — a tool schema in
12
+ // zod, an extra route on `server.app`. A requirement that lives in another
13
+ // package's `peerDependencies` is not something reachability analysis can
14
+ // see, so it is stated here instead. Once your own code imports one, its
15
+ // entry is doing nothing and can go.
16
+ "ignoreDependencies": ["@tanstack/ai", "@tanstack/ai-openai", "hono", "jose", "openai", "zod"],
17
+
18
+ "duplicates": {
19
+ // fallow's default is 0, which means "no limit": clone groups get printed
20
+ // and the run still exits 0, so duplication would be reported and never
21
+ // blocked. A percentage is the only gate duplication has.
22
+ //
23
+ // Read it knowing the denominator is your whole project: 10% of a young
24
+ // one is a handful of lines, so early on this is close to "no copy-paste
25
+ // at all", which is the right setting for code nobody has had time to
26
+ // duplicate yet. Raise it once the number stops meaning anything.
27
+ "threshold": 10,
28
+ },
29
+
30
+ "health": {
31
+ // fallow's defaults, kept as-is: they are the standard we want to hold.
32
+ "maxCyclomatic": 20,
33
+ "maxCognitive": 15,
34
+
35
+ // CRAP is complexity weighted by test coverage, and a project with no
36
+ // coverage scores 0% everywhere, which collapses it to
37
+ // `cyclomatic² + cyclomatic`. 420 is what cyclomatic 20 scores at zero
38
+ // coverage, so this ceiling sits exactly on maxCyclomatic and adds nothing
39
+ // of its own. Give it coverage and lower it.
40
+ "maxCrap": 420,
41
+ },
42
+ }
@@ -0,0 +1,19 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Environment variables — .env.example is the committed one
5
+ .env
6
+ .env.local
7
+ .env.*.local
8
+
9
+ # TypeScript
10
+ *.tsbuildinfo
11
+
12
+ # Tooling caches
13
+ .fallow/
14
+
15
+ # Logs
16
+ *.log
17
+
18
+ # OS files
19
+ .DS_Store
@@ -0,0 +1,18 @@
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
+ };
@@ -0,0 +1,5 @@
1
+ export default {
2
+ printWidth: 100,
3
+ singleQuote: false,
4
+ tabWidth: 4,
5
+ };
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ // Named explicitly rather than left to @types auto-inclusion, which is an
6
+ // editor-dependent heuristic. The `Bun` global comes from here.
7
+ "types": ["bun"],
8
+ "target": "ESNext",
9
+ "module": "Preserve",
10
+ "moduleDetection": "force",
11
+
12
+ // Bundler mode
13
+ "moduleResolution": "bundler",
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noImplicitOverride": true,
22
+ "noUncheckedIndexedAccess": true
23
+ },
24
+ "include": ["**/*.ts"]
25
+ }
@@ -0,0 +1,266 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node: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
+ 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
+ /**
31
+ * ASP.NET conventions the extractor normalizes away so an `.endpoint.ts` path
32
+ * matches the spec whichever way the API was mounted: routes are keyed without
33
+ * the `/api` prefix (either side may carry it), and `JsonNode`-typed query
34
+ * parameters, which Swagger expands into `Root.Options`, `Root.Parent` and
35
+ * `Root.Root` dot-paths, collapse back to one object parameter.
36
+ */
37
+ const API_PREFIX = /^\/api(?=\/|$)/i;
38
+ const JSON_NODE_MEMBER = /^(Options|Parent|Root)\b/;
39
+
40
+ function toCamelCase(s: string) {
41
+ return s
42
+ .split(".")
43
+ .map((seg) => seg.replace(/^[A-Z]/, (c) => c.toLowerCase()))
44
+ .join(".");
45
+ }
46
+
47
+ function normalizePath(raw: string) {
48
+ let p = raw.startsWith("/") ? raw : `/${raw}`;
49
+ p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(API_PREFIX, "") || "/";
50
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
51
+ }
52
+
53
+ function walkFiles(dir: string, accept: (filePath: string) => boolean) {
54
+ if (!fs.existsSync(dir)) return [];
55
+ const out: string[] = [];
56
+ function walk(d: string) {
57
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
58
+ const p = path.join(d, e.name);
59
+ if (e.isDirectory()) walk(p);
60
+ else if (accept(p)) out.push(p);
61
+ }
62
+ }
63
+ walk(dir);
64
+ return out;
65
+ }
66
+
67
+ function endpointFiles(root: string) {
68
+ const out = new Map<string, string>();
69
+ for (const filePath of walkFiles(root, (p) => p.endsWith(".endpoint.ts"))) {
70
+ const c = fs.readFileSync(filePath, "utf8");
71
+ const method = c.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2];
72
+ const route = c.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2];
73
+ if (method && route) out.set(`${method}:${normalizePath(route)}`, filePath);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function extractEndpoint(
79
+ doc: unknown,
80
+ route: string,
81
+ method: HttpMethod,
82
+ pathItemIn: unknown,
83
+ operationIn: unknown,
84
+ ) {
85
+ const pathItem = obj(pathItemIn);
86
+ const operation = obj(operationIn);
87
+
88
+ const rawParams = [
89
+ ...(Array.isArray(pathItem.parameters) ? (pathItem.parameters as unknown[]) : []),
90
+ ...(Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []),
91
+ ]
92
+ .map((x) => deref(doc, x))
93
+ .filter((x) => typeof x.in === "string" && ["path", "query"].includes(x.in))
94
+ .filter((x) => typeof x.name === "string");
95
+
96
+ const jsonNodeRoots = new Set<string>();
97
+ for (const x of rawParams) {
98
+ const name = String(x.name);
99
+ if (!name.includes(".")) continue;
100
+ const root = name.split(".")[0]!;
101
+ const rest = name.slice(root.length + 1);
102
+ if (JSON_NODE_MEMBER.test(rest)) jsonNodeRoots.add(root);
103
+ }
104
+
105
+ const params = dedupe(
106
+ rawParams
107
+ .filter((x) => {
108
+ const name = String(x.name);
109
+ if (!name.includes(".")) return true;
110
+ const root = name.split(".")[0]!;
111
+ return !jsonNodeRoots.has(root);
112
+ })
113
+ .map((x) =>
114
+ toProp(
115
+ doc,
116
+ toCamelCase(String(x.name)),
117
+ x.schema,
118
+ String(x.in) === "path" || x.required === true,
119
+ ),
120
+ )
121
+ .concat(
122
+ [...jsonNodeRoots].map((root) => ({
123
+ name: toCamelCase(root),
124
+ required: false,
125
+ type: "object",
126
+ })),
127
+ ),
128
+ );
129
+
130
+ const body: Prop[] = [];
131
+ const requestBody = deref(doc, operation.requestBody);
132
+ const requestContent = pickContent(requestBody.content);
133
+ if (requestContent) {
134
+ const s = deref(doc, requestContent.schema);
135
+ const t = schemaType(s);
136
+ if (t === "object") body.push(...toProps(doc, s));
137
+ else if (t === "array") body.push(toProp(doc, "items", s, requestBody.required === true));
138
+ else body.push(toProp(doc, "body", s, requestBody.required === true));
139
+ }
140
+
141
+ const parsed = parseResponse(doc, operation.responses);
142
+ const normalizedPath = normalizePath(route);
143
+
144
+ return {
145
+ key: `${method}:${normalizedPath}`,
146
+ method,
147
+ path: normalizedPath,
148
+ params,
149
+ body: dedupe(body),
150
+ response: dedupe(parsed.response),
151
+ responseKind: parsed.responseKind,
152
+ successStatus: parsed.successStatus,
153
+ errorStatuses: parsed.errorStatuses,
154
+ } satisfies Endpoint;
155
+ }
156
+
157
+ function parseSwaggerEndpoints(swagger: unknown) {
158
+ const out = new Map<string, Endpoint>();
159
+ const doc = obj(swagger);
160
+ for (const [route, pathItem] of Object.entries(obj(doc.paths))) {
161
+ for (const [openApiMethod, method] of METHODS) {
162
+ const operation = obj(pathItem)[openApiMethod];
163
+ if (!isObj(operation)) continue;
164
+ const endpoint = extractEndpoint(doc, route, method, pathItem, operation);
165
+ out.set(endpoint.key, endpoint);
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function formatGeneratedFiles(filePaths: string[]) {
172
+ if (!filePaths.length) return;
173
+ try {
174
+ // Absolute paths: a relative domainsDir yields paths relative to the
175
+ // process cwd, which a prettier run elsewhere would resolve wrongly.
176
+ Bun.spawnSync(["bunx", "prettier", "--write", ...filePaths.map((p) => path.resolve(p))], {
177
+ stdio: ["ignore", "ignore", "ignore"],
178
+ });
179
+ } catch {
180
+ // Formatting is cosmetic; a missing prettier must not fail the run.
181
+ }
182
+ }
183
+
184
+ async function fetchJson(url: string) {
185
+ const response = await fetch(url, {
186
+ headers: { Accept: "application/json" },
187
+ });
188
+ if (!response.ok) {
189
+ throw new Error(`Swagger request failed (${response.status})`);
190
+ }
191
+ return response.json();
192
+ }
193
+
194
+ function resolveEndpoint(key: string, extracted: Map<string, Endpoint>) {
195
+ const [method, route] = key.split(":");
196
+ if (!method || !route) return undefined;
197
+ return (
198
+ extracted.get(key) ??
199
+ extracted.get(`${method}:${normalizePath(`/api${route}`)}`) ??
200
+ (route.startsWith("/api/")
201
+ ? extracted.get(`${method}:${normalizePath(route.slice(4))}`)
202
+ : undefined)
203
+ );
204
+ }
205
+
206
+ // The post-write prettier pass reformats generated blocks, so an exact string
207
+ // compare would report freshly synced files as drift forever. Compare modulo
208
+ // the axes prettier varies: quote style, whitespace, and trailing commas.
209
+ function sameModuloFormatting(a: string, b: string) {
210
+ function norm(s: string) {
211
+ return s
212
+ .replace(/['"]/g, '"')
213
+ .replace(/\s+/g, "")
214
+ .replace(/,(?=[}\])])/g, "");
215
+ }
216
+ return norm(a) === norm(b);
217
+ }
218
+
219
+ export async function extractEndpoints(options: ExtractEndpointsOptions) {
220
+ const { swaggerUrls, domainsDir, write = false } = options;
221
+
222
+ // Merge every document first: a file missing from one swagger doc may be
223
+ // matched by another. Last write wins on duplicate keys, so per-agent docs
224
+ // deliberately override the server-level one (collectSwaggerUrls order).
225
+ const extracted = new Map<string, Endpoint>();
226
+ for (const swaggerUrl of swaggerUrls) {
227
+ for (const [key, endpoint] of parseSwaggerEndpoints(await fetchJson(swaggerUrl))) {
228
+ extracted.set(key, endpoint);
229
+ }
230
+ }
231
+
232
+ let matched = 0;
233
+ const missing: string[] = [];
234
+ const drift: string[] = [];
235
+ const updated: string[] = [];
236
+ const updatedFiles: string[] = [];
237
+
238
+ for (const [key, filePath] of endpointFiles(domainsDir).entries()) {
239
+ const relativePath = path.relative(domainsDir, filePath);
240
+ const endpoint = resolveEndpoint(key, extracted);
241
+ if (!endpoint) {
242
+ missing.push(relativePath);
243
+ continue;
244
+ }
245
+
246
+ const current = fs.readFileSync(filePath, "utf8");
247
+ const next = withGeneratedBlock(current, endpoint);
248
+ if (next === null || next === current || sameModuloFormatting(next, current)) {
249
+ matched++;
250
+ continue;
251
+ }
252
+
253
+ if (!write) {
254
+ drift.push(relativePath);
255
+ continue;
256
+ }
257
+
258
+ fs.writeFileSync(filePath, next);
259
+ updatedFiles.push(filePath);
260
+ updated.push(relativePath);
261
+ }
262
+
263
+ if (write) formatGeneratedFiles(updatedFiles);
264
+
265
+ return { matched, missing, drift, updated };
266
+ }
@@ -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
+ }