@m6d/cortex-cli 0.12.1 → 0.14.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 +193 -128
- package/package.json +16 -5
- package/src/cli.ts +19 -12
- package/src/commands/apply.ts +186 -0
- package/src/commands/{cc.ts → management.ts} +173 -112
- package/src/commands/new.ts +83 -84
- package/src/commands/setup.ts +610 -0
- package/src/contracts/charts/chart-spec.ts +1 -1
- package/src/contracts/client-tools/index.ts +3 -3
- package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
- package/src/contracts/datasets/index.ts +3 -0
- package/src/contracts/egress-policy/index.ts +16 -0
- package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
- package/src/contracts/{egress → egress-policy}/url.ts +22 -9
- package/src/contracts/errors/index.ts +38 -0
- package/src/contracts/json/index.ts +5 -0
- package/src/contracts/manifest/index.ts +973 -0
- package/src/contracts/manifest-file/index.ts +16 -0
- package/src/contracts/markdown/index.ts +1 -1
- package/src/contracts/pattern/index.ts +276 -0
- package/src/contracts/server/index.ts +349 -0
- package/src/contracts/server/reserved-names.fixtures.json +18 -0
- package/src/contracts/triggers/index.ts +112 -0
- package/src/contracts/wire/index.ts +67 -33
- package/src/{cc → management}/client.ts +33 -7
- package/src/{cc → management}/flags.ts +2 -1
- package/src/{cc → management}/profiles.ts +10 -8
- package/src/scaffold/files.ts +448 -284
- package/src/scaffold/ports.ts +12 -24
- package/src/scaffold/templates/cspell.json.txt +41 -0
- package/src/scaffold/templates/datasets.ts.txt +52 -0
- package/src/scaffold/templates/dockerignore.txt +6 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
- package/src/scaffold/templates/gitignore.txt +1 -1
- package/src/scaffold/templates/server.ts.txt +40 -0
- package/src/scaffold/templates/setup.ts.txt +44 -0
- package/src/ui/prompts.ts +24 -38
- package/src/ui/report.ts +5 -15
- package/tsconfig.json +2 -3
- package/src/commands/graph.ts +0 -228
- package/src/commands/swagger.ts +0 -247
- package/src/config/load.ts +0 -122
- package/src/config/validate.ts +0 -46
- package/src/contracts/credentials/index.ts +0 -183
- package/src/contracts/egress/index.ts +0 -120
- package/src/contracts/graph/clients/embed.ts +0 -62
- package/src/contracts/graph/clients/neo4j.ts +0 -85
- package/src/contracts/graph/helpers.ts +0 -55
- package/src/contracts/graph/index.ts +0 -30
- package/src/contracts/graph/schema.ts +0 -65
- package/src/contracts/graph/types.ts +0 -125
- package/src/contracts/rich-text/index.ts +0 -220
- package/src/contracts/runtime/api.ts +0 -345
- package/src/contracts/runtime/auth.ts +0 -41
- package/src/contracts/runtime/grammar.ts +0 -169
- package/src/contracts/runtime/index.ts +0 -15
- package/src/contracts/runtime/models.ts +0 -50
- package/src/contracts/runtime/signature.ts +0 -255
- package/src/contracts/runtime/sync.ts +0 -196
- package/src/contracts/runtime/triggers.ts +0 -267
- package/src/graph/expand-domains.ts +0 -286
- package/src/graph/generate-cypher.ts +0 -201
- package/src/graph/seed.ts +0 -199
- package/src/graph/validate.ts +0 -78
- package/src/scaffold/features.ts +0 -190
- package/src/scaffold/templates/domains-README.md.txt +0 -16
- package/src/scaffold/templates/index.ts.txt +0 -18
- package/src/swagger/extract-endpoints.ts +0 -269
- package/src/swagger/generated-block.ts +0 -74
- package/src/swagger/openapi-schema.ts +0 -168
- package/src/swagger/parse-response.ts +0 -88
- package/src/swagger/types.ts +0 -25
- /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
- /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
- /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
- /package/src/{cc → management}/output.ts +0 -0
package/src/commands/graph.ts
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import type { Command } from "@commander-js/extra-typings";
|
|
2
|
-
import {
|
|
3
|
-
GRAPH_SCHEMA,
|
|
4
|
-
GRAPH_SCHEMA_VERSION,
|
|
5
|
-
createEmbedder,
|
|
6
|
-
createNeo4jClient,
|
|
7
|
-
type DomainDef,
|
|
8
|
-
type EmbeddingProviderConfig,
|
|
9
|
-
type Neo4jClient,
|
|
10
|
-
type Neo4jConfig,
|
|
11
|
-
} from "@cortex/contracts/graph";
|
|
12
|
-
import pc from "picocolors";
|
|
13
|
-
|
|
14
|
-
import { configLabel, configOption, openProject } from "@/config/load";
|
|
15
|
-
import { seedGraph } from "@/graph/seed";
|
|
16
|
-
import * as report from "@/ui/report";
|
|
17
|
-
|
|
18
|
-
const TITLE = "cortex graph seed";
|
|
19
|
-
|
|
20
|
-
/** Domain names are longer than swagger's finding labels, so the body is wider. */
|
|
21
|
-
const DOMAIN_COLUMN = 14;
|
|
22
|
-
|
|
23
|
-
/** A failing statement is worth one line: enough to identify, not enough to drown. */
|
|
24
|
-
const DETAIL_WIDTH = 60;
|
|
25
|
-
|
|
26
|
-
/** Only the parts of a loaded config `graph seed` reads. */
|
|
27
|
-
type ProjectConfig = {
|
|
28
|
-
neo4j?: Neo4jConfig;
|
|
29
|
-
embedding?: EmbeddingProviderConfig & { dimension: number };
|
|
30
|
-
knowledge?: { domains?: Record<string, DomainDef> };
|
|
31
|
-
agents?: Record<string, { knowledge?: { domains?: Record<string, DomainDef> } | null }>;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Connection failures reach us as whatever `fetch` threw, so match on shape
|
|
35
|
-
// rather than on a driver's error class.
|
|
36
|
-
const UNREACHABLE =
|
|
37
|
-
/unable to connect|connection refused|econnrefused|failed to fetch|fetch failed|enotfound|getaddrinfo|timed out/i;
|
|
38
|
-
|
|
39
|
-
export function registerGraph(program: Command) {
|
|
40
|
-
const graph = program
|
|
41
|
-
.command("graph")
|
|
42
|
-
.helpGroup("Resources:")
|
|
43
|
-
.summary("The Neo4j knowledge graph")
|
|
44
|
-
.description(
|
|
45
|
-
"The Neo4j knowledge graph.\n\nReads `knowledge.domains` and `neo4j` from your cortex.config.ts.",
|
|
46
|
-
)
|
|
47
|
-
.addOption(configOption())
|
|
48
|
-
.helpCommand(false);
|
|
49
|
-
|
|
50
|
-
// No flags. `--domain <name>` is flag-shaped design work, not a flag:
|
|
51
|
-
// `expandDomains` resolves cross-domain references and the node pass creates every
|
|
52
|
-
// node before any edge, so a subset seed risks dangling edges.
|
|
53
|
-
graph
|
|
54
|
-
.command("seed")
|
|
55
|
-
.description("Load concepts, endpoints, services, and rules into Neo4j")
|
|
56
|
-
.action(async function () {
|
|
57
|
-
await seed(graph);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function seed(group: Command) {
|
|
62
|
-
const { config: loaded, configPath, verbose } = await openProject(group, TITLE);
|
|
63
|
-
const config = loaded as ProjectConfig;
|
|
64
|
-
|
|
65
|
-
// Inputs are reported as they resolve. With no domains there is nothing to
|
|
66
|
-
// seed; an absent neo4j or embedding section is `runSeed`'s error to throw.
|
|
67
|
-
if (config.neo4j?.url) report.input("Neo4j", config.neo4j.url);
|
|
68
|
-
const domains = collectDomains(config);
|
|
69
|
-
const names = Object.keys(domains);
|
|
70
|
-
if (!names.length) failMissingDomains(configPath);
|
|
71
|
-
report.input("Domains", `${names.length} — ${names.join(", ")}`);
|
|
72
|
-
|
|
73
|
-
const startedAt = Date.now();
|
|
74
|
-
const result = await runSeed(config, domains).catch(function (error: unknown): never {
|
|
75
|
-
return failSeed({ error, config, configPath, verbose });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// A domain is the unit the user edits, so it is the unit the report counts
|
|
79
|
-
// against: a wrong number next to `attendance` points at src/domains/attendance/.
|
|
80
|
-
report.blank();
|
|
81
|
-
for (const [name, domain] of Object.entries(result.domains)) {
|
|
82
|
-
const counts = [report.plural(domain.nodes, "node"), report.plural(domain.edges, "edge")];
|
|
83
|
-
if (domain.failed) counts.push(`${domain.failed} failed`);
|
|
84
|
-
report.row(name, counts.join(" · "), DOMAIN_COLUMN);
|
|
85
|
-
|
|
86
|
-
// The first failure only: enough to identify what broke, not enough to
|
|
87
|
-
// drown the run — the count above says how many followed it.
|
|
88
|
-
const failure = domain.failures[0];
|
|
89
|
-
if (failure) {
|
|
90
|
-
report.row("", `${pc.red("✗")} ${truncate(failure.error)}`, DOMAIN_COLUMN);
|
|
91
|
-
report.row("", ` ${truncate(oneLine(failure.statement))}`, DOMAIN_COLUMN);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Embeddings are a second pass over the whole graph, so they get their own
|
|
96
|
-
// step rather than a row among the counts.
|
|
97
|
-
report.blank();
|
|
98
|
-
report.input(
|
|
99
|
-
"Embeddings",
|
|
100
|
-
`${report.plural(result.embeddings.concepts, "concept")}, dimension ${result.embeddings.dimension}`,
|
|
101
|
-
);
|
|
102
|
-
report.blank();
|
|
103
|
-
|
|
104
|
-
const counts = [report.plural(result.nodes, "node"), report.plural(result.edges, "edge")];
|
|
105
|
-
if (result.failed) counts.push(`${result.failed} failed`);
|
|
106
|
-
report.footer(`${counts.join(" · ")} (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
|
|
107
|
-
|
|
108
|
-
if (result.failed) {
|
|
109
|
-
report.note("The graph is partially seeded. Re-running is safe — seeding is MERGE-based.");
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Cypher is generated multi-line and indented; a report row is neither. */
|
|
115
|
-
function oneLine(statement: string) {
|
|
116
|
-
return statement.replace(/\s+/g, " ").trim();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function truncate(text: string) {
|
|
120
|
-
return text.length > DETAIL_WIDTH ? `${text.slice(0, DETAIL_WIDTH - 2)} …` : text;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Server level first, then per agent — an agent's domain of the same name wins. */
|
|
124
|
-
function collectDomains(config: ProjectConfig) {
|
|
125
|
-
const all = { ...config.knowledge?.domains };
|
|
126
|
-
|
|
127
|
-
for (const agent of Object.values(config.agents ?? {})) {
|
|
128
|
-
Object.assign(all, agent.knowledge?.domains);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return all;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The whole pipeline is the CLI's, built on the graph contract it depends on:
|
|
136
|
-
* `@cortex/contracts/graph` is where the vocabulary, the Neo4j client and the embedder
|
|
137
|
-
* come from, and the version it seeds at is the version of that dependency.
|
|
138
|
-
*/
|
|
139
|
-
async function runSeed(config: ProjectConfig, domains: Record<string, DomainDef>) {
|
|
140
|
-
if (!config.neo4j) throw new Error("neo4j config is required for graph seed");
|
|
141
|
-
if (!config.embedding) throw new Error("embedding config is required for graph seed");
|
|
142
|
-
|
|
143
|
-
const embed = createEmbedder(config.embedding);
|
|
144
|
-
const neo4j = createNeo4jClient(config.neo4j, embed);
|
|
145
|
-
|
|
146
|
-
await warnOnRestamp(neo4j);
|
|
147
|
-
|
|
148
|
-
return await seedGraph(
|
|
149
|
-
{
|
|
150
|
-
neo4j,
|
|
151
|
-
embedding: { embed, dimension: config.embedding.dimension },
|
|
152
|
-
schema: GRAPH_SCHEMA,
|
|
153
|
-
schemaVersion: GRAPH_SCHEMA_VERSION,
|
|
154
|
-
},
|
|
155
|
-
domains,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Seeding is MERGE-based, so a graph written under an older schema is not cleaned
|
|
161
|
-
* up — its nodes stay, under labels nothing reads. Worth saying out loud; not
|
|
162
|
-
* worth blocking on, since re-seeding is exactly how that graph gets fixed.
|
|
163
|
-
*/
|
|
164
|
-
async function warnOnRestamp(neo4j: Neo4jClient) {
|
|
165
|
-
// An unreachable graph is the seed's to report, far better than a warning could.
|
|
166
|
-
const rows = await neo4j
|
|
167
|
-
.query(
|
|
168
|
-
`MATCH (g:${GRAPH_SCHEMA.label.marker}) RETURN g.${GRAPH_SCHEMA.prop.version} AS version`,
|
|
169
|
-
)
|
|
170
|
-
.catch(() => undefined);
|
|
171
|
-
if (!rows) return;
|
|
172
|
-
|
|
173
|
-
const stamped = (rows as { version?: number }[])[0]?.version;
|
|
174
|
-
if (stamped === undefined || stamped === GRAPH_SCHEMA_VERSION) return;
|
|
175
|
-
|
|
176
|
-
report.blank();
|
|
177
|
-
report.row(
|
|
178
|
-
"",
|
|
179
|
-
`${pc.yellow("!")} Graph was written with schema v${stamped}; restamping as v${GRAPH_SCHEMA_VERSION}.`,
|
|
180
|
-
DOMAIN_COLUMN,
|
|
181
|
-
);
|
|
182
|
-
report.row("", " Nodes from the old schema are not removed.", DOMAIN_COLUMN);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function failMissingDomains(configPath: string): never {
|
|
186
|
-
report.fail({
|
|
187
|
-
what: `${configLabel(configPath)} has no knowledge.domains`,
|
|
188
|
-
blocks: [
|
|
189
|
-
"`graph seed` loads domain definitions into Neo4j. Add them:",
|
|
190
|
-
" knowledge: { domains: { leaves: leavesDomain } }",
|
|
191
|
-
"See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
|
|
192
|
-
],
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* The neo4j and embedding sections are `runSeed`'s to require — it throws
|
|
198
|
-
* `"neo4j config is required for graph seed"`. This renders what it threw.
|
|
199
|
-
*/
|
|
200
|
-
function failSeed(options: {
|
|
201
|
-
error: unknown;
|
|
202
|
-
config: ProjectConfig;
|
|
203
|
-
configPath: string;
|
|
204
|
-
verbose: boolean;
|
|
205
|
-
}): never {
|
|
206
|
-
const { error, config, configPath, verbose } = options;
|
|
207
|
-
const label = configLabel(configPath);
|
|
208
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
209
|
-
const url = config.neo4j?.url;
|
|
210
|
-
if (url !== undefined && UNREACHABLE.test(message)) {
|
|
211
|
-
report.fail({
|
|
212
|
-
what: `Cannot reach Neo4j at ${url}`,
|
|
213
|
-
error,
|
|
214
|
-
blocks: [
|
|
215
|
-
"Is it running? docker compose up -d neo4j",
|
|
216
|
-
`Otherwise check neo4j.url / neo4j.user / neo4j.password in\n${label}.`,
|
|
217
|
-
],
|
|
218
|
-
verbose,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
report.fail({
|
|
223
|
-
what: "Could not seed the graph",
|
|
224
|
-
error,
|
|
225
|
-
blocks: [`Check neo4j, embedding, and knowledge.domains in ${label}.`],
|
|
226
|
-
verbose,
|
|
227
|
-
});
|
|
228
|
-
}
|
package/src/commands/swagger.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
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, configOption, openProject } from "@/config/load";
|
|
7
|
-
import { declaredRoute, extractEndpoints } from "@/swagger/extract-endpoints";
|
|
8
|
-
import * as report from "@/ui/report";
|
|
9
|
-
|
|
10
|
-
/** A convention, not config: `KnowledgeConfig` has no `domainsDir`. */
|
|
11
|
-
const DEFAULT_DOMAINS_DIR = "src/domains";
|
|
12
|
-
|
|
13
|
-
/** Only the parts of a loaded config `swagger` reads to report its inputs. */
|
|
14
|
-
type ProjectConfig = {
|
|
15
|
-
knowledge?: { swagger?: { url?: string } };
|
|
16
|
-
agents?: Record<string, { knowledge?: { swagger?: { url?: string } } | null } | undefined>;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function registerSwagger(program: Command) {
|
|
20
|
-
const swagger = program
|
|
21
|
-
.command("swagger")
|
|
22
|
-
.helpGroup("Resources:")
|
|
23
|
-
.summary("Endpoint schemas, from your Swagger spec")
|
|
24
|
-
.description(
|
|
25
|
-
"Endpoint schemas, from your Swagger spec.\n\nReads `knowledge.swagger.url` from your cortex.config.ts and the .endpoint.ts\nfiles under src/domains.",
|
|
26
|
-
)
|
|
27
|
-
.addOption(configOption())
|
|
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
|
-
await run(swagger, verb, swagger.opts().domainsDir);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function run(group: Command, verb: "sync" | "check", domainsDirOption: string) {
|
|
54
|
-
const title = `cortex swagger ${verb}`;
|
|
55
|
-
const { config: loaded, configPath, path, explicit, verbose } = await openProject(group, title);
|
|
56
|
-
|
|
57
|
-
// Relative to the config file, never to the cwd — the domains tree belongs to
|
|
58
|
-
// the project, and `--config ../other/cortex.config.ts` must still find it.
|
|
59
|
-
const domainsDir = resolve(dirname(configPath), domainsDirOption);
|
|
60
|
-
if (!existsSync(domainsDir)) failMissingDomainsDir(domainsDir, verb);
|
|
61
|
-
|
|
62
|
-
const urls = swaggerUrls(loaded);
|
|
63
|
-
if (!urls.length) failMissingSwaggerUrl(configPath, verb);
|
|
64
|
-
for (const url of urls) report.input("Swagger", url);
|
|
65
|
-
|
|
66
|
-
// Unlike `graph seed`, this needs nothing from the project's server — not
|
|
67
|
-
// even a version handshake. What it writes is `.endpoint.ts` source whose
|
|
68
|
-
// generated block has to satisfy that server's own `EndpointDef`, so a
|
|
69
|
-
// codegen that outran the installed server is a type error in the project's
|
|
70
|
-
// `bun run check`, loudly, rather than something to detect here.
|
|
71
|
-
const startedAt = Date.now();
|
|
72
|
-
const result = await extractEndpoints({
|
|
73
|
-
swaggerUrls: urls,
|
|
74
|
-
domainsDir,
|
|
75
|
-
write: verb === "sync",
|
|
76
|
-
}).catch(function (error: unknown): never {
|
|
77
|
-
return failExtract({ error, configPath, verbose });
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// Reported once the run is over because that is when the count is known —
|
|
81
|
-
// the total is every file the walk considered, however it ended up.
|
|
82
|
-
const total =
|
|
83
|
-
result.matched + result.updated.length + result.drift.length + result.missing.length;
|
|
84
|
-
report.input(
|
|
85
|
-
"Domains",
|
|
86
|
-
`${configLabel(domainsDir)} — ${report.plural(total, "endpoint file")}`,
|
|
87
|
-
);
|
|
88
|
-
report.blank();
|
|
89
|
-
|
|
90
|
-
const elapsed = `(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`;
|
|
91
|
-
if (verb === "sync") return renderSync(result, domainsDir, elapsed);
|
|
92
|
-
renderCheck(
|
|
93
|
-
result,
|
|
94
|
-
domainsDir,
|
|
95
|
-
elapsed,
|
|
96
|
-
syncCommand({ path, explicit, domainsDir: domainsDirOption }),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The `sync` that fixes what `check` found has to act on the tree `check` read,
|
|
102
|
-
* so it carries whatever flags got it there. Defaults stay off the line — a hint
|
|
103
|
-
* is something to paste, not a transcript.
|
|
104
|
-
*/
|
|
105
|
-
function syncCommand(options: { path: string; explicit: boolean; domainsDir: string }) {
|
|
106
|
-
return [
|
|
107
|
-
"cortex swagger sync",
|
|
108
|
-
...(options.explicit ? [`--config ${options.path}`] : []),
|
|
109
|
-
...(options.domainsDir === DEFAULT_DOMAINS_DIR
|
|
110
|
-
? []
|
|
111
|
-
: [`--domains-dir ${options.domainsDir}`]),
|
|
112
|
-
].join(" ");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
type Report = {
|
|
116
|
-
matched: number;
|
|
117
|
-
missing: string[];
|
|
118
|
-
drift: string[];
|
|
119
|
-
updated: string[];
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* `sync` says what it did. Only changed files are printed — at 37 files a
|
|
124
|
-
* per-file log is 34 lines of `ok` — and `MISSING` goes last, because it is the
|
|
125
|
-
* one thing left for a human. Writing is the job, so it still exits 0.
|
|
126
|
-
*/
|
|
127
|
-
function renderSync(result: Report, domainsDir: string, elapsed: string) {
|
|
128
|
-
for (const file of result.updated) report.row("updated", file);
|
|
129
|
-
for (const file of result.missing) {
|
|
130
|
-
report.row("missing", file);
|
|
131
|
-
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
132
|
-
}
|
|
133
|
-
if (result.updated.length || result.missing.length) report.blank();
|
|
134
|
-
|
|
135
|
-
const parts = [`${result.updated.length} updated`];
|
|
136
|
-
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
137
|
-
parts.push(`${result.matched} unchanged`);
|
|
138
|
-
if (result.updated.length) parts.push("formatted with prettier");
|
|
139
|
-
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
140
|
-
|
|
141
|
-
if (result.missing.length) {
|
|
142
|
-
report.note(
|
|
143
|
-
`${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.`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* `check` says what is wrong, and writes nothing. `drift` and `MISSING` are both
|
|
150
|
-
* findings — one is fixed by a tool, the other by a person — so either exits 2.
|
|
151
|
-
*/
|
|
152
|
-
function renderCheck(result: Report, domainsDir: string, elapsed: string, sync: string) {
|
|
153
|
-
for (const file of result.drift) {
|
|
154
|
-
report.row("drift", file);
|
|
155
|
-
report.row("", `${routeOf(join(domainsDir, file))} — no longer matches the spec`);
|
|
156
|
-
}
|
|
157
|
-
for (const file of result.missing) {
|
|
158
|
-
report.row("missing", file);
|
|
159
|
-
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
160
|
-
}
|
|
161
|
-
if (result.drift.length || result.missing.length) report.blank();
|
|
162
|
-
|
|
163
|
-
const parts: string[] = [];
|
|
164
|
-
if (result.drift.length) parts.push(`${result.drift.length} drifted`);
|
|
165
|
-
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
166
|
-
parts.push(`${result.matched} up to date`);
|
|
167
|
-
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
168
|
-
|
|
169
|
-
if (result.drift.length) {
|
|
170
|
-
report.note(
|
|
171
|
-
`Run \`${sync}\` to update the ${report.plural(result.drift.length, "drifted file")}.`,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
if (result.missing.length) {
|
|
175
|
-
report.note(
|
|
176
|
-
`${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.`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
if (result.drift.length || result.missing.length) process.exit(2);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* The route a finding is about. The report is a list of paths; the route is
|
|
184
|
-
* what the reader has to act on, and it lives in the file.
|
|
185
|
-
*/
|
|
186
|
-
function routeOf(filePath: string) {
|
|
187
|
-
const { method = "?", path = "?" } = declaredRoute(readFileSync(filePath, "utf8"));
|
|
188
|
-
return `${method} ${path}`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Server level, then per agent, deduped — per-agent docs override on a key clash. */
|
|
192
|
-
function swaggerUrls(config: ProjectConfig) {
|
|
193
|
-
const urls = new Set<string>();
|
|
194
|
-
if (config.knowledge?.swagger?.url) urls.add(config.knowledge.swagger.url);
|
|
195
|
-
|
|
196
|
-
for (const agent of Object.values(config.agents ?? {})) {
|
|
197
|
-
if (agent?.knowledge?.swagger?.url) urls.add(agent.knowledge.swagger.url);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return [...urls];
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** `1 endpoint file has` / `3 endpoint files have` — both notes open with it. */
|
|
204
|
-
function missingLead(result: Report) {
|
|
205
|
-
return `${report.plural(result.missing.length, "endpoint file")} ${result.missing.length === 1 ? "has" : "have"}`;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* An absent domains dir is *couldn't run*, not "nothing to do": `extractEndpoints`
|
|
210
|
-
* walks a missing directory as an empty one and would report a clean 0 of 0.
|
|
211
|
-
*/
|
|
212
|
-
function failMissingDomainsDir(domainsDir: string, verb: string): never {
|
|
213
|
-
report.fail({
|
|
214
|
-
what: `No domains directory at ${configLabel(domainsDir)}`,
|
|
215
|
-
blocks: [
|
|
216
|
-
`\`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:`,
|
|
217
|
-
` cortex swagger ${verb} --domains-dir ./lib/domains`,
|
|
218
|
-
],
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function failMissingSwaggerUrl(configPath: string, verb: string): never {
|
|
223
|
-
report.fail({
|
|
224
|
-
what: `${configLabel(configPath)} has no knowledge.swagger.url`,
|
|
225
|
-
blocks: [
|
|
226
|
-
`\`swagger ${verb}\` generates endpoint schemas from a Swagger spec. Add one:`,
|
|
227
|
-
' knowledge: { swagger: { url: "https://api.example.com/swagger/v1/swagger.json" } }',
|
|
228
|
-
"See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
|
|
229
|
-
],
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* The `◇ Swagger` lines above name every URL that was tried, so the error
|
|
235
|
-
* states the failure and points at the key behind it.
|
|
236
|
-
*/
|
|
237
|
-
function failExtract(options: { error: unknown; configPath: string; verbose: boolean }): never {
|
|
238
|
-
const { error, configPath, verbose } = options;
|
|
239
|
-
report.fail({
|
|
240
|
-
what: "Could not read the Swagger spec",
|
|
241
|
-
error,
|
|
242
|
-
blocks: [
|
|
243
|
-
`Is the API running? Otherwise check knowledge.swagger.url in\n${configLabel(configPath)}.`,
|
|
244
|
-
],
|
|
245
|
-
verbose,
|
|
246
|
-
});
|
|
247
|
-
}
|
package/src/config/load.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config discovery and loading. Env needs no step of its own:
|
|
3
|
-
* discovery is cwd-only and Bun's `.env` auto-load is cwd-relative, so the two
|
|
4
|
-
* are always colocated and `process.env` is populated before the config
|
|
5
|
-
* evaluates.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { relative, resolve } from "node:path";
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
|
-
|
|
12
|
-
import { Option } from "@commander-js/extra-typings";
|
|
13
|
-
import type { Command } from "@commander-js/extra-typings";
|
|
14
|
-
import { isRecord } from "@cortex/contracts/json";
|
|
15
|
-
|
|
16
|
-
import { validateConfig } from "@/config/validate";
|
|
17
|
-
import * as report from "@/ui/report";
|
|
18
|
-
|
|
19
|
-
// Discovery is this exact name in the cwd, with no upward walk, so the flag's
|
|
20
|
-
// default *is* the discovery rule.
|
|
21
|
-
const DEFAULT_CONFIG = "./cortex.config.ts";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* `-c, --config <path>` for a command group that reads the project: declared
|
|
25
|
-
* on the group, so `cortex new` and `cortex cc` never show it. Commander hands
|
|
26
|
-
* a verb's unknown options back to its parent, so `swagger -c x sync` and
|
|
27
|
-
* `swagger sync -c x` both land on the group.
|
|
28
|
-
*/
|
|
29
|
-
export function configOption() {
|
|
30
|
-
// The second argument is what help prints; without it commander would
|
|
31
|
-
// JSON.stringify the path and show quotes.
|
|
32
|
-
return new Option("-c, --config <path>", "Path to cortex.config.ts").default(
|
|
33
|
-
DEFAULT_CONFIG,
|
|
34
|
-
DEFAULT_CONFIG,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** How a config path is printed once found: relative to the cwd, `./`-prefixed. */
|
|
39
|
-
export function configLabel(path: string) {
|
|
40
|
-
const relativePath = relative(process.cwd(), path);
|
|
41
|
-
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* `./cortex.config.ts` in the cwd, that exact name — no upward walk and no
|
|
46
|
-
* extension fallback, so a run from the wrong directory fails loudly instead of
|
|
47
|
-
* silently picking up an ancestor's config. `--config` replaces the path
|
|
48
|
-
* outright and accepts anything Bun can import.
|
|
49
|
-
*/
|
|
50
|
-
function findConfig(options: { path: string; explicit: boolean; command: string }) {
|
|
51
|
-
const { path, explicit, command } = options;
|
|
52
|
-
const absolute = resolve(process.cwd(), path);
|
|
53
|
-
if (existsSync(absolute)) return absolute;
|
|
54
|
-
|
|
55
|
-
if (explicit) {
|
|
56
|
-
report.fail({
|
|
57
|
-
what: `No config file at ${path}`,
|
|
58
|
-
blocks: [
|
|
59
|
-
"--config takes a path to a file Bun can import, resolved from the\ndirectory you run in.",
|
|
60
|
-
],
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
report.fail({
|
|
65
|
-
what: `No cortex.config.ts in ${process.cwd()}`,
|
|
66
|
-
blocks: [
|
|
67
|
-
"The CLI looks for exactly ./cortex.config.ts in the directory you run in.\nIt does not search parent directories.",
|
|
68
|
-
"Run it from the project root, or point at the file:",
|
|
69
|
-
` ${command} --config ./server/cortex.config.ts`,
|
|
70
|
-
],
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* What every project command does before its own work: find the config from
|
|
76
|
-
* its group's `--config`, open the report, load and validate it, and report
|
|
77
|
-
* its path as the first input. Discovery runs before the report opens: with
|
|
78
|
-
* no config there is no run to report on, only the reason there isn't one.
|
|
79
|
-
*/
|
|
80
|
-
export async function openProject(group: Command, title: string) {
|
|
81
|
-
// The group and the root reach this file untyped: `Command` here is the
|
|
82
|
-
// parameter type, not cli.ts's inferred one.
|
|
83
|
-
const { config: path, verbose = false } = group.optsWithGlobals() as {
|
|
84
|
-
config: string;
|
|
85
|
-
verbose?: boolean;
|
|
86
|
-
};
|
|
87
|
-
const explicit = group.getOptionValueSource("config") !== "default";
|
|
88
|
-
const configPath = findConfig({ path, explicit, command: title });
|
|
89
|
-
|
|
90
|
-
report.header(title);
|
|
91
|
-
const config = await loadConfig(configPath, verbose);
|
|
92
|
-
validateConfig(config, configLabel(configPath));
|
|
93
|
-
report.input("Config", configLabel(configPath));
|
|
94
|
-
|
|
95
|
-
return { config, configPath, path, explicit, verbose };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The whole loader: one import, one default export, no unwrap branch. */
|
|
99
|
-
async function loadConfig(path: string, verbose: boolean) {
|
|
100
|
-
try {
|
|
101
|
-
const module = (await import(pathToFileURL(path).href)) as { default?: unknown };
|
|
102
|
-
const config = module.default;
|
|
103
|
-
|
|
104
|
-
if (config === undefined) throw new Error("The module has no default export.");
|
|
105
|
-
if (!isRecord(config)) throw new Error("The default export is not an object.");
|
|
106
|
-
|
|
107
|
-
return config;
|
|
108
|
-
} catch (error) {
|
|
109
|
-
report.fail({
|
|
110
|
-
what: `Could not load ${configLabel(path)}`,
|
|
111
|
-
error,
|
|
112
|
-
blocks: [
|
|
113
|
-
// The line above this one is the underlying error, and for the most
|
|
114
|
-
// common failure it reads `Cannot find package '@m6d/cortex-server'` —
|
|
115
|
-
// the config imports the server for `defineAgent`. So the install is
|
|
116
|
-
// named here rather than left to be inferred.
|
|
117
|
-
"Importing the file has to run cleanly on its own: it default-exports a\nplain CortexConfig object and nothing else, and every package it imports\nhas to be installed — `bun add @m6d/cortex-server` if that is what is\nmissing above.",
|
|
118
|
-
],
|
|
119
|
-
verbose,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
package/src/config/validate.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One generic rule: a key present with an `undefined` value
|
|
3
|
-
* is a failure, listed by full path. It is essentially always the
|
|
4
|
-
* `process.env["X"]!` pattern, where `!` silences the type error and `undefined`
|
|
5
|
-
* flows on to fail far from its cause. Absent keys are fine — that is how
|
|
6
|
-
* optional sections are expressed.
|
|
7
|
-
*
|
|
8
|
-
* The error names config paths only. Annotating each with the env var behind it
|
|
9
|
-
* is not implementable: the CLI imports an already-evaluated object, so the name
|
|
10
|
-
* is gone. Point at `.env` and stop.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import * as report from "@/ui/report";
|
|
14
|
-
|
|
15
|
-
/** `label` is how the config file is named in the failure: the `./`-relative path the loader prints. */
|
|
16
|
-
export function validateConfig(config: Record<string, unknown>, label: string) {
|
|
17
|
-
const paths = findUndefined(config, "", new WeakSet());
|
|
18
|
-
if (!paths.length) return;
|
|
19
|
-
|
|
20
|
-
report.fail({
|
|
21
|
-
what: `${label} has ${report.plural(paths.length, "undefined value")}`,
|
|
22
|
-
blocks: [
|
|
23
|
-
paths.map((entry) => ` ${entry}`).join("\n"),
|
|
24
|
-
"These keys are present but evaluate to undefined — usually a missing\nenvironment variable. Bun loads .env from the directory you run in; check\nthat ./.env exists and is complete.",
|
|
25
|
-
],
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// `seen` guards the domain graph, where concepts and endpoints reference each
|
|
30
|
-
// other in cycles.
|
|
31
|
-
function findUndefined(value: object, prefix: string, seen: WeakSet<object>): string[] {
|
|
32
|
-
if (seen.has(value)) return [];
|
|
33
|
-
seen.add(value);
|
|
34
|
-
|
|
35
|
-
return (Object.entries(value) as [string, unknown][]).flatMap(([key, child]) => {
|
|
36
|
-
const path = prefix ? `${prefix}.${key}` : key;
|
|
37
|
-
if (child === undefined) return [path];
|
|
38
|
-
if (typeof child !== "object" || child === null) return [];
|
|
39
|
-
// Domain data is built by the server's `define*` helpers, which write
|
|
40
|
-
// every optional key whether or not it was passed. `undefined` under a
|
|
41
|
-
// `__brand` means "this endpoint mutates nothing", never a missing env
|
|
42
|
-
// var, so the rule stops at the seed data it would otherwise drown in.
|
|
43
|
-
if ("__brand" in child) return [];
|
|
44
|
-
return findUndefined(child, path, seen);
|
|
45
|
-
});
|
|
46
|
-
}
|