@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.
Files changed (76) hide show
  1. package/README.md +193 -128
  2. package/package.json +16 -5
  3. package/src/cli.ts +19 -12
  4. package/src/commands/apply.ts +186 -0
  5. package/src/commands/{cc.ts → management.ts} +173 -112
  6. package/src/commands/new.ts +83 -84
  7. package/src/commands/setup.ts +610 -0
  8. package/src/contracts/charts/chart-spec.ts +1 -1
  9. package/src/contracts/client-tools/index.ts +3 -3
  10. package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
  11. package/src/contracts/datasets/index.ts +3 -0
  12. package/src/contracts/egress-policy/index.ts +16 -0
  13. package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
  14. package/src/contracts/{egress → egress-policy}/url.ts +22 -9
  15. package/src/contracts/errors/index.ts +38 -0
  16. package/src/contracts/json/index.ts +5 -0
  17. package/src/contracts/manifest/index.ts +973 -0
  18. package/src/contracts/manifest-file/index.ts +16 -0
  19. package/src/contracts/markdown/index.ts +1 -1
  20. package/src/contracts/pattern/index.ts +276 -0
  21. package/src/contracts/server/index.ts +349 -0
  22. package/src/contracts/server/reserved-names.fixtures.json +18 -0
  23. package/src/contracts/triggers/index.ts +112 -0
  24. package/src/contracts/wire/index.ts +67 -33
  25. package/src/{cc → management}/client.ts +33 -7
  26. package/src/{cc → management}/flags.ts +2 -1
  27. package/src/{cc → management}/profiles.ts +10 -8
  28. package/src/scaffold/files.ts +448 -284
  29. package/src/scaffold/ports.ts +12 -24
  30. package/src/scaffold/templates/cspell.json.txt +41 -0
  31. package/src/scaffold/templates/datasets.ts.txt +52 -0
  32. package/src/scaffold/templates/dockerignore.txt +6 -0
  33. package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
  34. package/src/scaffold/templates/gitignore.txt +1 -1
  35. package/src/scaffold/templates/server.ts.txt +40 -0
  36. package/src/scaffold/templates/setup.ts.txt +44 -0
  37. package/src/ui/prompts.ts +24 -38
  38. package/src/ui/report.ts +5 -15
  39. package/tsconfig.json +2 -3
  40. package/src/commands/graph.ts +0 -228
  41. package/src/commands/swagger.ts +0 -247
  42. package/src/config/load.ts +0 -122
  43. package/src/config/validate.ts +0 -46
  44. package/src/contracts/credentials/index.ts +0 -183
  45. package/src/contracts/egress/index.ts +0 -120
  46. package/src/contracts/graph/clients/embed.ts +0 -62
  47. package/src/contracts/graph/clients/neo4j.ts +0 -85
  48. package/src/contracts/graph/helpers.ts +0 -55
  49. package/src/contracts/graph/index.ts +0 -30
  50. package/src/contracts/graph/schema.ts +0 -65
  51. package/src/contracts/graph/types.ts +0 -125
  52. package/src/contracts/rich-text/index.ts +0 -220
  53. package/src/contracts/runtime/api.ts +0 -345
  54. package/src/contracts/runtime/auth.ts +0 -41
  55. package/src/contracts/runtime/grammar.ts +0 -169
  56. package/src/contracts/runtime/index.ts +0 -15
  57. package/src/contracts/runtime/models.ts +0 -50
  58. package/src/contracts/runtime/signature.ts +0 -255
  59. package/src/contracts/runtime/sync.ts +0 -196
  60. package/src/contracts/runtime/triggers.ts +0 -267
  61. package/src/graph/expand-domains.ts +0 -286
  62. package/src/graph/generate-cypher.ts +0 -201
  63. package/src/graph/seed.ts +0 -199
  64. package/src/graph/validate.ts +0 -78
  65. package/src/scaffold/features.ts +0 -190
  66. package/src/scaffold/templates/domains-README.md.txt +0 -16
  67. package/src/scaffold/templates/index.ts.txt +0 -18
  68. package/src/swagger/extract-endpoints.ts +0 -269
  69. package/src/swagger/generated-block.ts +0 -74
  70. package/src/swagger/openapi-schema.ts +0 -168
  71. package/src/swagger/parse-response.ts +0 -88
  72. package/src/swagger/types.ts +0 -25
  73. /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
  74. /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
  75. /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
  76. /package/src/{cc → management}/output.ts +0 -0
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }