@m6d/cortex-cli 0.1.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 +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- package/tsconfig.json +46 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { type Command, Option } from "@commander-js/extra-typings";
|
|
5
|
+
|
|
6
|
+
import { configLabel, configOption, openProject } from "@/config/load";
|
|
7
|
+
import { extractEndpoints } from "@/swagger/extract-endpoints";
|
|
8
|
+
import * as report from "@/ui/report";
|
|
9
|
+
|
|
10
|
+
/** A convention, not config: `KnowledgeConfig` has no `domainsDir`. */
|
|
11
|
+
const DEFAULT_DOMAINS_DIR = "src/domains";
|
|
12
|
+
|
|
13
|
+
/** Only the parts of a loaded config `swagger` reads to report its inputs. */
|
|
14
|
+
type ProjectConfig = {
|
|
15
|
+
knowledge?: { swagger?: { url?: string } };
|
|
16
|
+
agents?: Record<string, { knowledge?: { swagger?: { url?: string } } | null } | undefined>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function registerSwagger(program: Command) {
|
|
20
|
+
const swagger = program
|
|
21
|
+
.command("swagger")
|
|
22
|
+
.helpGroup("Resources:")
|
|
23
|
+
.summary("Endpoint schemas, from your Swagger spec")
|
|
24
|
+
.description(
|
|
25
|
+
"Endpoint schemas, from your Swagger spec.\n\nReads `knowledge.swagger.url` from your cortex.config.ts and the .endpoint.ts\nfiles under src/domains.",
|
|
26
|
+
)
|
|
27
|
+
.addOption(configOption())
|
|
28
|
+
// Declared once, on the group: the two verbs read the same directory, and
|
|
29
|
+
// commander hands a verb's unknown options back to its parent, so
|
|
30
|
+
// `swagger -d x sync` and `swagger sync -d x` both land here.
|
|
31
|
+
.addOption(
|
|
32
|
+
new Option(
|
|
33
|
+
"-d, --domains-dir <path>",
|
|
34
|
+
"Domains root, relative to the config file",
|
|
35
|
+
).default(DEFAULT_DOMAINS_DIR, DEFAULT_DOMAINS_DIR),
|
|
36
|
+
)
|
|
37
|
+
.helpCommand(false);
|
|
38
|
+
|
|
39
|
+
for (const verb of ["sync", "check"] as const) {
|
|
40
|
+
swagger
|
|
41
|
+
.command(verb)
|
|
42
|
+
.description(
|
|
43
|
+
verb === "sync"
|
|
44
|
+
? "Update .endpoint.ts files in place"
|
|
45
|
+
: "Report drift without writing (exit 2 if stale)",
|
|
46
|
+
)
|
|
47
|
+
.action(async function () {
|
|
48
|
+
await run(swagger, verb, swagger.opts().domainsDir);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function run(group: Command, verb: "sync" | "check", domainsDirOption: string) {
|
|
54
|
+
const title = `cortex swagger ${verb}`;
|
|
55
|
+
const { config: loaded, configPath, path, explicit, verbose } = await openProject(group, title);
|
|
56
|
+
|
|
57
|
+
// Relative to the config file, never to the cwd — the domains tree belongs to
|
|
58
|
+
// the project, and `--config ../other/cortex.config.ts` must still find it.
|
|
59
|
+
const domainsDir = resolve(dirname(configPath), domainsDirOption);
|
|
60
|
+
if (!existsSync(domainsDir)) failMissingDomainsDir(domainsDir, verb);
|
|
61
|
+
|
|
62
|
+
const urls = swaggerUrls(loaded);
|
|
63
|
+
for (const url of urls) report.input("Swagger", url);
|
|
64
|
+
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
const result = await runExtract(urls, domainsDir, verb).catch(function (error: unknown): never {
|
|
67
|
+
return failExtract({ error, configPath, verbose, verb });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Reported once the run is over because that is when the count is known —
|
|
71
|
+
// the total is every file the walk considered, however it ended up.
|
|
72
|
+
const total =
|
|
73
|
+
result.matched + result.updated.length + result.drift.length + result.missing.length;
|
|
74
|
+
report.input(
|
|
75
|
+
"Domains",
|
|
76
|
+
`${configLabel(domainsDir)} — ${report.plural(total, "endpoint file")}`,
|
|
77
|
+
);
|
|
78
|
+
report.blank();
|
|
79
|
+
|
|
80
|
+
const elapsed = `(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`;
|
|
81
|
+
if (verb === "sync") return renderSync(result, domainsDir, elapsed);
|
|
82
|
+
renderCheck(
|
|
83
|
+
result,
|
|
84
|
+
domainsDir,
|
|
85
|
+
elapsed,
|
|
86
|
+
syncCommand({ path, explicit, domainsDir: domainsDirOption }),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The `sync` that fixes what `check` found has to act on the tree `check` read,
|
|
92
|
+
* so it carries whatever flags got it there. Defaults stay off the line — a hint
|
|
93
|
+
* is something to paste, not a transcript.
|
|
94
|
+
*/
|
|
95
|
+
function syncCommand(options: { path: string; explicit: boolean; domainsDir: string }) {
|
|
96
|
+
return [
|
|
97
|
+
"cortex swagger sync",
|
|
98
|
+
...(options.explicit ? [`--config ${options.path}`] : []),
|
|
99
|
+
...(options.domainsDir === DEFAULT_DOMAINS_DIR
|
|
100
|
+
? []
|
|
101
|
+
: [`--domains-dir ${options.domainsDir}`]),
|
|
102
|
+
].join(" ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type Report = {
|
|
106
|
+
matched: number;
|
|
107
|
+
missing: string[];
|
|
108
|
+
drift: string[];
|
|
109
|
+
updated: string[];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `sync` says what it did. Only changed files are printed — at 37 files a
|
|
114
|
+
* per-file log is 34 lines of `ok` — and `MISSING` goes last, because it is the
|
|
115
|
+
* one thing left for a human. Writing is the job, so it still exits 0.
|
|
116
|
+
*/
|
|
117
|
+
function renderSync(result: Report, domainsDir: string, elapsed: string) {
|
|
118
|
+
for (const file of result.updated) report.row("updated", file);
|
|
119
|
+
for (const file of result.missing) {
|
|
120
|
+
report.row("missing", file);
|
|
121
|
+
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
122
|
+
}
|
|
123
|
+
if (result.updated.length || result.missing.length) report.blank();
|
|
124
|
+
|
|
125
|
+
const parts = [`${result.updated.length} updated`];
|
|
126
|
+
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
127
|
+
parts.push(`${result.matched} unchanged`);
|
|
128
|
+
if (result.updated.length) parts.push("formatted with prettier");
|
|
129
|
+
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
130
|
+
|
|
131
|
+
if (result.missing.length) {
|
|
132
|
+
report.note(
|
|
133
|
+
`${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger check\` will keep failing until it is resolved.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `check` says what is wrong, and writes nothing. `drift` and `MISSING` are both
|
|
140
|
+
* findings — one is fixed by a tool, the other by a person — so either exits 2.
|
|
141
|
+
*/
|
|
142
|
+
function renderCheck(result: Report, domainsDir: string, elapsed: string, sync: string) {
|
|
143
|
+
for (const file of result.drift) {
|
|
144
|
+
report.row("drift", file);
|
|
145
|
+
report.row("", `${routeOf(join(domainsDir, file))} — no longer matches the spec`);
|
|
146
|
+
}
|
|
147
|
+
for (const file of result.missing) {
|
|
148
|
+
report.row("missing", file);
|
|
149
|
+
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
150
|
+
}
|
|
151
|
+
if (result.drift.length || result.missing.length) report.blank();
|
|
152
|
+
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
if (result.drift.length) parts.push(`${result.drift.length} drifted`);
|
|
155
|
+
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
156
|
+
parts.push(`${result.matched} up to date`);
|
|
157
|
+
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
158
|
+
|
|
159
|
+
if (result.drift.length) {
|
|
160
|
+
report.note(
|
|
161
|
+
`Run \`${sync}\` to update the ${report.plural(result.drift.length, "drifted file")}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (result.missing.length) {
|
|
165
|
+
report.note(
|
|
166
|
+
`${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger sync\` cannot fix it; it needs a human.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (result.drift.length || result.missing.length) process.exit(2);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
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.
|
|
176
|
+
*/
|
|
177
|
+
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" });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Server level, then per agent, deduped — per-agent docs override on a key clash. */
|
|
198
|
+
function swaggerUrls(config: ProjectConfig) {
|
|
199
|
+
const urls = new Set<string>();
|
|
200
|
+
if (config.knowledge?.swagger?.url) urls.add(config.knowledge.swagger.url);
|
|
201
|
+
|
|
202
|
+
for (const agent of Object.values(config.agents ?? {})) {
|
|
203
|
+
if (agent?.knowledge?.swagger?.url) urls.add(agent.knowledge.swagger.url);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return [...urls];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `1 endpoint file has` / `3 endpoint files have` — both notes open with it. */
|
|
210
|
+
function missingLead(result: Report) {
|
|
211
|
+
return `${report.plural(result.missing.length, "endpoint file")} ${result.missing.length === 1 ? "has" : "have"}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* An absent domains dir is *couldn't run*, not "nothing to do": `extractEndpoints`
|
|
216
|
+
* walks a missing directory as an empty one and would report a clean 0 of 0.
|
|
217
|
+
*/
|
|
218
|
+
function failMissingDomainsDir(domainsDir: string, verb: string): never {
|
|
219
|
+
report.fail({
|
|
220
|
+
what: `No domains directory at ${configLabel(domainsDir)}`,
|
|
221
|
+
blocks: [
|
|
222
|
+
`\`swagger ${verb}\` reads .endpoint.ts files under a domains root — ${DEFAULT_DOMAINS_DIR} by\ndefault — resolved relative to the config file. Point it somewhere else:`,
|
|
223
|
+
` cortex swagger ${verb} --domains-dir ./lib/domains`,
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Section requirements are `runExtract`'s to enforce — it already throws
|
|
230
|
+
* `"No swagger URLs found in knowledge config"`. This renders what it threw.
|
|
231
|
+
*/
|
|
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.
|
|
255
|
+
report.fail({
|
|
256
|
+
what: "Could not read the Swagger spec",
|
|
257
|
+
error,
|
|
258
|
+
blocks: [`Is the API running? Otherwise check knowledge.swagger.url in\n${label}.`],
|
|
259
|
+
verbose,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config discovery and loading. Env needs no step of its own:
|
|
3
|
+
* discovery is cwd-only and Bun's `.env` auto-load is cwd-relative, so the two
|
|
4
|
+
* are always colocated and `process.env` is populated before the config
|
|
5
|
+
* evaluates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { relative, resolve } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { Option } from "@commander-js/extra-typings";
|
|
13
|
+
import type { Command } from "@commander-js/extra-typings";
|
|
14
|
+
|
|
15
|
+
import { validateConfig } from "@/config/validate";
|
|
16
|
+
import * as report from "@/ui/report";
|
|
17
|
+
|
|
18
|
+
// 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.
|
|
21
|
+
const DEFAULT_CONFIG = "./cortex.config.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `-c, --config <path>` for a command group that reads the project: declared
|
|
25
|
+
* on the group, so `cortex new` and `cortex cc` never show it. Commander hands
|
|
26
|
+
* a verb's unknown options back to its parent, so `swagger -c x sync` and
|
|
27
|
+
* `swagger sync -c x` both land on the group.
|
|
28
|
+
*/
|
|
29
|
+
export function configOption() {
|
|
30
|
+
return new Option("-c, --config <path>", "Path to cortex.config.ts").default(
|
|
31
|
+
DEFAULT_CONFIG,
|
|
32
|
+
DEFAULT_CONFIG,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How a config path is printed once found: relative to the cwd, `./`-prefixed. */
|
|
37
|
+
export function configLabel(path: string) {
|
|
38
|
+
const relativePath = relative(process.cwd(), path);
|
|
39
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `./cortex.config.ts` in the cwd, that exact name — no upward walk and no
|
|
44
|
+
* extension fallback, so a run from the wrong directory fails loudly instead of
|
|
45
|
+
* silently picking up an ancestor's config. `--config` replaces the path
|
|
46
|
+
* outright and accepts anything Bun can import.
|
|
47
|
+
*/
|
|
48
|
+
function findConfig(options: { path: string; explicit: boolean; command: string }) {
|
|
49
|
+
const { path, explicit, command } = options;
|
|
50
|
+
const absolute = resolve(process.cwd(), path);
|
|
51
|
+
if (existsSync(absolute)) return absolute;
|
|
52
|
+
|
|
53
|
+
if (explicit) {
|
|
54
|
+
report.fail({
|
|
55
|
+
what: `No config file at ${path}`,
|
|
56
|
+
blocks: [
|
|
57
|
+
"--config takes a path to a file Bun can import, resolved from the\ndirectory you run in.",
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
report.fail({
|
|
63
|
+
what: `No cortex.config.ts in ${process.cwd()}`,
|
|
64
|
+
blocks: [
|
|
65
|
+
"The CLI looks for exactly ./cortex.config.ts in the directory you run in.\nIt does not search parent directories.",
|
|
66
|
+
"Run it from the project root, or point at the file:",
|
|
67
|
+
` ${command} --config ./server/cortex.config.ts`,
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What every project command does before its own work: find the config from
|
|
74
|
+
* its group's `--config`, open the report, load and validate it, and report
|
|
75
|
+
* its path as the first input. Discovery runs before the report opens: with
|
|
76
|
+
* no config there is no run to report on, only the reason there isn't one.
|
|
77
|
+
*/
|
|
78
|
+
export async function openProject(group: Command, title: string) {
|
|
79
|
+
// The group and the root reach this file untyped: `Command` here is the
|
|
80
|
+
// parameter type, not cli.ts's inferred one.
|
|
81
|
+
const { config: path, verbose = false } = group.optsWithGlobals() as {
|
|
82
|
+
config: string;
|
|
83
|
+
verbose?: boolean;
|
|
84
|
+
};
|
|
85
|
+
const explicit = group.getOptionValueSource("config") !== "default";
|
|
86
|
+
const configPath = findConfig({ path, explicit, command: title });
|
|
87
|
+
|
|
88
|
+
report.header(title);
|
|
89
|
+
const config = await loadConfig(configPath, verbose);
|
|
90
|
+
validateConfig(config, configLabel(configPath));
|
|
91
|
+
report.input("Config", configLabel(configPath));
|
|
92
|
+
|
|
93
|
+
return { config, configPath, path, explicit, verbose };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The whole loader: one import, one default export, no unwrap branch. */
|
|
97
|
+
async function loadConfig(path: string, verbose: boolean) {
|
|
98
|
+
try {
|
|
99
|
+
const module = (await import(pathToFileURL(path).href)) as { default?: unknown };
|
|
100
|
+
const config = module.default;
|
|
101
|
+
|
|
102
|
+
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
|
+
}
|
|
106
|
+
|
|
107
|
+
return config as Record<string, unknown>;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
report.fail({
|
|
110
|
+
what: `Could not load ${configLabel(path)}`,
|
|
111
|
+
error,
|
|
112
|
+
blocks: [
|
|
113
|
+
// The line above this one is the underlying error, and for the most
|
|
114
|
+
// common failure it reads `Cannot find package '@m6d/cortex-server'` —
|
|
115
|
+
// the config imports the server for `defineAgent`. So the install is
|
|
116
|
+
// named here rather than left to be inferred.
|
|
117
|
+
"Importing the file has to run cleanly on its own: it default-exports a\nplain CortexConfig object and nothing else, and every package it imports\nhas to be installed — `bun add @m6d/cortex-server` if that is what is\nmissing above.",
|
|
118
|
+
],
|
|
119
|
+
verbose,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One generic rule: a key present with an `undefined` value
|
|
3
|
+
* is a failure, listed by full path. It is essentially always the
|
|
4
|
+
* `process.env["X"]!` pattern, where `!` silences the type error and `undefined`
|
|
5
|
+
* flows on to fail far from its cause. Absent keys are fine — that is how
|
|
6
|
+
* optional sections are expressed.
|
|
7
|
+
*
|
|
8
|
+
* The error names config paths only. Annotating each with the env var behind it
|
|
9
|
+
* is not implementable: the CLI imports an already-evaluated object, so the name
|
|
10
|
+
* is gone. Point at `.env` and stop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as report from "@/ui/report";
|
|
14
|
+
|
|
15
|
+
/** `label` is how the config file is named in the failure: the `./`-relative path the loader prints. */
|
|
16
|
+
export function validateConfig(config: Record<string, unknown>, label: string) {
|
|
17
|
+
const paths = findUndefined(config, "", new WeakSet());
|
|
18
|
+
if (!paths.length) return;
|
|
19
|
+
|
|
20
|
+
report.fail({
|
|
21
|
+
what: `${label} has ${paths.length} undefined value${paths.length === 1 ? "" : "s"}`,
|
|
22
|
+
blocks: [
|
|
23
|
+
paths.map((entry) => ` ${entry}`).join("\n"),
|
|
24
|
+
"These keys are present but evaluate to undefined — usually a missing\nenvironment variable. Bun loads .env from the directory you run in; check\nthat ./.env exists and is complete.",
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `seen` guards the domain graph, where concepts and endpoints reference each
|
|
30
|
+
// other in cycles.
|
|
31
|
+
function findUndefined(value: object, prefix: string, seen: WeakSet<object>): string[] {
|
|
32
|
+
if (seen.has(value)) return [];
|
|
33
|
+
seen.add(value);
|
|
34
|
+
|
|
35
|
+
return (Object.entries(value) as [string, unknown][]).flatMap(function ([key, child]) {
|
|
36
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
37
|
+
if (child === undefined) return [path];
|
|
38
|
+
if (typeof child !== "object" || child === null) return [];
|
|
39
|
+
// Domain data is built by the server's `define*` helpers, which write
|
|
40
|
+
// every optional key whether or not it was passed. `undefined` under a
|
|
41
|
+
// `__brand` means "this endpoint mutates nothing", never a missing env
|
|
42
|
+
// var, so the rule stops at the seed data it would otherwise drown in.
|
|
43
|
+
if ("__brand" in child) return [];
|
|
44
|
+
return findUndefined(child, path, seen);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The boundary on a Vega-Lite spec the model wrote, shared by the widget
|
|
3
|
+
* (which draws it live) and, as needed, the server:
|
|
4
|
+
* inline data only, no fetches, no interactivity, bounded work.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const MAX_CHART_ROWS = 200;
|
|
8
|
+
const MAX_CHART_SIZE = 4000;
|
|
9
|
+
/** Transforms whose output size is set by their own parameters or by an expression rather than by the rows. */
|
|
10
|
+
const EXPANDING_TRANSFORMS = new Set([
|
|
11
|
+
"flatten",
|
|
12
|
+
"fold",
|
|
13
|
+
"density",
|
|
14
|
+
"impute",
|
|
15
|
+
"regression",
|
|
16
|
+
"loess",
|
|
17
|
+
"quantile",
|
|
18
|
+
"sample",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** What a single key or value in a chart spec may not be; undefined when it is fine on its own. */
|
|
22
|
+
function chartNodeViolation(key: string, value: unknown) {
|
|
23
|
+
if (key === "url") return "Charts may only use inline data and no URLs.";
|
|
24
|
+
if (key === "sequence" || key === "graticule") return "Charts may only use inline data.";
|
|
25
|
+
if (EXPANDING_TRANSFORMS.has(key)) return "Charts may not use transforms that generate rows.";
|
|
26
|
+
if (key === "params" || key === "selection" || key === "signals" || key === "on") {
|
|
27
|
+
return "Charts may not be interactive.";
|
|
28
|
+
}
|
|
29
|
+
// An expression reference can compute what a literal could not, such as axis ticks.
|
|
30
|
+
if (key === "expr") return "Charts may not carry expressions.";
|
|
31
|
+
if (EXPRESSION_KEYS.has(key) && typeof value === "string" && !isBoundedExpression(value)) {
|
|
32
|
+
return "Charts may only use simple expressions over datum.";
|
|
33
|
+
}
|
|
34
|
+
if (key === "usermeta") return "Charts may not carry usermeta.";
|
|
35
|
+
if (typeof value === "string" && externalResourceIn(value)) {
|
|
36
|
+
return "Charts may not reference URLs.";
|
|
37
|
+
}
|
|
38
|
+
if ((key === "width" || key === "height") && dimensionPixels(value) > MAX_CHART_SIZE) {
|
|
39
|
+
return `Charts may be at most ${MAX_CHART_SIZE} pixels wide or tall.`;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a Vega-Lite spec holds an expression string that Vega evaluates per row. */
|
|
45
|
+
const EXPRESSION_KEYS = new Set(["calculate", "filter", "test", "signal", "labelExpr"]);
|
|
46
|
+
/** Functions an expression may call: each is constant work on scalars. */
|
|
47
|
+
const EXPRESSION_FUNCTIONS = new Set([
|
|
48
|
+
"abs",
|
|
49
|
+
"ceil",
|
|
50
|
+
"floor",
|
|
51
|
+
"round",
|
|
52
|
+
"min",
|
|
53
|
+
"max",
|
|
54
|
+
"sqrt",
|
|
55
|
+
"pow",
|
|
56
|
+
"log",
|
|
57
|
+
"exp",
|
|
58
|
+
"year",
|
|
59
|
+
"month",
|
|
60
|
+
"date",
|
|
61
|
+
"day",
|
|
62
|
+
"hours",
|
|
63
|
+
"minutes",
|
|
64
|
+
"toNumber",
|
|
65
|
+
"toString",
|
|
66
|
+
"toDate",
|
|
67
|
+
"isValid",
|
|
68
|
+
"isNaN",
|
|
69
|
+
"if",
|
|
70
|
+
"indexof",
|
|
71
|
+
"length",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* True when an expression is arithmetic, comparison and logic over datum
|
|
76
|
+
* fields, literals and the scalar functions above. Anything else (another
|
|
77
|
+
* identifier, a call to any other function) is refused, so no expression can
|
|
78
|
+
* allocate by its arguments the way sequence() or pad() do.
|
|
79
|
+
*/
|
|
80
|
+
function isBoundedExpression(expression: string) {
|
|
81
|
+
if (expression.length > 500 || /[\\`]/.test(expression)) return false;
|
|
82
|
+
const stripped = expression.replace(/'[^']*'|"[^"]*"/g, '""');
|
|
83
|
+
for (const [, name] of stripped.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
84
|
+
if (!EXPRESSION_FUNCTIONS.has(name ?? "")) return false;
|
|
85
|
+
}
|
|
86
|
+
const identifiers = stripped
|
|
87
|
+
.replace(/(datum|parent)(\s*\.[\w$]+|\s*\[\s*""\s*\])+|\b\d[\w.]*/g, "")
|
|
88
|
+
.matchAll(/[A-Za-z_$][\w$]*/g);
|
|
89
|
+
for (const [name] of identifiers) {
|
|
90
|
+
if (!EXPRESSION_FUNCTIONS.has(name) && !["true", "false", "null"].includes(name))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A chart dimension in pixels: a number, or the band step of its `{ step }` form. */
|
|
97
|
+
function dimensionPixels(value: unknown) {
|
|
98
|
+
if (typeof value === "number") return value;
|
|
99
|
+
const step = (value as { step?: unknown } | null)?.step;
|
|
100
|
+
return typeof step === "number" ? step : 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inline data is opaque: rows, or a GeoJSON collection's features, are
|
|
105
|
+
* counted and never read as controls. Anything else in a `values` position
|
|
106
|
+
* (an axis tick list, an expression object) is a control and is walked.
|
|
107
|
+
*/
|
|
108
|
+
function inlineDataViolation(data: unknown, key: string) {
|
|
109
|
+
const features = (data as { features?: unknown } | null)?.features;
|
|
110
|
+
const rows = Array.isArray(data) ? data.length : Array.isArray(features) ? features.length : -1;
|
|
111
|
+
if (rows < 0)
|
|
112
|
+
return (
|
|
113
|
+
chartNodeViolation(key, data) ??
|
|
114
|
+
firstViolation(childEntries(data, key), chartSpecViolation)
|
|
115
|
+
);
|
|
116
|
+
return rows > MAX_CHART_ROWS ? `Charts may hold at most ${MAX_CHART_ROWS} rows.` : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Why a Vega-Lite spec is refused, or undefined when it may be drawn. The
|
|
121
|
+
* walk is over the spec's control positions: a `url` key anywhere is a fetch
|
|
122
|
+
* (data, image marks); a generator or a row-producing transform is an
|
|
123
|
+
* unbounded row count; params would redraw after the resource scrub;
|
|
124
|
+
* `usermeta` lets the spec rewrite the embed options; a string carrying
|
|
125
|
+
* url() or a CSS escape could reach an SVG presentation attribute. Inline
|
|
126
|
+
* rows are only counted, never read as controls, and the drawing size is
|
|
127
|
+
* capped so a spec cannot exhaust the reader's page.
|
|
128
|
+
*/
|
|
129
|
+
export function chartSpecViolation(value: unknown, key = ""): string | undefined {
|
|
130
|
+
if (key === "values") return inlineDataViolation(value, key);
|
|
131
|
+
if (key === "datasets") return firstViolation(childEntries(value), inlineDataViolation);
|
|
132
|
+
return (
|
|
133
|
+
chartNodeViolation(key, value) ??
|
|
134
|
+
firstViolation(childEntries(value, key), (child, childKey) =>
|
|
135
|
+
chartSpecViolation(child, childKey),
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** An array's items under their parent's key, an object's own entries, nothing for a scalar. */
|
|
141
|
+
function childEntries(value: unknown, key = "") {
|
|
142
|
+
if (Array.isArray(value)) return value.map((item) => [key, item] as [string, unknown]);
|
|
143
|
+
return value && typeof value === "object" ? Object.entries(value) : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function firstViolation(
|
|
147
|
+
entries: [string, unknown][],
|
|
148
|
+
check: (value: unknown, key: string) => string | undefined,
|
|
149
|
+
) {
|
|
150
|
+
for (const [key, value] of entries) {
|
|
151
|
+
const reason = check(value, key);
|
|
152
|
+
if (reason) return reason;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const URL_FUNCTION = /url\(([^)]*)\)/gi;
|
|
158
|
+
/** Every CSS function that names a resource to load; only url() can point at a fragment. */
|
|
159
|
+
export const IMAGE_FUNCTION = /\b(url|src|image|image-set|-webkit-image-set|cross-fade|element)\(/i;
|
|
160
|
+
/** The same set without url(), whose argument is checked for a fragment instead. */
|
|
161
|
+
const OTHER_IMAGE_FUNCTION = /\b(src|image|image-set|-webkit-image-set|cross-fade|element)\(/i;
|
|
162
|
+
const OTHER_IMAGE_FUNCTION_GLOBAL =
|
|
163
|
+
/\b(src|image|image-set|-webkit-image-set|cross-fade|element)\([^)]*\)/gi;
|
|
164
|
+
const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\(.)/gi;
|
|
165
|
+
|
|
166
|
+
/** Style text with its CSS escapes decoded, so a url() hidden behind one reads as itself. */
|
|
167
|
+
function decodeCssEscapes(text: string) {
|
|
168
|
+
return text.replace(CSS_ESCAPE, (_, hex: string | undefined, literal: string | undefined) => {
|
|
169
|
+
if (!hex) return literal ?? "";
|
|
170
|
+
const code = parseInt(hex, 16);
|
|
171
|
+
// CSS maps null, surrogates and anything past the last code point to U+FFFD.
|
|
172
|
+
const invalid = code === 0 || (code >= 0xd800 && code <= 0xdfff) || code > 0x10ffff;
|
|
173
|
+
return String.fromCodePoint(invalid ? 0xfffd : code);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The target inside one url(...) argument, unquoted and trimmed. */
|
|
178
|
+
function urlTarget(argument: string) {
|
|
179
|
+
return argument
|
|
180
|
+
.trim()
|
|
181
|
+
.replace(/^(['"])(.*)\1$/s, "$2")
|
|
182
|
+
.trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** True when a CSS or attribute value would make the browser fetch: a url() to anything but a fragment, or an escape that could hide one. */
|
|
186
|
+
export function externalResourceIn(value: string) {
|
|
187
|
+
if (value.includes("\\") || OTHER_IMAGE_FUNCTION.test(value)) return true;
|
|
188
|
+
for (const [, argument] of value.matchAll(URL_FUNCTION)) {
|
|
189
|
+
if (!urlTarget(argument ?? "").startsWith("#")) return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Style text with every resource-loading function neutralized: CSS escapes decoded first, url() kept only to fragments. */
|
|
195
|
+
export function scrubStyleText(text: string) {
|
|
196
|
+
return decodeCssEscapes(text)
|
|
197
|
+
.replace(OTHER_IMAGE_FUNCTION_GLOBAL, "none")
|
|
198
|
+
.replace(URL_FUNCTION, (match, argument: string) =>
|
|
199
|
+
urlTarget(argument).startsWith("#") ? match : "none",
|
|
200
|
+
);
|
|
201
|
+
}
|