@m6d/cortex-cli 0.3.0 → 0.4.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 +1 -1
- package/src/cc/client.ts +58 -90
- package/src/cc/flags.ts +8 -11
- package/src/cc/output.ts +3 -6
- package/src/cc/profiles.ts +8 -11
- package/src/commands/cc.ts +18 -11
- package/src/commands/graph.ts +17 -18
- package/src/commands/new.ts +5 -6
- package/src/commands/swagger.ts +35 -49
- package/src/config/load.ts +6 -6
- package/src/config/validate.ts +2 -2
- package/src/contracts/charts/chart-spec.ts +9 -6
- package/src/contracts/credentials/index.ts +22 -5
- package/src/contracts/egress/index.ts +8 -47
- package/src/contracts/egress/scrub.ts +3 -1
- package/src/contracts/egress/url.ts +101 -9
- package/src/contracts/graph/helpers.ts +8 -10
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/runtime/api.ts +2 -2
- package/src/contracts/runtime/dataset-sql.ts +49 -70
- package/src/contracts/runtime/datasets.ts +33 -43
- package/src/contracts/runtime/grammar.ts +12 -11
- package/src/contracts/runtime/signature.ts +7 -8
- package/src/contracts/wire/index.ts +12 -5
- package/src/graph/expand-domains.ts +7 -9
- package/src/graph/generate-cypher.ts +1 -1
- package/src/graph/seed.ts +6 -14
- package/src/scaffold/files.ts +19 -25
- package/src/swagger/extract-endpoints.ts +79 -76
- package/src/swagger/openapi-schema.ts +21 -14
- package/src/swagger/parse-response.ts +1 -1
- package/src/ui/report.ts +1 -1
package/package.json
CHANGED
package/src/cc/client.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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((
|
|
85
|
+
properties: optional(recordOf(isJsonSchema)),
|
|
107
86
|
required: optional(isStrings),
|
|
108
87
|
enum: optional(Array.isArray),
|
|
109
88
|
items: optional(isJsonSchema),
|
|
110
|
-
anyOf: optional((
|
|
89
|
+
anyOf: optional(arrayOf(isJsonSchema)),
|
|
111
90
|
description: optional(isString),
|
|
112
91
|
format: optional(isString),
|
|
113
92
|
$ref: optional(isString),
|
|
114
|
-
} satisfies Record<string,
|
|
93
|
+
} satisfies Record<string, Check>;
|
|
115
94
|
|
|
116
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
return
|
|
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.
|
|
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
|
|
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.
|
|
242
|
-
*
|
|
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:
|
|
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
|
/**
|
package/src/cc/profiles.ts
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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 (
|
|
32
|
-
const { current, profiles } = value
|
|
30
|
+
if (!isRecord(value)) return false;
|
|
31
|
+
const { current, profiles } = value;
|
|
33
32
|
return (
|
|
34
33
|
typeof current === "string" &&
|
|
35
|
-
|
|
36
|
-
profiles !== null &&
|
|
37
|
-
!Array.isArray(profiles) &&
|
|
34
|
+
isRecord(profiles) &&
|
|
38
35
|
Object.values(profiles).every(isProfile)
|
|
39
36
|
);
|
|
40
37
|
}
|
package/src/commands/cc.ts
CHANGED
|
@@ -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 {
|
|
4
|
+
import { canCarrySecret } from "@cortex/contracts/egress";
|
|
5
|
+
import { isRecord } from "@cortex/contracts/json";
|
|
5
6
|
|
|
6
|
-
import { callManagement, inputSchemaOf,
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
[...
|
|
220
|
+
[...byRouter.values()].flat().map((o) => [o.operation.operationId, o]),
|
|
219
221
|
);
|
|
220
|
-
for (const [router, operations] of
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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>();
|
package/src/commands/graph.ts
CHANGED
|
@@ -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
|
|
66
|
-
// an absent section is `runSeed`'s error to throw
|
|
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)
|
|
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
|
|
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
|
-
*
|
|
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({
|
package/src/commands/new.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
package/src/commands/swagger.ts
CHANGED
|
@@ -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
|
|
67
|
-
|
|
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
|
|
174
|
-
*
|
|
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
|
|
179
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
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
|
|
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: [
|
|
242
|
+
blocks: [
|
|
243
|
+
`Is the API running? Otherwise check knowledge.swagger.url in\n${configLabel(configPath)}.`,
|
|
244
|
+
],
|
|
259
245
|
verbose,
|
|
260
246
|
});
|
|
261
247
|
}
|
package/src/config/load.ts
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
|
107
|
+
return config;
|
|
108
108
|
} catch (error) {
|
|
109
109
|
report.fail({
|
|
110
110
|
what: `Could not load ${configLabel(path)}`,
|