@m6d/cortex-cli 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -130
- package/package.json +16 -5
- package/src/cli.ts +17 -12
- package/src/commands/apply.ts +169 -0
- package/src/commands/{cc.ts → management.ts} +150 -100
- package/src/commands/new.ts +50 -74
- package/src/contracts/charts/chart-spec.ts +1 -1
- package/src/contracts/client-tools/index.ts +3 -3
- package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
- package/src/contracts/datasets/index.ts +3 -0
- package/src/contracts/egress-policy/index.ts +16 -0
- package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
- package/src/contracts/{egress → egress-policy}/url.ts +22 -9
- package/src/contracts/errors/index.ts +38 -0
- package/src/contracts/json/index.ts +5 -0
- package/src/contracts/manifest/index.ts +932 -0
- package/src/contracts/manifest-file/index.ts +16 -0
- package/src/contracts/markdown/index.ts +1 -1
- package/src/contracts/pattern/index.ts +276 -0
- package/src/contracts/server/index.ts +247 -0
- package/src/contracts/server/reserved-names.fixtures.json +18 -0
- package/src/contracts/triggers/index.ts +112 -0
- package/src/contracts/wire/index.ts +61 -32
- package/src/{cc → management}/client.ts +19 -7
- package/src/{cc → management}/flags.ts +2 -1
- package/src/{cc → management}/profiles.ts +5 -5
- package/src/scaffold/files.ts +362 -237
- package/src/scaffold/ports.ts +13 -24
- package/src/scaffold/templates/datasets.ts.txt +52 -0
- package/src/scaffold/templates/dockerignore.txt +7 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
- package/src/scaffold/templates/gitignore.txt +2 -1
- package/src/scaffold/templates/server.ts.txt +40 -0
- package/src/scaffold/templates/setup.ts.txt +52 -0
- package/src/ui/prompts.ts +4 -31
- package/src/ui/report.ts +5 -15
- package/tsconfig.json +2 -3
- package/src/commands/graph.ts +0 -228
- package/src/commands/swagger.ts +0 -247
- package/src/config/load.ts +0 -122
- package/src/config/validate.ts +0 -46
- package/src/contracts/credentials/index.ts +0 -183
- package/src/contracts/egress/index.ts +0 -120
- package/src/contracts/graph/clients/embed.ts +0 -45
- package/src/contracts/graph/clients/neo4j.ts +0 -85
- package/src/contracts/graph/helpers.ts +0 -55
- package/src/contracts/graph/index.ts +0 -30
- package/src/contracts/graph/schema.ts +0 -65
- package/src/contracts/graph/types.ts +0 -125
- package/src/contracts/rich-text/index.ts +0 -220
- package/src/contracts/runtime/api.ts +0 -345
- package/src/contracts/runtime/auth.ts +0 -41
- package/src/contracts/runtime/grammar.ts +0 -169
- package/src/contracts/runtime/index.ts +0 -15
- package/src/contracts/runtime/models.ts +0 -50
- package/src/contracts/runtime/signature.ts +0 -255
- package/src/contracts/runtime/sync.ts +0 -196
- package/src/contracts/runtime/triggers.ts +0 -267
- package/src/graph/expand-domains.ts +0 -286
- package/src/graph/generate-cypher.ts +0 -201
- package/src/graph/seed.ts +0 -199
- package/src/graph/validate.ts +0 -78
- package/src/scaffold/features.ts +0 -190
- package/src/scaffold/templates/domains-README.md.txt +0 -16
- package/src/scaffold/templates/index.ts.txt +0 -18
- package/src/swagger/extract-endpoints.ts +0 -269
- package/src/swagger/generated-block.ts +0 -74
- package/src/swagger/openapi-schema.ts +0 -168
- package/src/swagger/parse-response.ts +0 -88
- package/src/swagger/types.ts +0 -25
- /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
- /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
- /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
- /package/src/{cc → management}/output.ts +0 -0
package/src/scaffold/ports.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host ports for the
|
|
3
|
-
* time.
|
|
2
|
+
* Host ports for the Cortex stack a generated project runs, chosen once at
|
|
3
|
+
* scaffold time.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Only the app and the engine are published: the browser and this
|
|
6
|
+
* project's callers reach them, and nothing outside the stack reaches its
|
|
7
|
+
* databases. A fixed pair would collide with the next project scaffolded on
|
|
8
|
+
* the same machine, so each project draws its own. Container ports stay
|
|
9
|
+
* canonical. The backend's own port is not one of these: it is the project's
|
|
10
|
+
* to choose, and stays the documented default.
|
|
11
11
|
*
|
|
12
12
|
* Every generated file spells its ports `{{port.<name>}}` and gets them filled in
|
|
13
|
-
* by `applyPorts`. That is the point of the token: compose and
|
|
14
|
-
* drift apart, because neither holds a number of its own.
|
|
13
|
+
* by `applyPorts`. That is the point of the token: compose and the README
|
|
14
|
+
* cannot drift apart, because neither holds a number of its own.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** High, unprivileged, and clear of the ephemeral range macOS and Linux hand out. */
|
|
@@ -51,11 +51,7 @@ function allocate(taken: Set<number>) {
|
|
|
51
51
|
return first ?? FIRST;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
* Every port a generated project can need, allocated together so they are
|
|
56
|
-
* distinct within the project. Features that were not selected simply never
|
|
57
|
-
* mention theirs.
|
|
58
|
-
*/
|
|
54
|
+
/** Every port a generated project publishes, allocated together so they are distinct. */
|
|
59
55
|
export function allocatePorts() {
|
|
60
56
|
const taken = new Set<number>();
|
|
61
57
|
|
|
@@ -65,19 +61,12 @@ export function allocatePorts() {
|
|
|
65
61
|
return port;
|
|
66
62
|
}
|
|
67
63
|
|
|
68
|
-
return {
|
|
69
|
-
postgres: next(),
|
|
70
|
-
redis: next(),
|
|
71
|
-
minio: next(),
|
|
72
|
-
minioConsole: next(),
|
|
73
|
-
neo4j: next(),
|
|
74
|
-
neo4jBolt: next(),
|
|
75
|
-
};
|
|
64
|
+
return { cortex: next(), engine: next() };
|
|
76
65
|
}
|
|
77
66
|
|
|
78
67
|
export type Ports = ReturnType<typeof allocatePorts>;
|
|
79
68
|
|
|
80
|
-
/** Fills in `{{port.
|
|
69
|
+
/** Fills in `{{port.cortex}}` and `{{port.engine}}`. Applied to every file, once, at the end. */
|
|
81
70
|
export function applyPorts(contents: string, ports: Ports) {
|
|
82
71
|
return Object.entries(ports).reduce(
|
|
83
72
|
(text, [name, port]) => text.replaceAll(`{{port.${name}}}`, String(port)),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { defineDatasetHost } from "@m6d/cortex";
|
|
3
|
+
import { drizzleDataset } from "@m6d/cortex/drizzle";
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* This backend as a dataset host: an `orders` table agents ask grouped
|
|
9
|
+
* questions of, such as revenue per product by month. It lives in memory and
|
|
10
|
+
* is seeded on every boot; point `db` and the table at your own database.
|
|
11
|
+
* The host answers whoever reaches its URL, so `resolveUser` is where yours
|
|
12
|
+
* verifies the token Cortex forwards, and `scoped` narrows rows to that user.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const orders = sqliteTable("orders", {
|
|
16
|
+
id: integer().primaryKey(),
|
|
17
|
+
product: text().notNull(),
|
|
18
|
+
quantity: integer().notNull(),
|
|
19
|
+
total: real().notNull(),
|
|
20
|
+
/** ISO text, which the dataset reads as a date. */
|
|
21
|
+
placedAt: text().notNull(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const sqlite = new Database(":memory:");
|
|
25
|
+
sqlite.run(
|
|
26
|
+
"create table orders (id integer primary key, product text not null, quantity integer not null, total real not null, placed_at text not null)",
|
|
27
|
+
);
|
|
28
|
+
const db = drizzle({ client: sqlite, casing: "snake_case" });
|
|
29
|
+
db.insert(orders)
|
|
30
|
+
.values([
|
|
31
|
+
{ product: "Notebook", quantity: 3, total: 12, placedAt: "2026-01-05" },
|
|
32
|
+
{ product: "Pen", quantity: 10, total: 15, placedAt: "2026-01-19" },
|
|
33
|
+
{ product: "Notebook", quantity: 1, total: 4, placedAt: "2026-02-02" },
|
|
34
|
+
{ product: "Backpack", quantity: 1, total: 49, placedAt: "2026-02-14" },
|
|
35
|
+
{ product: "Pen", quantity: 4, total: 6, placedAt: "2026-03-08" },
|
|
36
|
+
])
|
|
37
|
+
.run();
|
|
38
|
+
|
|
39
|
+
export const datasets = defineDatasetHost({
|
|
40
|
+
expose: [
|
|
41
|
+
drizzleDataset({
|
|
42
|
+
name: "orders",
|
|
43
|
+
description: "One row per order: what was bought, how many, for how much, and when.",
|
|
44
|
+
table: orders,
|
|
45
|
+
dialect: "sqlite",
|
|
46
|
+
db,
|
|
47
|
+
types: { placedAt: "date" },
|
|
48
|
+
scoped: () => undefined,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
resolveUser: () => ({}),
|
|
52
|
+
});
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
|
3
3
|
|
|
4
|
-
// The
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
4
|
+
// The roots everything else hangs off: the backend, the manifest
|
|
5
|
+
// `cortex apply` loads, and the env setup `bun install` runs. Left to
|
|
6
|
+
// auto-detection, framework heuristics mark far too much as reachable and
|
|
7
|
+
// unused exports stop being reportable.
|
|
8
|
+
"entry": ["index.ts", "cortex.manifest.ts", "setup.ts"],
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// zod, an extra route on `server.app`. A requirement that lives in another
|
|
13
|
-
// package's `peerDependencies` is not something reachability analysis can
|
|
14
|
-
// see, so it is stated here instead. Once your own code imports one, its
|
|
15
|
-
// entry is doing nothing and can go.
|
|
16
|
-
"ignoreDependencies": ["@tanstack/ai", "@tanstack/ai-openai", "hono", "jose", "openai", "zod"],
|
|
10
|
+
// The CLI is run as `cortex`, a binary nothing here imports, so
|
|
11
|
+
// reachability analysis cannot see that the project uses it.
|
|
12
|
+
"ignoreDependencies": ["@m6d/cortex-cli"],
|
|
17
13
|
|
|
18
14
|
"duplicates": {
|
|
19
15
|
// fallow's default is 0, which means "no limit": clone groups get printed
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineServer } from "@m6d/cortex";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This backend as a Cortex server: the variable its agents' prompts read,
|
|
6
|
+
* and the native tool it runs for them. Cortex presents CORTEX_SERVER_KEY as
|
|
7
|
+
* the bearer of every call, and nothing below runs for any other.
|
|
8
|
+
*/
|
|
9
|
+
export const server = defineServer({
|
|
10
|
+
key: process.env["CORTEX_SERVER_KEY"]!,
|
|
11
|
+
variables: {
|
|
12
|
+
// Resolved once per thread; prompts read it as {{userName}}.
|
|
13
|
+
userName: {
|
|
14
|
+
description: "Display name of the signed-in user",
|
|
15
|
+
example: "Sara",
|
|
16
|
+
type: "string",
|
|
17
|
+
resolve: ({ user }) => {
|
|
18
|
+
const name = user?.claims["name"];
|
|
19
|
+
return typeof name === "string" ? name : "guest";
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
tools: {
|
|
24
|
+
rollDice: {
|
|
25
|
+
description: "Roll dice and report each face and the total.",
|
|
26
|
+
input: z.object({
|
|
27
|
+
count: z.number().int().min(1).max(10).describe("How many dice to roll"),
|
|
28
|
+
sides: z.number().int().min(2).max(100).default(6).describe("Faces per die"),
|
|
29
|
+
}),
|
|
30
|
+
readOnly: true,
|
|
31
|
+
run: ({ count, sides }) => {
|
|
32
|
+
const faces = Array.from(
|
|
33
|
+
{ length: count },
|
|
34
|
+
() => 1 + Math.floor(Math.random() * sides),
|
|
35
|
+
);
|
|
36
|
+
return { faces, total: faces.reduce((sum, face) => sum + face, 0) };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Writes .env and cortex.env on every `bun install`: each starts from its
|
|
6
|
+
* committed .example copy, and each value the copy leaves blank is
|
|
7
|
+
* generated here, the Compose project this checkout's stack runs as among
|
|
8
|
+
* them. A value already set is never changed, so a fresh clone gets a stack
|
|
9
|
+
* and secrets of its own, and a rerun changes nothing. The one exception: a
|
|
10
|
+
* cortex.env written now holds new keys, which cannot read the volumes of the
|
|
11
|
+
* stack .env names, so the stack gets a new name too. Without the example
|
|
12
|
+
* copies, as in an image build, it writes nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const token = () => randomBytes(32).toString("base64url");
|
|
16
|
+
const key = () => randomBytes(32).toString("base64");
|
|
17
|
+
|
|
18
|
+
/** The package name as Compose takes a project name: lowercase words and hyphens. */
|
|
19
|
+
const { name } = JSON.parse(readFileSync("package.json", "utf8")) as { name: string };
|
|
20
|
+
const project =
|
|
21
|
+
name
|
|
22
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
23
|
+
.slice(0, 40)
|
|
24
|
+
.replace(/^-+|-+$/g, "") || "cortex";
|
|
25
|
+
|
|
26
|
+
const GENERATED = [
|
|
27
|
+
[".env", "COMPOSE_PROJECT_NAME", () => `${project}-${randomBytes(4).toString("hex")}`],
|
|
28
|
+
[".env", "CORTEX_SERVER_KEY", token],
|
|
29
|
+
["cortex.env", "DEFAULT_ADMIN_PASSWORD", token],
|
|
30
|
+
// Cortex takes management keys only under this prefix. cspell:ignore mgmt
|
|
31
|
+
["cortex.env", "BOOTSTRAP_MANAGEMENT_KEY", () => `cortex_mgmt_${token()}`],
|
|
32
|
+
["cortex.env", "BETTER_AUTH_SECRET", key],
|
|
33
|
+
["cortex.env", "SECRETS_MASTER_KEY", key],
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
const freshKeys = !existsSync("cortex.env") && existsSync("cortex.env.example");
|
|
37
|
+
|
|
38
|
+
for (const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source = existsSync(file) ? file : `${file}.example`;
|
|
40
|
+
if (!existsSync(source)) continue;
|
|
41
|
+
// Only its owner reads a file of secrets, however it was first created.
|
|
42
|
+
if (source === file) chmodSync(file, 0o600);
|
|
43
|
+
let text = readFileSync(source, "utf8");
|
|
44
|
+
if (file === ".env" && freshKeys) {
|
|
45
|
+
text = text.replace(/^COMPOSE_PROJECT_NAME=.*$/m, "COMPOSE_PROJECT_NAME=");
|
|
46
|
+
}
|
|
47
|
+
for (const [target, variable, generate] of GENERATED) {
|
|
48
|
+
if (target !== file) continue;
|
|
49
|
+
text = text.replace(new RegExp(`^${variable}=$`, "m"), `${variable}=${generate()}`);
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
52
|
+
}
|
package/src/ui/prompts.ts
CHANGED
|
@@ -8,20 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
cancel,
|
|
11
|
+
confirm,
|
|
11
12
|
intro,
|
|
12
13
|
isCancel,
|
|
13
14
|
log,
|
|
14
|
-
multiselect,
|
|
15
15
|
outro,
|
|
16
16
|
password,
|
|
17
|
-
select,
|
|
18
17
|
spinner,
|
|
19
18
|
text,
|
|
20
19
|
} from "@clack/prompts";
|
|
21
20
|
|
|
22
|
-
import { FEATURES, type Feature } from "@/scaffold/features";
|
|
23
|
-
import type { Database } from "@/scaffold/files";
|
|
24
|
-
|
|
25
21
|
export { intro, log, outro };
|
|
26
22
|
|
|
27
23
|
async function orExit<T>(answer: Promise<T | symbol>) {
|
|
@@ -54,32 +50,9 @@ export function askName(check: (name: string) => string | undefined) {
|
|
|
54
50
|
);
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
export function askDatabase() {
|
|
62
|
-
return orExit(
|
|
63
|
-
select<Database>({
|
|
64
|
-
message: "Database",
|
|
65
|
-
initialValue: "postgres",
|
|
66
|
-
options: [
|
|
67
|
-
{ value: "postgres", hint: "docker-compose.yml runs one for you" },
|
|
68
|
-
{ value: "mssql", hint: "normally an existing external instance" },
|
|
69
|
-
],
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** One multi-select for the whole catalogue — minus anything a flag settled. */
|
|
75
|
-
export function askFeatures(undecided: Feature[]) {
|
|
76
|
-
return orExit(
|
|
77
|
-
multiselect<Feature>({
|
|
78
|
-
message: "Which optional features?",
|
|
79
|
-
required: false,
|
|
80
|
-
options: undecided.map((feature) => ({ value: feature, hint: FEATURES[feature].hint })),
|
|
81
|
-
}),
|
|
82
|
-
);
|
|
53
|
+
/** A yes-or-no question a flag left open, answered no unless the user says otherwise. */
|
|
54
|
+
export function askConfirm(message: string) {
|
|
55
|
+
return orExit(confirm({ message, initialValue: false }));
|
|
83
56
|
}
|
|
84
57
|
|
|
85
58
|
/** A required value a command was not given: asked by name, re-asked while empty. */
|
package/src/ui/report.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The output grammar, shared by every command
|
|
2
|
+
* The output grammar, shared by every command.
|
|
3
3
|
* The report layout is deliberately frozen; this file is the only
|
|
4
4
|
* place that knows the box characters.
|
|
5
5
|
*/
|
|
@@ -31,12 +31,7 @@ function describe(error: unknown) {
|
|
|
31
31
|
// anything opened the box above it.
|
|
32
32
|
let open = false;
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
export function plural(count: number, noun: string) {
|
|
36
|
-
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** `┌ cortex graph seed` — opens a run. */
|
|
34
|
+
/** `┌ cortex login` — opens a run. */
|
|
40
35
|
export function header(title: string) {
|
|
41
36
|
open = true;
|
|
42
37
|
write(process.stdout, ["", `${pc.gray("┌")} ${title}`, BAR]);
|
|
@@ -50,20 +45,15 @@ export function input(label: string, value: string) {
|
|
|
50
45
|
write(process.stdout, [`${pc.green("◇")} ${label.padEnd(9)} ${value}`]);
|
|
51
46
|
}
|
|
52
47
|
|
|
53
|
-
export function blank() {
|
|
54
|
-
write(process.stdout, [BAR]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
48
|
/**
|
|
58
|
-
* `│
|
|
59
|
-
*
|
|
60
|
-
* is the label column: findings fit in 10, domain names need 14.
|
|
49
|
+
* `│ name orders` — a row in the body. An empty label indents to the same
|
|
50
|
+
* gutter, for a continuation line. `width` is the label column.
|
|
61
51
|
*/
|
|
62
52
|
export function row(label: string, text: string, width = 10) {
|
|
63
53
|
write(process.stdout, [`${BAR} ${label.padEnd(width - 2)} ${text}`]);
|
|
64
54
|
}
|
|
65
55
|
|
|
66
|
-
/** `└
|
|
56
|
+
/** `└ authenticated with ada@example.com` — closes a run. */
|
|
67
57
|
export function footer(text: string) {
|
|
68
58
|
write(process.stdout, [`${pc.gray("└")} ${text}`, ""]);
|
|
69
59
|
}
|
package/tsconfig.json
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
// Environment setup & latest features
|
|
6
6
|
"lib": ["ESNext"],
|
|
7
7
|
// Named explicitly: auto-inclusion of @types/* is an editor-dependent
|
|
8
|
-
// heuristic, and `Bun`/`Response` come from here.
|
|
9
|
-
// tsconfig for the same fix.
|
|
8
|
+
// heuristic, and `Bun`/`Response` come from here.
|
|
10
9
|
"types": ["bun"],
|
|
11
10
|
"target": "ESNext",
|
|
12
11
|
"module": "Preserve",
|
|
@@ -18,7 +17,7 @@
|
|
|
18
17
|
"resolveJsonModule": true,
|
|
19
18
|
"noEmit": true,
|
|
20
19
|
"paths": {
|
|
21
|
-
//
|
|
20
|
+
// @cortex/contracts is a private workspace package
|
|
22
21
|
// that never publishes, so the CLI carries its own copy: `prepack`
|
|
23
22
|
// vendors it into src/contracts, where the first candidate finds it in
|
|
24
23
|
// the published tarball. Inside the repo that copy doesn't exist and
|
package/src/commands/graph.ts
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import type { Command } from "@commander-js/extra-typings";
|
|
2
|
-
import {
|
|
3
|
-
GRAPH_SCHEMA,
|
|
4
|
-
GRAPH_SCHEMA_VERSION,
|
|
5
|
-
createEmbedder,
|
|
6
|
-
createNeo4jClient,
|
|
7
|
-
type DomainDef,
|
|
8
|
-
type EmbeddingProviderConfig,
|
|
9
|
-
type Neo4jClient,
|
|
10
|
-
type Neo4jConfig,
|
|
11
|
-
} from "@cortex/contracts/graph";
|
|
12
|
-
import pc from "picocolors";
|
|
13
|
-
|
|
14
|
-
import { configLabel, configOption, openProject } from "@/config/load";
|
|
15
|
-
import { seedGraph } from "@/graph/seed";
|
|
16
|
-
import * as report from "@/ui/report";
|
|
17
|
-
|
|
18
|
-
const TITLE = "cortex graph seed";
|
|
19
|
-
|
|
20
|
-
/** Domain names are longer than swagger's finding labels, so the body is wider. */
|
|
21
|
-
const DOMAIN_COLUMN = 14;
|
|
22
|
-
|
|
23
|
-
/** A failing statement is worth one line: enough to identify, not enough to drown. */
|
|
24
|
-
const DETAIL_WIDTH = 60;
|
|
25
|
-
|
|
26
|
-
/** Only the parts of a loaded config `graph seed` reads. */
|
|
27
|
-
type ProjectConfig = {
|
|
28
|
-
neo4j?: Neo4jConfig;
|
|
29
|
-
embedding?: EmbeddingProviderConfig & { dimension: number };
|
|
30
|
-
knowledge?: { domains?: Record<string, DomainDef> };
|
|
31
|
-
agents?: Record<string, { knowledge?: { domains?: Record<string, DomainDef> } | null }>;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Connection failures reach us as whatever `fetch` threw, so match on shape
|
|
35
|
-
// rather than on a driver's error class.
|
|
36
|
-
const UNREACHABLE =
|
|
37
|
-
/unable to connect|connection refused|econnrefused|failed to fetch|fetch failed|enotfound|getaddrinfo|timed out/i;
|
|
38
|
-
|
|
39
|
-
export function registerGraph(program: Command) {
|
|
40
|
-
const graph = program
|
|
41
|
-
.command("graph")
|
|
42
|
-
.helpGroup("Resources:")
|
|
43
|
-
.summary("The Neo4j knowledge graph")
|
|
44
|
-
.description(
|
|
45
|
-
"The Neo4j knowledge graph.\n\nReads `knowledge.domains` and `neo4j` from your cortex.config.ts.",
|
|
46
|
-
)
|
|
47
|
-
.addOption(configOption())
|
|
48
|
-
.helpCommand(false);
|
|
49
|
-
|
|
50
|
-
// No flags. `--domain <name>` is flag-shaped design work, not a flag:
|
|
51
|
-
// `expandDomains` resolves cross-domain references and the node pass creates every
|
|
52
|
-
// node before any edge, so a subset seed risks dangling edges.
|
|
53
|
-
graph
|
|
54
|
-
.command("seed")
|
|
55
|
-
.description("Load concepts, endpoints, services, and rules into Neo4j")
|
|
56
|
-
.action(async function () {
|
|
57
|
-
await seed(graph);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function seed(group: Command) {
|
|
62
|
-
const { config: loaded, configPath, verbose } = await openProject(group, TITLE);
|
|
63
|
-
const config = loaded as ProjectConfig;
|
|
64
|
-
|
|
65
|
-
// Inputs are reported as they resolve. With no domains there is nothing to
|
|
66
|
-
// seed; an absent neo4j or embedding section is `runSeed`'s error to throw.
|
|
67
|
-
if (config.neo4j?.url) report.input("Neo4j", config.neo4j.url);
|
|
68
|
-
const domains = collectDomains(config);
|
|
69
|
-
const names = Object.keys(domains);
|
|
70
|
-
if (!names.length) failMissingDomains(configPath);
|
|
71
|
-
report.input("Domains", `${names.length} — ${names.join(", ")}`);
|
|
72
|
-
|
|
73
|
-
const startedAt = Date.now();
|
|
74
|
-
const result = await runSeed(config, domains).catch(function (error: unknown): never {
|
|
75
|
-
return failSeed({ error, config, configPath, verbose });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// A domain is the unit the user edits, so it is the unit the report counts
|
|
79
|
-
// against: a wrong number next to `attendance` points at src/domains/attendance/.
|
|
80
|
-
report.blank();
|
|
81
|
-
for (const [name, domain] of Object.entries(result.domains)) {
|
|
82
|
-
const counts = [report.plural(domain.nodes, "node"), report.plural(domain.edges, "edge")];
|
|
83
|
-
if (domain.failed) counts.push(`${domain.failed} failed`);
|
|
84
|
-
report.row(name, counts.join(" · "), DOMAIN_COLUMN);
|
|
85
|
-
|
|
86
|
-
// The first failure only: enough to identify what broke, not enough to
|
|
87
|
-
// drown the run — the count above says how many followed it.
|
|
88
|
-
const failure = domain.failures[0];
|
|
89
|
-
if (failure) {
|
|
90
|
-
report.row("", `${pc.red("✗")} ${truncate(failure.error)}`, DOMAIN_COLUMN);
|
|
91
|
-
report.row("", ` ${truncate(oneLine(failure.statement))}`, DOMAIN_COLUMN);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Embeddings are a second pass over the whole graph, so they get their own
|
|
96
|
-
// step rather than a row among the counts.
|
|
97
|
-
report.blank();
|
|
98
|
-
report.input(
|
|
99
|
-
"Embeddings",
|
|
100
|
-
`${report.plural(result.embeddings.concepts, "concept")}, dimension ${result.embeddings.dimension}`,
|
|
101
|
-
);
|
|
102
|
-
report.blank();
|
|
103
|
-
|
|
104
|
-
const counts = [report.plural(result.nodes, "node"), report.plural(result.edges, "edge")];
|
|
105
|
-
if (result.failed) counts.push(`${result.failed} failed`);
|
|
106
|
-
report.footer(`${counts.join(" · ")} (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
|
|
107
|
-
|
|
108
|
-
if (result.failed) {
|
|
109
|
-
report.note("The graph is partially seeded. Re-running is safe — seeding is MERGE-based.");
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Cypher is generated multi-line and indented; a report row is neither. */
|
|
115
|
-
function oneLine(statement: string) {
|
|
116
|
-
return statement.replace(/\s+/g, " ").trim();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function truncate(text: string) {
|
|
120
|
-
return text.length > DETAIL_WIDTH ? `${text.slice(0, DETAIL_WIDTH - 2)} …` : text;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Server level first, then per agent — an agent's domain of the same name wins. */
|
|
124
|
-
function collectDomains(config: ProjectConfig) {
|
|
125
|
-
const all = { ...config.knowledge?.domains };
|
|
126
|
-
|
|
127
|
-
for (const agent of Object.values(config.agents ?? {})) {
|
|
128
|
-
Object.assign(all, agent.knowledge?.domains);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return all;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The whole pipeline is the CLI's, built on the graph contract it depends on:
|
|
136
|
-
* `@cortex/contracts/graph` is where the vocabulary, the Neo4j client and the embedder
|
|
137
|
-
* come from, and the version it seeds at is the version of that dependency.
|
|
138
|
-
*/
|
|
139
|
-
async function runSeed(config: ProjectConfig, domains: Record<string, DomainDef>) {
|
|
140
|
-
if (!config.neo4j) throw new Error("neo4j config is required for graph seed");
|
|
141
|
-
if (!config.embedding) throw new Error("embedding config is required for graph seed");
|
|
142
|
-
|
|
143
|
-
const embed = createEmbedder(config.embedding);
|
|
144
|
-
const neo4j = createNeo4jClient(config.neo4j, embed);
|
|
145
|
-
|
|
146
|
-
await warnOnRestamp(neo4j);
|
|
147
|
-
|
|
148
|
-
return await seedGraph(
|
|
149
|
-
{
|
|
150
|
-
neo4j,
|
|
151
|
-
embedding: { embed, dimension: config.embedding.dimension },
|
|
152
|
-
schema: GRAPH_SCHEMA,
|
|
153
|
-
schemaVersion: GRAPH_SCHEMA_VERSION,
|
|
154
|
-
},
|
|
155
|
-
domains,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Seeding is MERGE-based, so a graph written under an older schema is not cleaned
|
|
161
|
-
* up — its nodes stay, under labels nothing reads. Worth saying out loud; not
|
|
162
|
-
* worth blocking on, since re-seeding is exactly how that graph gets fixed.
|
|
163
|
-
*/
|
|
164
|
-
async function warnOnRestamp(neo4j: Neo4jClient) {
|
|
165
|
-
// An unreachable graph is the seed's to report, far better than a warning could.
|
|
166
|
-
const rows = await neo4j
|
|
167
|
-
.query(
|
|
168
|
-
`MATCH (g:${GRAPH_SCHEMA.label.marker}) RETURN g.${GRAPH_SCHEMA.prop.version} AS version`,
|
|
169
|
-
)
|
|
170
|
-
.catch(() => undefined);
|
|
171
|
-
if (!rows) return;
|
|
172
|
-
|
|
173
|
-
const stamped = (rows as { version?: number }[])[0]?.version;
|
|
174
|
-
if (stamped === undefined || stamped === GRAPH_SCHEMA_VERSION) return;
|
|
175
|
-
|
|
176
|
-
report.blank();
|
|
177
|
-
report.row(
|
|
178
|
-
"",
|
|
179
|
-
`${pc.yellow("!")} Graph was written with schema v${stamped}; restamping as v${GRAPH_SCHEMA_VERSION}.`,
|
|
180
|
-
DOMAIN_COLUMN,
|
|
181
|
-
);
|
|
182
|
-
report.row("", " Nodes from the old schema are not removed.", DOMAIN_COLUMN);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function failMissingDomains(configPath: string): never {
|
|
186
|
-
report.fail({
|
|
187
|
-
what: `${configLabel(configPath)} has no knowledge.domains`,
|
|
188
|
-
blocks: [
|
|
189
|
-
"`graph seed` loads domain definitions into Neo4j. Add them:",
|
|
190
|
-
" knowledge: { domains: { leaves: leavesDomain } }",
|
|
191
|
-
"See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
|
|
192
|
-
],
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* The neo4j and embedding sections are `runSeed`'s to require — it throws
|
|
198
|
-
* `"neo4j config is required for graph seed"`. This renders what it threw.
|
|
199
|
-
*/
|
|
200
|
-
function failSeed(options: {
|
|
201
|
-
error: unknown;
|
|
202
|
-
config: ProjectConfig;
|
|
203
|
-
configPath: string;
|
|
204
|
-
verbose: boolean;
|
|
205
|
-
}): never {
|
|
206
|
-
const { error, config, configPath, verbose } = options;
|
|
207
|
-
const label = configLabel(configPath);
|
|
208
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
209
|
-
const url = config.neo4j?.url;
|
|
210
|
-
if (url !== undefined && UNREACHABLE.test(message)) {
|
|
211
|
-
report.fail({
|
|
212
|
-
what: `Cannot reach Neo4j at ${url}`,
|
|
213
|
-
error,
|
|
214
|
-
blocks: [
|
|
215
|
-
"Is it running? docker compose up -d neo4j",
|
|
216
|
-
`Otherwise check neo4j.url / neo4j.user / neo4j.password in\n${label}.`,
|
|
217
|
-
],
|
|
218
|
-
verbose,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
report.fail({
|
|
223
|
-
what: "Could not seed the graph",
|
|
224
|
-
error,
|
|
225
|
-
blocks: [`Check neo4j, embedding, and knowledge.domains in ${label}.`],
|
|
226
|
-
verbose,
|
|
227
|
-
});
|
|
228
|
-
}
|