@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,333 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Option, type Command } from "@commander-js/extra-typings";
|
|
6
|
+
|
|
7
|
+
import pkg from "../../package.json";
|
|
8
|
+
import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
|
|
9
|
+
import { scaffoldFiles, type Database } from "@/scaffold/files";
|
|
10
|
+
import { allocatePorts } from "@/scaffold/ports";
|
|
11
|
+
import { askDatabase, askFeatures, askName, intro, log, outro, runSteps } from "@/ui/prompts";
|
|
12
|
+
import * as report from "@/ui/report";
|
|
13
|
+
|
|
14
|
+
/** npm's rule for an unscoped package name. The name is the directory too. */
|
|
15
|
+
const VALID_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
16
|
+
const MAX_NAME = 214;
|
|
17
|
+
|
|
18
|
+
/** The two names npm rejects outright, however well-formed they otherwise are. */
|
|
19
|
+
const RESERVED_NAMES = ["node_modules", "favicon.ico"];
|
|
20
|
+
|
|
21
|
+
export function registerNew(program: Command) {
|
|
22
|
+
const command = program
|
|
23
|
+
.command("new")
|
|
24
|
+
.helpGroup("Project:")
|
|
25
|
+
.description("Scaffold a new Cortex server")
|
|
26
|
+
.argument("[name]", "Directory to create, and the generated package name")
|
|
27
|
+
.addOption(
|
|
28
|
+
new Option("--database <type>", "Database the project is configured for")
|
|
29
|
+
.choices(["postgres", "mssql"] as const)
|
|
30
|
+
.default("postgres" as const),
|
|
31
|
+
)
|
|
32
|
+
// Both halves of every toggle, so a flag can answer its question either
|
|
33
|
+
// way and `--yes` is not the only way to say "no". Declaring the positive
|
|
34
|
+
// first leaves the value `undefined` when neither is passed, which is
|
|
35
|
+
// what marks the question as still unanswered.
|
|
36
|
+
.option("--attachments", FEATURES.attachments.hint)
|
|
37
|
+
.option("--no-attachments", "Leave attachments out")
|
|
38
|
+
.option("--redis", FEATURES.redis.hint)
|
|
39
|
+
.option("--no-redis", "Leave Redis out")
|
|
40
|
+
.option("--auth", FEATURES.auth.hint)
|
|
41
|
+
.option("--no-auth", "Leave auth out")
|
|
42
|
+
.option("--knowledge", FEATURES.knowledge.hint)
|
|
43
|
+
.option("--no-knowledge", "Leave the knowledge graph out")
|
|
44
|
+
.option("--control-center", FEATURES.controlCenter.hint)
|
|
45
|
+
.option("--no-control-center", "Leave the Control Center out")
|
|
46
|
+
.option("--yes", "Accept the defaults instead of asking");
|
|
47
|
+
|
|
48
|
+
command.action(async function (name, options) {
|
|
49
|
+
// The globals live on the root command, which reaches this file untyped —
|
|
50
|
+
// `Command` here is the parameter type, not cli.ts's inferred one.
|
|
51
|
+
const { verbose = false } = program.opts() as { verbose?: boolean };
|
|
52
|
+
|
|
53
|
+
await scaffold(name, {
|
|
54
|
+
...options,
|
|
55
|
+
verbose,
|
|
56
|
+
databasePassed: command.getOptionValueSource("database") !== "default",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Options = {
|
|
62
|
+
database: Database;
|
|
63
|
+
yes?: boolean;
|
|
64
|
+
verbose: boolean;
|
|
65
|
+
databasePassed: boolean;
|
|
66
|
+
} & Partial<Record<Feature, boolean>>;
|
|
67
|
+
|
|
68
|
+
async function scaffold(name: string | undefined, options: Options) {
|
|
69
|
+
const { verbose, databasePassed } = options;
|
|
70
|
+
|
|
71
|
+
// No TTY ⇒ never prompt and behave as `--yes` (design spec §5.3). On Bun a
|
|
72
|
+
// piped stdin reports `undefined` rather than `false`, which is why this
|
|
73
|
+
// compares against `true`.
|
|
74
|
+
const interactive = process.stdin.isTTY === true && options.yes !== true;
|
|
75
|
+
|
|
76
|
+
if (name === undefined && !interactive) missingName();
|
|
77
|
+
|
|
78
|
+
// A name that came from a flag is checked before anything prints; one that
|
|
79
|
+
// is prompted for is checked by the prompt, which can ask again.
|
|
80
|
+
if (name !== undefined && !isValidName(name)) failInvalidName(name);
|
|
81
|
+
if (name !== undefined && isTaken(resolve(process.cwd(), name))) failTaken(name);
|
|
82
|
+
|
|
83
|
+
const started = Date.now();
|
|
84
|
+
intro(`cortex new · ${pkg.name} ${pkg.version}`);
|
|
85
|
+
|
|
86
|
+
const project = name ?? (await askName(checkName)).trim();
|
|
87
|
+
|
|
88
|
+
const database = interactive && !databasePassed ? await askDatabase() : options.database;
|
|
89
|
+
const features = await chooseFeatures(options, interactive);
|
|
90
|
+
|
|
91
|
+
const target = resolve(process.cwd(), project);
|
|
92
|
+
|
|
93
|
+
// Allocated here rather than inside the generators, which stay pure: picking a
|
|
94
|
+
// port means probing the machine for a free one.
|
|
95
|
+
const ports = allocatePorts();
|
|
96
|
+
const files = scaffoldFiles(project, database, features, ports);
|
|
97
|
+
let failedInstall: string | undefined;
|
|
98
|
+
|
|
99
|
+
await runSteps([
|
|
100
|
+
{
|
|
101
|
+
title: `Writing ${Object.keys(files).length} files`,
|
|
102
|
+
run: function () {
|
|
103
|
+
for (const [path, contents] of Object.entries(files)) {
|
|
104
|
+
const file = join(target, path);
|
|
105
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
106
|
+
writeFileSync(file, contents);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const summary = [database, ...features, "model"].join(" · ");
|
|
110
|
+
return `Wrote ${Object.keys(files).length} files to ./${project}\n${summary}`;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
// Always, no flag: running it is what separates a scaffold from a
|
|
115
|
+
// template dump.
|
|
116
|
+
title: "Installing dependencies",
|
|
117
|
+
run: async function () {
|
|
118
|
+
// Timed from here, not from the run: the prompts above are the
|
|
119
|
+
// user's time, and "in 41.0s" of which 38 were spent answering
|
|
120
|
+
// questions is a number nobody can act on.
|
|
121
|
+
const startedInstall = Date.now();
|
|
122
|
+
const result = await install(target);
|
|
123
|
+
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
failedInstall = result.output;
|
|
126
|
+
// What went wrong is reported below, once the run is over;
|
|
127
|
+
// --verbose still owes the reader the command that failed.
|
|
128
|
+
return verbose ? "$ bun install" : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const packages = /(\d+) packages? installed/.exec(result.output)?.[1];
|
|
132
|
+
const what = packages === undefined ? "dependencies" : `${packages} packages`;
|
|
133
|
+
const done = `Installed ${what} in ${seconds(startedInstall)}`;
|
|
134
|
+
return verbose ? `$ bun install\n${done}` : done;
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
// A repo nested inside another is a mess to discover later, and the
|
|
139
|
+
// .gitignore just written is inert without one. Silent either way,
|
|
140
|
+
// which is why this step reports nothing.
|
|
141
|
+
title: "Initializing git",
|
|
142
|
+
run: function () {
|
|
143
|
+
if (isInsideWorkTree(target)) {
|
|
144
|
+
return verbose ? "git init skipped — already inside a work tree" : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
spawnSync("git", ["init"], { cwd: target, stdio: "ignore" });
|
|
148
|
+
return verbose ? "$ git init" : undefined;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
if (failedInstall !== undefined) warnInstall(project, failedInstall, verbose);
|
|
154
|
+
|
|
155
|
+
outro(`Done in ${seconds(started)}`);
|
|
156
|
+
process.stdout.write(nextSteps(project, features, files));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The files are correct; only the install did not happen, so this warns and the
|
|
161
|
+
* run still exits 0. What bun said is the whole diagnosis, so it is kept: the
|
|
162
|
+
* last line by default, the captured log under --verbose.
|
|
163
|
+
*/
|
|
164
|
+
function warnInstall(project: string, output: string, verbose: boolean) {
|
|
165
|
+
const detail = verbose ? output.trim() : lastLine(output);
|
|
166
|
+
|
|
167
|
+
log.warn(
|
|
168
|
+
// clack indents continuation lines by 3, so 2 more puts the command in
|
|
169
|
+
// the same column the error grammar uses.
|
|
170
|
+
`bun install did not finish. The files are written — run it yourself:\n\n` +
|
|
171
|
+
` cd ${project} && bun install` +
|
|
172
|
+
(detail === "" ? "" : `\n\n${detail}`),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function nextSteps(project: string, features: Feature[], files: ReturnType<typeof scaffoldFiles>) {
|
|
177
|
+
const keys = [
|
|
178
|
+
"CORTEX_DATABASE_URL",
|
|
179
|
+
"CORTEX_MODEL_*",
|
|
180
|
+
...features.map((feature) => FEATURES[feature].secrets).filter((key) => key !== undefined),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
`\nNext steps:\n\n` +
|
|
185
|
+
step(`cd ${project}`) +
|
|
186
|
+
step("edit .env", `fill in ${keys.length === 2 ? keys.join(" and ") : keys.join(", ")}`) +
|
|
187
|
+
(files["docker-compose.yml"] === undefined ? "" : step("docker compose up -d")) +
|
|
188
|
+
// The `port` the generated cortex.config.ts sets, which is the server's
|
|
189
|
+
// own default. Service ports move at scaffold time; this one does not.
|
|
190
|
+
step("bun run dev", "→ http://localhost:3331") +
|
|
191
|
+
`\n`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One multi-select for the whole catalogue, minus whatever the flags already
|
|
197
|
+
* settled: a passed flag suppresses its question, the rest are still asked.
|
|
198
|
+
*/
|
|
199
|
+
async function chooseFeatures(options: Options, interactive: boolean) {
|
|
200
|
+
const chosen = new Set(FEATURE_NAMES.filter((feature) => options[feature] === true));
|
|
201
|
+
const undecided = FEATURE_NAMES.filter((feature) => options[feature] === undefined);
|
|
202
|
+
|
|
203
|
+
if (interactive && undecided.length > 0) {
|
|
204
|
+
for (const feature of await askFeatures(undecided)) chosen.add(feature);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Catalogue order, not the order the flags or the checkboxes came in.
|
|
208
|
+
return FEATURE_NAMES.filter((feature) => chosen.has(feature));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function install(cwd: string) {
|
|
212
|
+
try {
|
|
213
|
+
// Piped rather than inherited: the spinner owns the screen while this runs.
|
|
214
|
+
// bun prints its summary to stderr, so both streams are read.
|
|
215
|
+
const child = Bun.spawn(["bun", "install"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
216
|
+
const output = await Promise.all([
|
|
217
|
+
new Response(child.stdout).text(),
|
|
218
|
+
new Response(child.stderr).text(),
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
return { ok: (await child.exited) === 0, output: output.join("") };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// A spawn that never starts — EACCES, EMFILE — is still just an install
|
|
224
|
+
// that did not happen, and the files are already written. It takes the
|
|
225
|
+
// same warn-and-exit-0 path as a bun install that ran and failed, rather
|
|
226
|
+
// than surfacing as "cortex hit an unexpected error".
|
|
227
|
+
return { ok: false, output: error instanceof Error ? error.message : String(error) };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function step(command: string, annotation?: string) {
|
|
232
|
+
// 27 puts the annotation column where cli-output.md §3 has it.
|
|
233
|
+
return annotation ? ` ${command.padEnd(27)}${annotation}\n` : ` ${command}\n`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function seconds(started: number) {
|
|
237
|
+
return `${((Date.now() - started) / 1000).toFixed(1)}s`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** bun puts the reason last, and that one line is what gets pasted into a search. */
|
|
241
|
+
function lastLine(output: string) {
|
|
242
|
+
return output.trim().split("\n").at(-1)?.trim() ?? "";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isValidName(name: string) {
|
|
246
|
+
return name.length <= MAX_NAME && VALID_NAME.test(name) && !RESERVED_NAMES.includes(name);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The prompt's rules, which are the flag path's two failures asked one at a time. */
|
|
250
|
+
function checkName(candidate: string) {
|
|
251
|
+
// One line each: clack prints them inside the prompt, which will not wrap.
|
|
252
|
+
if (RESERVED_NAMES.includes(candidate)) return `npm does not allow the name ${candidate}.`;
|
|
253
|
+
if (!isValidName(candidate)) return "Lowercase letters, digits, and . _ - only.";
|
|
254
|
+
if (isTaken(resolve(process.cwd(), candidate))) {
|
|
255
|
+
return `./${candidate} already exists and is not an empty directory.`;
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `My App` → `my-app`, so the suggested command is one the user can paste. */
|
|
261
|
+
function slugify(name: string) {
|
|
262
|
+
const slug = name
|
|
263
|
+
.trim()
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
266
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
267
|
+
.slice(0, MAX_NAME);
|
|
268
|
+
|
|
269
|
+
return slug === "" || RESERVED_NAMES.includes(slug) ? "my-app" : slug;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A missing path is the only clean go-ahead, and an empty directory the one
|
|
274
|
+
* tolerated exception. Anything else — a regular file of that name, a directory
|
|
275
|
+
* with contents, one we lack permission to read — is a conflict: swallowing it
|
|
276
|
+
* scaffolds halfway and then dies on the first write.
|
|
277
|
+
*/
|
|
278
|
+
function isTaken(target: string) {
|
|
279
|
+
try {
|
|
280
|
+
return readdirSync(target).length > 0;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return !(error instanceof Error && "code" in error && error.code === "ENOENT");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isInsideWorkTree(target: string) {
|
|
287
|
+
return (
|
|
288
|
+
spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
289
|
+
cwd: target,
|
|
290
|
+
stdio: "ignore",
|
|
291
|
+
}).status === 0
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function missingName(): never {
|
|
296
|
+
// No TTY ⇒ never prompt, so a missing required argument is a hard error
|
|
297
|
+
// (design spec §5.3). With a TTY this branch is only reached under `--yes`,
|
|
298
|
+
// which answers every question including the one that has no default.
|
|
299
|
+
report.fail({
|
|
300
|
+
what:
|
|
301
|
+
process.stdin.isTTY === true
|
|
302
|
+
? "cortex new needs a project name."
|
|
303
|
+
: "cortex new needs a project name when stdin is not a TTY.",
|
|
304
|
+
blocks: [
|
|
305
|
+
"Pass it as an argument, and --yes to accept defaults for the rest:",
|
|
306
|
+
" cortex new my-app --yes",
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function failTaken(name: string): never {
|
|
312
|
+
report.fail({
|
|
313
|
+
what: `./${name} already exists and is not an empty directory.`,
|
|
314
|
+
blocks: [
|
|
315
|
+
"cortex new never merges into an existing project. Pick another name,\nor remove it first:",
|
|
316
|
+
` rm -rf ${name}`,
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The name is the directory *and* the generated `package.json` name, so an
|
|
323
|
+
* illegal one produces a project npm refuses and a `cd` that does not parse.
|
|
324
|
+
*/
|
|
325
|
+
function failInvalidName(name: string): never {
|
|
326
|
+
report.fail({
|
|
327
|
+
what: `"${name}" is not a usable project name`,
|
|
328
|
+
blocks: [
|
|
329
|
+
"It becomes the directory and the generated package.json `name`, so it has\nto be one npm accepts: lowercase, no spaces, not a name npm reserves. Try:",
|
|
330
|
+
` cortex new ${slugify(name)}`,
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { type Command, Option } from "@commander-js/extra-typings";
|
|
5
|
+
|
|
6
|
+
import { configLabel, findConfig, loadConfig } from "@/config/load";
|
|
7
|
+
import { validateConfig } from "@/config/validate";
|
|
8
|
+
import { extractEndpoints } from "@/swagger/extract-endpoints";
|
|
9
|
+
import * as report from "@/ui/report";
|
|
10
|
+
|
|
11
|
+
/** A convention, not config: `KnowledgeConfig` has no `domainsDir` (design spec §5.5). */
|
|
12
|
+
const DEFAULT_DOMAINS_DIR = "src/domains";
|
|
13
|
+
|
|
14
|
+
/** Only the parts of a loaded config `swagger` reads to report its inputs. */
|
|
15
|
+
type ProjectConfig = {
|
|
16
|
+
knowledge?: { swagger?: { url?: string } };
|
|
17
|
+
agents?: Record<string, { knowledge?: { swagger?: { url?: string } } | null } | undefined>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function registerSwagger(program: Command) {
|
|
21
|
+
const swagger = program
|
|
22
|
+
.command("swagger")
|
|
23
|
+
.helpGroup("Resources:")
|
|
24
|
+
.summary("Endpoint schemas, from your Swagger spec")
|
|
25
|
+
.description(
|
|
26
|
+
"Endpoint schemas, from your Swagger spec.\n\nReads `knowledge.swagger.url` from your cortex.config.ts and the .endpoint.ts\nfiles under src/domains.",
|
|
27
|
+
)
|
|
28
|
+
// Declared once, on the group: the two verbs read the same directory, and
|
|
29
|
+
// commander hands a verb's unknown options back to its parent, so
|
|
30
|
+
// `swagger -d x sync` and `swagger sync -d x` both land here.
|
|
31
|
+
.addOption(
|
|
32
|
+
new Option(
|
|
33
|
+
"-d, --domains-dir <path>",
|
|
34
|
+
"Domains root, relative to the config file",
|
|
35
|
+
).default(DEFAULT_DOMAINS_DIR, DEFAULT_DOMAINS_DIR),
|
|
36
|
+
)
|
|
37
|
+
.helpCommand(false);
|
|
38
|
+
|
|
39
|
+
for (const verb of ["sync", "check"] as const) {
|
|
40
|
+
swagger
|
|
41
|
+
.command(verb)
|
|
42
|
+
.description(
|
|
43
|
+
verb === "sync"
|
|
44
|
+
? "Update .endpoint.ts files in place"
|
|
45
|
+
: "Report drift without writing (exit 2 if stale)",
|
|
46
|
+
)
|
|
47
|
+
.action(async function () {
|
|
48
|
+
// The globals live on the root command, which reaches this file
|
|
49
|
+
// untyped — `Command` here is the parameter type, not cli.ts's
|
|
50
|
+
// inferred one.
|
|
51
|
+
const { config: path, verbose = false } = program.opts() as {
|
|
52
|
+
config: string;
|
|
53
|
+
verbose?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
await run({
|
|
57
|
+
verb,
|
|
58
|
+
path,
|
|
59
|
+
explicit: program.getOptionValueSource("config") !== "default",
|
|
60
|
+
verbose,
|
|
61
|
+
domainsDir: swagger.opts().domainsDir,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function run(options: {
|
|
68
|
+
verb: "sync" | "check";
|
|
69
|
+
path: string;
|
|
70
|
+
explicit: boolean;
|
|
71
|
+
verbose: boolean;
|
|
72
|
+
domainsDir: string;
|
|
73
|
+
}) {
|
|
74
|
+
const { verb, verbose } = options;
|
|
75
|
+
const title = `cortex swagger ${verb}`;
|
|
76
|
+
|
|
77
|
+
// Discovery runs before the box opens: with no config there is no run to
|
|
78
|
+
// report on, only the reason there isn't one.
|
|
79
|
+
const configPath = findConfig({ ...options, command: title });
|
|
80
|
+
|
|
81
|
+
report.header(title);
|
|
82
|
+
const loaded = await loadConfig(configPath, verbose);
|
|
83
|
+
validateConfig(loaded, configPath);
|
|
84
|
+
report.input("Config", configLabel(configPath));
|
|
85
|
+
|
|
86
|
+
// Relative to the config file, never to the cwd — the domains tree belongs to
|
|
87
|
+
// the project, and `--config ../other/cortex.config.ts` must still find it.
|
|
88
|
+
const domainsDir = resolve(dirname(configPath), options.domainsDir);
|
|
89
|
+
if (!existsSync(domainsDir)) failMissingDomainsDir(domainsDir, verb);
|
|
90
|
+
|
|
91
|
+
const urls = swaggerUrls(loaded);
|
|
92
|
+
for (const url of urls) report.input("Swagger", url);
|
|
93
|
+
|
|
94
|
+
const startedAt = Date.now();
|
|
95
|
+
const result = await runExtract(urls, domainsDir, verb).catch(function (error: unknown): never {
|
|
96
|
+
return failExtract({ error, configPath, verbose, verb });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Reported once the run is over because that is when the count is known —
|
|
100
|
+
// the total is every file the walk considered, however it ended up.
|
|
101
|
+
const total =
|
|
102
|
+
result.matched + result.updated.length + result.drift.length + result.missing.length;
|
|
103
|
+
report.input(
|
|
104
|
+
"Domains",
|
|
105
|
+
`${configLabel(domainsDir)} — ${report.plural(total, "endpoint file")}`,
|
|
106
|
+
);
|
|
107
|
+
report.blank();
|
|
108
|
+
|
|
109
|
+
const elapsed = `(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`;
|
|
110
|
+
if (verb === "sync") return renderSync(result, domainsDir, elapsed);
|
|
111
|
+
renderCheck(result, domainsDir, elapsed, syncCommand(options));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The `sync` that fixes what `check` found has to act on the tree `check` read,
|
|
116
|
+
* so it carries whatever flags got it there. Defaults stay off the line — a hint
|
|
117
|
+
* is something to paste, not a transcript.
|
|
118
|
+
*/
|
|
119
|
+
function syncCommand(options: { path: string; explicit: boolean; domainsDir: string }) {
|
|
120
|
+
return [
|
|
121
|
+
"cortex swagger sync",
|
|
122
|
+
...(options.explicit ? [`--config ${options.path}`] : []),
|
|
123
|
+
...(options.domainsDir === DEFAULT_DOMAINS_DIR
|
|
124
|
+
? []
|
|
125
|
+
: [`--domains-dir ${options.domainsDir}`]),
|
|
126
|
+
].join(" ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Report = {
|
|
130
|
+
matched: number;
|
|
131
|
+
missing: string[];
|
|
132
|
+
drift: string[];
|
|
133
|
+
updated: string[];
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `sync` says what it did. Only changed files are printed — at 37 files a
|
|
138
|
+
* per-file log is 34 lines of `ok` — and `MISSING` goes last, because it is the
|
|
139
|
+
* one thing left for a human. Writing is the job, so it still exits 0.
|
|
140
|
+
*/
|
|
141
|
+
function renderSync(result: Report, domainsDir: string, elapsed: string) {
|
|
142
|
+
for (const file of result.updated) report.row("updated", file);
|
|
143
|
+
for (const file of result.missing) {
|
|
144
|
+
report.row("missing", file);
|
|
145
|
+
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
146
|
+
}
|
|
147
|
+
if (result.updated.length || result.missing.length) report.blank();
|
|
148
|
+
|
|
149
|
+
const parts = [`${result.updated.length} updated`];
|
|
150
|
+
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
151
|
+
parts.push(`${result.matched} unchanged`);
|
|
152
|
+
if (result.updated.length) parts.push("formatted with prettier");
|
|
153
|
+
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
154
|
+
|
|
155
|
+
if (result.missing.length) {
|
|
156
|
+
report.note(
|
|
157
|
+
`${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger check\` will keep failing until it is resolved.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `check` says what is wrong, and writes nothing. `drift` and `MISSING` are both
|
|
164
|
+
* findings — one is fixed by a tool, the other by a person — so either exits 2.
|
|
165
|
+
*/
|
|
166
|
+
function renderCheck(result: Report, domainsDir: string, elapsed: string, sync: string) {
|
|
167
|
+
for (const file of result.drift) {
|
|
168
|
+
report.row("drift", file);
|
|
169
|
+
report.row("", `${routeOf(join(domainsDir, file))} — no longer matches the spec`);
|
|
170
|
+
}
|
|
171
|
+
for (const file of result.missing) {
|
|
172
|
+
report.row("missing", file);
|
|
173
|
+
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
174
|
+
}
|
|
175
|
+
if (result.drift.length || result.missing.length) report.blank();
|
|
176
|
+
|
|
177
|
+
const parts: string[] = [];
|
|
178
|
+
if (result.drift.length) parts.push(`${result.drift.length} drifted`);
|
|
179
|
+
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
180
|
+
parts.push(`${result.matched} up to date`);
|
|
181
|
+
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
182
|
+
|
|
183
|
+
if (result.drift.length) {
|
|
184
|
+
report.note(
|
|
185
|
+
`Run \`${sync}\` to update the ${report.plural(result.drift.length, "drifted file")}.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (result.missing.length) {
|
|
189
|
+
report.note(
|
|
190
|
+
`${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger sync\` cannot fix it; it needs a human.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (result.drift.length || result.missing.length) process.exit(2);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The route a finding is about, read back the way the server's own file walk
|
|
198
|
+
* reads it. The report is a list of paths; the route is what the reader has to
|
|
199
|
+
* act on, and it lives in the file.
|
|
200
|
+
*/
|
|
201
|
+
function routeOf(filePath: string) {
|
|
202
|
+
const source = readFileSync(filePath, "utf8");
|
|
203
|
+
const method = source.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2] ?? "?";
|
|
204
|
+
const route = source.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2] ?? "?";
|
|
205
|
+
return `${method} ${route}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Unlike `graph seed`, this needs nothing from the project's server — not even a
|
|
210
|
+
* version handshake. What it writes is `.endpoint.ts` source whose generated block
|
|
211
|
+
* has to satisfy that server's own `EndpointDef`, so a codegen that outran the
|
|
212
|
+
* installed server is a type error in the project's `bun run check`, loudly, rather
|
|
213
|
+
* than something to detect here.
|
|
214
|
+
*/
|
|
215
|
+
async function runExtract(swaggerUrls: string[], domainsDir: string, verb: "sync" | "check") {
|
|
216
|
+
if (!swaggerUrls.length) throw new Error("No swagger URLs found in knowledge config");
|
|
217
|
+
|
|
218
|
+
return await extractEndpoints({ swaggerUrls, domainsDir, write: verb === "sync" });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Server level, then per agent, deduped — per-agent docs override on a key clash. */
|
|
222
|
+
function swaggerUrls(config: ProjectConfig) {
|
|
223
|
+
const urls = new Set<string>();
|
|
224
|
+
if (config.knowledge?.swagger?.url) urls.add(config.knowledge.swagger.url);
|
|
225
|
+
|
|
226
|
+
for (const agent of Object.values(config.agents ?? {})) {
|
|
227
|
+
if (agent?.knowledge?.swagger?.url) urls.add(agent.knowledge.swagger.url);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return [...urls];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `1 endpoint file has` / `3 endpoint files have` — both notes open with it. */
|
|
234
|
+
function missingLead(result: Report) {
|
|
235
|
+
return `${report.plural(result.missing.length, "endpoint file")} ${result.missing.length === 1 ? "has" : "have"}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* An absent domains dir is *couldn't run*, not "nothing to do": `extractEndpoints`
|
|
240
|
+
* walks a missing directory as an empty one and would report a clean 0 of 0.
|
|
241
|
+
*/
|
|
242
|
+
function failMissingDomainsDir(domainsDir: string, verb: string): never {
|
|
243
|
+
report.fail({
|
|
244
|
+
what: `No domains directory at ${configLabel(domainsDir)}`,
|
|
245
|
+
blocks: [
|
|
246
|
+
`\`swagger ${verb}\` reads .endpoint.ts files under a domains root — ${DEFAULT_DOMAINS_DIR} by\ndefault — resolved relative to the config file. Point it somewhere else:`,
|
|
247
|
+
` cortex swagger ${verb} --domains-dir ./lib/domains`,
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Section requirements are `runExtract`'s to enforce — it already throws
|
|
254
|
+
* `"No swagger URLs found in knowledge config"`. This renders what it threw.
|
|
255
|
+
*/
|
|
256
|
+
function failExtract(options: {
|
|
257
|
+
error: unknown;
|
|
258
|
+
configPath: string;
|
|
259
|
+
verbose: boolean;
|
|
260
|
+
verb: string;
|
|
261
|
+
}): never {
|
|
262
|
+
const { error, configPath, verbose, verb } = options;
|
|
263
|
+
const label = configLabel(configPath);
|
|
264
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
265
|
+
|
|
266
|
+
if (message === "No swagger URLs found in knowledge config") {
|
|
267
|
+
report.fail({
|
|
268
|
+
what: `${label} has no knowledge.swagger.url`,
|
|
269
|
+
blocks: [
|
|
270
|
+
`\`swagger ${verb}\` generates endpoint schemas from a Swagger spec. Add one:`,
|
|
271
|
+
' knowledge: { swagger: { url: "https://api.example.com/swagger/v1/swagger.json" } }',
|
|
272
|
+
"See https://github.com/boring91/cortex#file-structure-convention",
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The `◇ Swagger` lines above name every URL that was tried, so the error
|
|
278
|
+
// states the failure and points at the key behind it.
|
|
279
|
+
report.fail({
|
|
280
|
+
what: "Could not read the Swagger spec",
|
|
281
|
+
error,
|
|
282
|
+
blocks: [`Is the API running? Otherwise check knowledge.swagger.url in\n${label}.`],
|
|
283
|
+
verbose,
|
|
284
|
+
});
|
|
285
|
+
}
|