@m6d/cortex-cli 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold and operate Cortex servers",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cc/client.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
+ import { isRecord } from "@cortex/contracts/json";
6
+
5
7
  import { profilesPath } from "./profiles";
6
8
 
7
9
  export type OpenApiOperation = {
@@ -58,31 +60,18 @@ function cachePath(url: string) {
58
60
  );
59
61
  }
60
62
 
61
- /**
62
- * Enough of an OpenAPI document to build commands from: `info.version`, a
63
- * `paths` object whose every operation has an `operationId` and, where
64
- * present, object-shaped `parameters`, `requestBody`, and `components`.
65
- * Deeper schema shapes are read defensively by the flag builder.
66
- */
67
- function isOpenApiDocument(value: unknown): value is OpenApiDocument {
68
- if (!isRecord(value)) return false;
69
- const { info, paths, components } = value;
70
- if (!isRecord(info) || typeof info["version"] !== "string") return false;
71
- if (components !== undefined) {
72
- if (!isRecord(components)) return false;
73
- const { schemas } = components;
74
- if (
75
- schemas !== undefined &&
76
- !(isRecord(schemas) && Object.values(schemas).every(isJsonSchema))
77
- )
78
- return false;
79
- }
80
- return (
81
- isRecord(paths) &&
82
- Object.values(paths).every(
83
- (methods) => isRecord(methods) && Object.values(methods).every(isOperation),
84
- )
85
- );
63
+ type Check = (value: unknown) => boolean;
64
+
65
+ const isString = (value: unknown) => typeof value === "string";
66
+ const optional = (check: Check) => (value: unknown) => value === undefined || check(value);
67
+ const arrayOf = (check: Check) => (value: unknown) => Array.isArray(value) && value.every(check);
68
+ const recordOf = (check: Check) => (value: unknown) =>
69
+ isRecord(value) && Object.values(value).every(check);
70
+ const isStrings = arrayOf(isString);
71
+
72
+ /** A record whose every listed field passes its check; fields not listed are ignored. */
73
+ function hasFields(value: unknown, fields: Record<string, Check>) {
74
+ return isRecord(value) && Object.entries(fields).every(([key, check]) => check(value[key]));
86
75
  }
87
76
 
88
77
  /**
@@ -91,83 +80,62 @@ function isOpenApiDocument(value: unknown): value is OpenApiDocument {
91
80
  * declared shape when present. Checked recursively so a nested `null` never
92
81
  * reaches it.
93
82
  */
94
- function isJsonSchema(value: unknown): value is JsonSchema {
95
- return isRecord(value) && Object.entries(SCHEMA_FIELDS).every(([key, ok]) => ok(value[key]));
96
- }
97
-
98
- const isString = (value: unknown) => typeof value === "string";
99
- const isStrings = (value: unknown) => Array.isArray(value) && value.every(isString);
100
- const optional = (check: (value: unknown) => boolean) => (value: unknown) =>
101
- value === undefined || check(value);
102
-
103
- /** One check per field the flag builder reads; anything else in the schema is ignored. */
104
83
  const SCHEMA_FIELDS = {
105
84
  type: optional((v) => isString(v) || isStrings(v)),
106
- properties: optional((v) => isRecord(v) && Object.values(v).every(isJsonSchema)),
85
+ properties: optional(recordOf(isJsonSchema)),
107
86
  required: optional(isStrings),
108
87
  enum: optional(Array.isArray),
109
88
  items: optional(isJsonSchema),
110
- anyOf: optional((v) => Array.isArray(v) && v.every(isJsonSchema)),
89
+ anyOf: optional(arrayOf(isJsonSchema)),
111
90
  description: optional(isString),
112
91
  format: optional(isString),
113
92
  $ref: optional(isString),
114
- } satisfies Record<string, (value: unknown) => boolean>;
93
+ } satisfies Record<string, Check>;
115
94
 
116
- function isContent(value: unknown): value is Record<string, { schema: JsonSchema }> {
117
- return (
118
- isRecord(value) &&
119
- Object.values(value).every((media) => isRecord(media) && isJsonSchema(media["schema"]))
120
- );
95
+ function isJsonSchema(value: unknown): value is JsonSchema {
96
+ return hasFields(value, SCHEMA_FIELDS);
121
97
  }
122
98
 
123
- function isOperation(value: unknown): value is OpenApiOperation {
124
- if (!isRecord(value) || typeof value["operationId"] !== "string") return false;
125
- const { summary, tags, parameters, requestBody } = value;
126
- const publishProcedure = value["x-publish-procedure"];
127
- const liveFields = value["x-live-fields"];
128
- if (summary !== undefined && typeof summary !== "string") return false;
129
- if (publishProcedure !== undefined && typeof publishProcedure !== "string") return false;
130
- if (
131
- liveFields !== undefined &&
132
- !(Array.isArray(liveFields) && liveFields.every((f) => typeof f === "string"))
133
- )
134
- return false;
135
- if (tags !== undefined && !(Array.isArray(tags) && tags.every((t) => typeof t === "string")))
136
- return false;
137
- if (
138
- parameters !== undefined &&
139
- !(
140
- Array.isArray(parameters) &&
141
- parameters.every(
142
- (p) =>
143
- isRecord(p) &&
144
- typeof p["name"] === "string" &&
145
- typeof p["in"] === "string" &&
146
- (p["required"] === undefined || typeof p["required"] === "boolean") &&
147
- (p["schema"] === undefined || isJsonSchema(p["schema"])) &&
148
- (p["content"] === undefined || isContent(p["content"])),
149
- )
150
- )
151
- )
152
- return false;
153
- return (
154
- requestBody === undefined || (isRecord(requestBody) && isContent(requestBody["content"]))
155
- );
156
- }
99
+ const isContent = recordOf((media) => hasFields(media, { schema: isJsonSchema }));
157
100
 
158
- function isCached(value: unknown): value is Cached {
159
- return (
160
- isRecord(value) && typeof value["etag"] === "string" && isOpenApiDocument(value["document"])
161
- );
101
+ const PARAMETER_FIELDS = {
102
+ name: isString,
103
+ in: isString,
104
+ required: optional((v) => typeof v === "boolean"),
105
+ schema: optional(isJsonSchema),
106
+ content: optional(isContent),
107
+ } satisfies Record<string, Check>;
108
+
109
+ const OPERATION_FIELDS = {
110
+ operationId: isString,
111
+ summary: optional(isString),
112
+ tags: optional(isStrings),
113
+ "x-publish-procedure": optional(isString),
114
+ "x-live-fields": optional(isStrings),
115
+ parameters: optional(arrayOf((parameter) => hasFields(parameter, PARAMETER_FIELDS))),
116
+ requestBody: optional((v) => hasFields(v, { content: isContent })),
117
+ } satisfies Record<string, Check>;
118
+
119
+ /**
120
+ * Enough of an OpenAPI document to build commands from: `info.version`, and a
121
+ * `paths` object whose every operation has an `operationId`. Every field the
122
+ * command and flag builders read, down through `components` schemas, is
123
+ * checked for the shape they expect.
124
+ */
125
+ function isOpenApiDocument(value: unknown): value is OpenApiDocument {
126
+ return hasFields(value, {
127
+ info: (v) => hasFields(v, { version: isString }),
128
+ paths: recordOf(recordOf((operation) => hasFields(operation, OPERATION_FIELDS))),
129
+ components: optional((v) => hasFields(v, { schemas: optional(recordOf(isJsonSchema)) })),
130
+ });
162
131
  }
163
132
 
164
- export function isRecord(value: unknown): value is Record<string, unknown> {
165
- return typeof value === "object" && value !== null && !Array.isArray(value);
133
+ function isCached(value: unknown): value is Cached {
134
+ return hasFields(value, { etag: isString, document: isOpenApiDocument });
166
135
  }
167
136
 
168
- /** A cache entry that fails to parse or has the wrong shape is treated as absent. */
137
+ /** A cache entry that is missing, fails to parse, or has the wrong shape is treated as absent. */
169
138
  function readCache(path: string) {
170
- if (!existsSync(path)) return undefined;
171
139
  try {
172
140
  const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
173
141
  return isCached(parsed) ? parsed : undefined;
@@ -180,7 +148,7 @@ function readCache(path: string) {
180
148
  * The management API's OpenAPI document, which is what the command tree is
181
149
  * built from. Revalidated with `If-None-Match` on every run and cached beside
182
150
  * the profiles, so a second run costs one 304 and an offline run still has
183
- * commands. `null` when neither the network nor the cache can supply it.
151
+ * commands. Throws when neither the network nor the cache can supply it.
184
152
  */
185
153
  export async function loadOpenApi(url: string) {
186
154
  const path = cachePath(url);
@@ -197,7 +165,7 @@ export async function loadOpenApi(url: string) {
197
165
  writeCache(path, { etag: response.headers.get("etag") ?? "", document });
198
166
  return { document, from: "network" as const };
199
167
  } catch (error) {
200
- if (cached) return { document: cached.document, from: "stale" as const, error };
168
+ if (cached) return { document: cached.document, from: "stale" as const };
201
169
  throw error;
202
170
  }
203
171
  }
@@ -238,8 +206,8 @@ export function inputSchemaOf(operation: OpenApiOperation) {
238
206
  * One management call. A query's input object is spread over the query
239
207
  * string, one parameter per field, with a scalar written plainly and
240
208
  * anything else JSON-encoded, which is how the document declares it. A
241
- * mutation posts its input as the JSON body. The error body's message on
242
- * failure.
209
+ * mutation posts its input as the JSON body. A refusal throws
210
+ * `ManagementError` with the error body's code and message.
243
211
  */
244
212
  export async function callManagement(
245
213
  profile: { url: string; key: string },
package/src/cc/flags.ts CHANGED
@@ -86,22 +86,19 @@ export function parseAnswer(flag: Flag, answer: string) {
86
86
  return ANSWER_PARSERS[flag.kind](text, flag);
87
87
  }
88
88
 
89
- const finite = (text: string) => (Number.isFinite(Number(text)) ? Number(text) : undefined);
90
- const json = (text: string) => {
91
- try {
92
- return JSON.parse(text) as unknown;
93
- } catch {
94
- return undefined;
95
- }
96
- };
97
-
98
89
  /** One parser per flag kind; booleans and enums must match a choice, arrays split on commas. */
99
90
  const ANSWER_PARSERS = {
100
91
  boolean: (text) => ({ true: true, false: false })[text],
101
92
  enum: (text, flag) => (flag.choices?.includes(text) ? text : undefined),
102
- number: finite,
93
+ number: (text) => (Number.isFinite(Number(text)) ? Number(text) : undefined),
103
94
  array: (text) => text.split(",").map((item) => item.trim()),
104
- json,
95
+ json: (text) => {
96
+ try {
97
+ return JSON.parse(text) as unknown;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ },
105
102
  string: (text) => text,
106
103
  } satisfies Record<Flag["kind"], (text: string, flag: Flag) => unknown>;
107
104
 
package/src/cc/output.ts CHANGED
@@ -1,14 +1,11 @@
1
+ import { isRecord } from "@cortex/contracts/json";
2
+
1
3
  import * as report from "@/ui/report";
2
4
 
3
5
  type Listing = { items: Array<Record<string, unknown>>; count: number; filteredCount: number };
4
6
 
5
7
  function isListing(value: unknown): value is Listing {
6
- return (
7
- typeof value === "object" &&
8
- value !== null &&
9
- "items" in value &&
10
- Array.isArray(value.items)
11
- );
8
+ return isRecord(value) && Array.isArray(value["items"]);
12
9
  }
13
10
 
14
11
  /**
@@ -2,6 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
+ import { isRecord } from "@cortex/contracts/json";
6
+
5
7
  type Profile = { url: string; key: string };
6
8
  type Store = { current: string; profiles: Record<string, Profile> };
7
9
 
@@ -19,22 +21,17 @@ export class ProfileFileError extends Error {
19
21
  }
20
22
 
21
23
  function isProfile(value: unknown): value is Profile {
22
- return (
23
- typeof value === "object" &&
24
- value !== null &&
25
- typeof (value as Record<string, unknown>)["url"] === "string" &&
26
- typeof (value as Record<string, unknown>)["key"] === "string"
27
- );
24
+ if (!isRecord(value)) return false;
25
+ const { url, key } = value;
26
+ return typeof url === "string" && typeof key === "string";
28
27
  }
29
28
 
30
29
  function isStore(value: unknown): value is Store {
31
- if (typeof value !== "object" || value === null) return false;
32
- const { current, profiles } = value as Record<string, unknown>;
30
+ if (!isRecord(value)) return false;
31
+ const { current, profiles } = value;
33
32
  return (
34
33
  typeof current === "string" &&
35
- typeof profiles === "object" &&
36
- profiles !== null &&
37
- !Array.isArray(profiles) &&
34
+ isRecord(profiles) &&
38
35
  Object.values(profiles).every(isProfile)
39
36
  );
40
37
  }
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
3
  import { type Command, Option } from "@commander-js/extra-typings";
4
- import { isTlsOrLoopback } from "@cortex/contracts/egress";
4
+ import { canCarrySecret } from "@cortex/contracts/egress";
5
+ import { isRecord } from "@cortex/contracts/json";
5
6
 
6
- import { callManagement, inputSchemaOf, isRecord, loadOpenApi, ManagementError } from "@/cc/client";
7
+ import { callManagement, inputSchemaOf, loadOpenApi, ManagementError } from "@/cc/client";
7
8
  import type { OpenApiDocument, OpenApiOperation } from "@/cc/client";
8
9
  import { flagsFor, parseAnswer, toOption } from "@/cc/flags";
9
10
  import type { Flag } from "@/cc/flags";
@@ -166,7 +167,7 @@ function withProfileFile<T>(operation: () => T) {
166
167
 
167
168
  /** The key rides on every call, so the URL it goes to must be https (or loopback). */
168
169
  function requireTls(url: string) {
169
- if (isTlsOrLoopback(url)) return url;
170
+ if (canCarrySecret(url, false)) return url;
170
171
  return report.fail({
171
172
  what: `${url} is not https`,
172
173
  blocks: [
@@ -191,13 +192,13 @@ function requireProfile(name: string | undefined) {
191
192
  return profile;
192
193
  }
193
194
 
194
- type Resolved = NonNullable<ReturnType<typeof resolveProfile>>;
195
+ type ResolvedProfile = NonNullable<ReturnType<typeof resolveProfile>>;
195
196
 
196
197
  type CcGroup = Command<[], { profile?: string; output: "table" | "json" }>;
197
198
 
198
199
  type Operation = { path: string; method: "get" | "post"; operation: OpenApiOperation };
199
200
 
200
- async function buildResourceCommands(cc: CcGroup, profile: Resolved) {
201
+ async function buildResourceCommands(cc: CcGroup, profile: ResolvedProfile) {
201
202
  const loaded = await loadOpenApi(profile.url).catch((error: unknown) =>
202
203
  report.fail({
203
204
  what: `Could not read the management API at ${profile.url}`,
@@ -210,14 +211,15 @@ async function buildResourceCommands(cc: CcGroup, profile: Resolved) {
210
211
  if (loaded.from === "stale")
211
212
  report.aside(`Using cached commands; ${profile.url} is unreachable.`);
212
213
  const { document } = loaded;
214
+ const byRouter = groupByRouter(document);
213
215
 
214
216
  // Every operation by procedure path, so a write can find the operation
215
217
  // its `x-publish-procedure` names and invoke it by that operation's own
216
218
  // method and path.
217
219
  const byProcedure = new Map(
218
- [...groupByRouter(document).values()].flat().map((o) => [o.operation.operationId, o]),
220
+ [...byRouter.values()].flat().map((o) => [o.operation.operationId, o]),
219
221
  );
220
- for (const [router, operations] of groupByRouter(document)) {
222
+ for (const [router, operations] of byRouter) {
221
223
  const group = cc.command(router).description(`${router} procedures`).helpCommand(false);
222
224
  for (const operation of operations) {
223
225
  registerOperation({ cc, group, router, operation, byProcedure, document, profile });
@@ -252,7 +254,7 @@ function registerOperation(scope: {
252
254
  operation: Operation;
253
255
  byProcedure: Map<string, Operation>;
254
256
  document: OpenApiDocument;
255
- profile: Resolved;
257
+ profile: ResolvedProfile;
256
258
  }) {
257
259
  const { cc, group, router, byProcedure, document, profile } = scope;
258
260
  const { path, method, operation } = scope.operation;
@@ -341,7 +343,7 @@ function declareCommand(
341
343
  * since "draft" would understate what just happened.
342
344
  */
343
345
  async function publishAfterWrite(
344
- profile: Resolved,
346
+ profile: ResolvedProfile,
345
347
  publisher: Operation,
346
348
  result: unknown,
347
349
  publish: boolean,
@@ -372,7 +374,7 @@ async function publishAfterWrite(
372
374
  * A management refusal, or a Control Center that cannot be reached, is the
373
375
  * run's outcome, in the error grammar; anything else is a bug.
374
376
  */
375
- async function callOrFail<T>(profile: Resolved, call: () => Promise<T>) {
377
+ async function callOrFail<T>(profile: ResolvedProfile, call: () => Promise<T>) {
376
378
  try {
377
379
  return await call();
378
380
  } catch (error) {
@@ -475,7 +477,12 @@ function readJsonBody(source: string) {
475
477
  return parsed;
476
478
  }
477
479
 
478
- async function walkPages(profile: Resolved, method: "get" | "post", path: string, body: unknown) {
480
+ async function walkPages(
481
+ profile: ResolvedProfile,
482
+ method: "get" | "post",
483
+ path: string,
484
+ body: unknown,
485
+ ) {
479
486
  const given = isRecord(body) ? body : {};
480
487
  const pageSize = typeof given["pageSize"] === "number" ? given["pageSize"] : 100;
481
488
  const items = new Array<unknown>();
@@ -62,12 +62,13 @@ async function seed(group: Command) {
62
62
  const { config: loaded, configPath, verbose } = await openProject(group, TITLE);
63
63
  const config = loaded as ProjectConfig;
64
64
 
65
- // Inputs are reported as they resolve, and only when the config has them:
66
- // an absent section is `runSeed`'s error to throw, not ours to predict.
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
67
  if (config.neo4j?.url) report.input("Neo4j", config.neo4j.url);
68
68
  const domains = collectDomains(config);
69
69
  const names = Object.keys(domains);
70
- if (names.length) report.input("Domains", `${names.length} — ${names.join(", ")}`);
70
+ if (!names.length) failMissingDomains(configPath);
71
+ report.input("Domains", `${names.length} — ${names.join(", ")}`);
71
72
 
72
73
  const startedAt = Date.now();
73
74
  const result = await runSeed(config, domains).catch(function (error: unknown): never {
@@ -121,7 +122,7 @@ function truncate(text: string) {
121
122
 
122
123
  /** Server level first, then per agent — an agent's domain of the same name wins. */
123
124
  function collectDomains(config: ProjectConfig) {
124
- const all: Record<string, DomainDef> = { ...config.knowledge?.domains };
125
+ const all = { ...config.knowledge?.domains };
125
126
 
126
127
  for (const agent of Object.values(config.agents ?? {})) {
127
128
  Object.assign(all, agent.knowledge?.domains);
@@ -136,7 +137,6 @@ function collectDomains(config: ProjectConfig) {
136
137
  * come from, and the version it seeds at is the version of that dependency.
137
138
  */
138
139
  async function runSeed(config: ProjectConfig, domains: Record<string, DomainDef>) {
139
- if (!Object.keys(domains).length) throw new Error("No domains found in knowledge config");
140
140
  if (!config.neo4j) throw new Error("neo4j config is required for graph seed");
141
141
  if (!config.embedding) throw new Error("embedding config is required for graph seed");
142
142
 
@@ -182,8 +182,19 @@ async function warnOnRestamp(neo4j: Neo4jClient) {
182
182
  report.row("", " Nodes from the old schema are not removed.", DOMAIN_COLUMN);
183
183
  }
184
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
+
185
196
  /**
186
- * Section requirements are `runSeed`'s to enforce — it already throws
197
+ * The neo4j and embedding sections are `runSeed`'s to require — it throws
187
198
  * `"neo4j config is required for graph seed"`. This renders what it threw.
188
199
  */
189
200
  function failSeed(options: {
@@ -195,18 +206,6 @@ function failSeed(options: {
195
206
  const { error, config, configPath, verbose } = options;
196
207
  const label = configLabel(configPath);
197
208
  const message = error instanceof Error ? error.message : String(error);
198
-
199
- if (message === "No domains found in knowledge config") {
200
- report.fail({
201
- what: `${label} has no knowledge.domains`,
202
- blocks: [
203
- "`graph seed` loads domain definitions into Neo4j. Add them:",
204
- " knowledge: { domains: { leaves: leavesDomain } }",
205
- "See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
206
- ],
207
- });
208
- }
209
-
210
209
  const url = config.neo4j?.url;
211
210
  if (url !== undefined && UNREACHABLE.test(message)) {
212
211
  report.fail({
@@ -68,9 +68,8 @@ type Options = {
68
68
  async function scaffold(name: string | undefined, options: Options) {
69
69
  const { verbose, databasePassed } = options;
70
70
 
71
- // No TTY ⇒ never prompt and behave as `--yes`. On Bun a
72
- // piped stdin reports `undefined` rather than `false`, which is why this
73
- // compares against `true`.
71
+ // No TTY ⇒ never prompt and behave as `--yes`. On Bun a piped stdin reports
72
+ // `undefined` rather than `false`, which is why this compares against `true`.
74
73
  const interactive = process.stdin.isTTY === true && options.yes !== true;
75
74
 
76
75
  if (name === undefined && !interactive) missingName();
@@ -293,9 +292,9 @@ function isInsideWorkTree(target: string) {
293
292
  }
294
293
 
295
294
  function missingName(): never {
296
- // No TTY ⇒ never prompt, so a missing required argument is a hard error
297
- // With a TTY this branch is only reached under `--yes`,
298
- // which answers every question including the one that has no default.
295
+ // No TTY ⇒ never prompt, so a missing required argument is a hard error.
296
+ // With a TTY this branch is only reached under `--yes`, which answers every
297
+ // question including the one that has no default.
299
298
  report.fail({
300
299
  what:
301
300
  process.stdin.isTTY === true
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { type Command, Option } from "@commander-js/extra-typings";
5
5
 
6
6
  import { configLabel, configOption, openProject } from "@/config/load";
7
- import { extractEndpoints } from "@/swagger/extract-endpoints";
7
+ import { declaredRoute, extractEndpoints } from "@/swagger/extract-endpoints";
8
8
  import * as report from "@/ui/report";
9
9
 
10
10
  /** A convention, not config: `KnowledgeConfig` has no `domainsDir`. */
@@ -60,11 +60,21 @@ async function run(group: Command, verb: "sync" | "check", domainsDirOption: str
60
60
  if (!existsSync(domainsDir)) failMissingDomainsDir(domainsDir, verb);
61
61
 
62
62
  const urls = swaggerUrls(loaded);
63
+ if (!urls.length) failMissingSwaggerUrl(configPath, verb);
63
64
  for (const url of urls) report.input("Swagger", url);
64
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.
65
71
  const startedAt = Date.now();
66
- const result = await runExtract(urls, domainsDir, verb).catch(function (error: unknown): never {
67
- return failExtract({ error, configPath, verbose, verb });
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 });
68
78
  });
69
79
 
70
80
  // Reported once the run is over because that is when the count is known —
@@ -170,28 +180,12 @@ function renderCheck(result: Report, domainsDir: string, elapsed: string, sync:
170
180
  }
171
181
 
172
182
  /**
173
- * The route a finding is about, read back the way the server's own file walk
174
- * reads it. The report is a list of paths; the route is what the reader has to
175
- * act on, and it lives in the file.
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.
176
185
  */
177
186
  function routeOf(filePath: string) {
178
- const source = readFileSync(filePath, "utf8");
179
- const method = source.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2] ?? "?";
180
- const route = source.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2] ?? "?";
181
- return `${method} ${route}`;
182
- }
183
-
184
- /**
185
- * Unlike `graph seed`, this needs nothing from the project's server — not even a
186
- * version handshake. What it writes is `.endpoint.ts` source whose generated block
187
- * has to satisfy that server's own `EndpointDef`, so a codegen that outran the
188
- * installed server is a type error in the project's `bun run check`, loudly, rather
189
- * than something to detect here.
190
- */
191
- async function runExtract(swaggerUrls: string[], domainsDir: string, verb: "sync" | "check") {
192
- if (!swaggerUrls.length) throw new Error("No swagger URLs found in knowledge config");
193
-
194
- return await extractEndpoints({ swaggerUrls, domainsDir, write: verb === "sync" });
187
+ const { method = "?", path = "?" } = declaredRoute(readFileSync(filePath, "utf8"));
188
+ return `${method} ${path}`;
195
189
  }
196
190
 
197
191
  /** Server level, then per agent, deduped — per-agent docs override on a key clash. */
@@ -225,37 +219,29 @@ function failMissingDomainsDir(domainsDir: string, verb: string): never {
225
219
  });
226
220
  }
227
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
+
228
233
  /**
229
- * Section requirements are `runExtract`'s to enforce — it already throws
230
- * `"No swagger URLs found in knowledge config"`. This renders what it threw.
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.
231
236
  */
232
- function failExtract(options: {
233
- error: unknown;
234
- configPath: string;
235
- verbose: boolean;
236
- verb: string;
237
- }): never {
238
- const { error, configPath, verbose, verb } = options;
239
- const label = configLabel(configPath);
240
- const message = error instanceof Error ? error.message : String(error);
241
-
242
- if (message === "No swagger URLs found in knowledge config") {
243
- report.fail({
244
- what: `${label} has no knowledge.swagger.url`,
245
- blocks: [
246
- `\`swagger ${verb}\` generates endpoint schemas from a Swagger spec. Add one:`,
247
- ' knowledge: { swagger: { url: "https://api.example.com/swagger/v1/swagger.json" } }',
248
- "See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
249
- ],
250
- });
251
- }
252
-
253
- // The `◇ Swagger` lines above name every URL that was tried, so the error
254
- // states the failure and points at the key behind it.
237
+ function failExtract(options: { error: unknown; configPath: string; verbose: boolean }): never {
238
+ const { error, configPath, verbose } = options;
255
239
  report.fail({
256
240
  what: "Could not read the Swagger spec",
257
241
  error,
258
- blocks: [`Is the API running? Otherwise check knowledge.swagger.url in\n${label}.`],
242
+ blocks: [
243
+ `Is the API running? Otherwise check knowledge.swagger.url in\n${configLabel(configPath)}.`,
244
+ ],
259
245
  verbose,
260
246
  });
261
247
  }
@@ -11,13 +11,13 @@ import { pathToFileURL } from "node:url";
11
11
 
12
12
  import { Option } from "@commander-js/extra-typings";
13
13
  import type { Command } from "@commander-js/extra-typings";
14
+ import { isRecord } from "@cortex/contracts/json";
14
15
 
15
16
  import { validateConfig } from "@/config/validate";
16
17
  import * as report from "@/ui/report";
17
18
 
18
19
  // Discovery is this exact name in the cwd, with no upward walk, so the flag's
19
- // default *is* the discovery rule. The second argument is what help prints;
20
- // without it commander would JSON.stringify the path and show quotes.
20
+ // default *is* the discovery rule.
21
21
  const DEFAULT_CONFIG = "./cortex.config.ts";
22
22
 
23
23
  /**
@@ -27,6 +27,8 @@ const DEFAULT_CONFIG = "./cortex.config.ts";
27
27
  * `swagger sync -c x` both land on the group.
28
28
  */
29
29
  export function configOption() {
30
+ // The second argument is what help prints; without it commander would
31
+ // JSON.stringify the path and show quotes.
30
32
  return new Option("-c, --config <path>", "Path to cortex.config.ts").default(
31
33
  DEFAULT_CONFIG,
32
34
  DEFAULT_CONFIG,
@@ -100,11 +102,9 @@ async function loadConfig(path: string, verbose: boolean) {
100
102
  const config = module.default;
101
103
 
102
104
  if (config === undefined) throw new Error("The module has no default export.");
103
- if (typeof config !== "object" || config === null || Array.isArray(config)) {
104
- throw new Error("The default export is not an object.");
105
- }
105
+ if (!isRecord(config)) throw new Error("The default export is not an object.");
106
106
 
107
- return config as Record<string, unknown>;
107
+ return config;
108
108
  } catch (error) {
109
109
  report.fail({
110
110
  what: `Could not load ${configLabel(path)}`,