@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.
- package/README.md +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- 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,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
|
+
}
|