@m6d/cortex-cli 0.12.1 → 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 -62
- 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/commands/swagger.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { type Command, Option } from "@commander-js/extra-typings";
|
|
5
|
-
|
|
6
|
-
import { configLabel, configOption, openProject } from "@/config/load";
|
|
7
|
-
import { declaredRoute, extractEndpoints } from "@/swagger/extract-endpoints";
|
|
8
|
-
import * as report from "@/ui/report";
|
|
9
|
-
|
|
10
|
-
/** A convention, not config: `KnowledgeConfig` has no `domainsDir`. */
|
|
11
|
-
const DEFAULT_DOMAINS_DIR = "src/domains";
|
|
12
|
-
|
|
13
|
-
/** Only the parts of a loaded config `swagger` reads to report its inputs. */
|
|
14
|
-
type ProjectConfig = {
|
|
15
|
-
knowledge?: { swagger?: { url?: string } };
|
|
16
|
-
agents?: Record<string, { knowledge?: { swagger?: { url?: string } } | null } | undefined>;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function registerSwagger(program: Command) {
|
|
20
|
-
const swagger = program
|
|
21
|
-
.command("swagger")
|
|
22
|
-
.helpGroup("Resources:")
|
|
23
|
-
.summary("Endpoint schemas, from your Swagger spec")
|
|
24
|
-
.description(
|
|
25
|
-
"Endpoint schemas, from your Swagger spec.\n\nReads `knowledge.swagger.url` from your cortex.config.ts and the .endpoint.ts\nfiles under src/domains.",
|
|
26
|
-
)
|
|
27
|
-
.addOption(configOption())
|
|
28
|
-
// Declared once, on the group: the two verbs read the same directory, and
|
|
29
|
-
// commander hands a verb's unknown options back to its parent, so
|
|
30
|
-
// `swagger -d x sync` and `swagger sync -d x` both land here.
|
|
31
|
-
.addOption(
|
|
32
|
-
new Option(
|
|
33
|
-
"-d, --domains-dir <path>",
|
|
34
|
-
"Domains root, relative to the config file",
|
|
35
|
-
).default(DEFAULT_DOMAINS_DIR, DEFAULT_DOMAINS_DIR),
|
|
36
|
-
)
|
|
37
|
-
.helpCommand(false);
|
|
38
|
-
|
|
39
|
-
for (const verb of ["sync", "check"] as const) {
|
|
40
|
-
swagger
|
|
41
|
-
.command(verb)
|
|
42
|
-
.description(
|
|
43
|
-
verb === "sync"
|
|
44
|
-
? "Update .endpoint.ts files in place"
|
|
45
|
-
: "Report drift without writing (exit 2 if stale)",
|
|
46
|
-
)
|
|
47
|
-
.action(async function () {
|
|
48
|
-
await run(swagger, verb, swagger.opts().domainsDir);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function run(group: Command, verb: "sync" | "check", domainsDirOption: string) {
|
|
54
|
-
const title = `cortex swagger ${verb}`;
|
|
55
|
-
const { config: loaded, configPath, path, explicit, verbose } = await openProject(group, title);
|
|
56
|
-
|
|
57
|
-
// Relative to the config file, never to the cwd — the domains tree belongs to
|
|
58
|
-
// the project, and `--config ../other/cortex.config.ts` must still find it.
|
|
59
|
-
const domainsDir = resolve(dirname(configPath), domainsDirOption);
|
|
60
|
-
if (!existsSync(domainsDir)) failMissingDomainsDir(domainsDir, verb);
|
|
61
|
-
|
|
62
|
-
const urls = swaggerUrls(loaded);
|
|
63
|
-
if (!urls.length) failMissingSwaggerUrl(configPath, verb);
|
|
64
|
-
for (const url of urls) report.input("Swagger", url);
|
|
65
|
-
|
|
66
|
-
// Unlike `graph seed`, this needs nothing from the project's server — not
|
|
67
|
-
// even a version handshake. What it writes is `.endpoint.ts` source whose
|
|
68
|
-
// generated block has to satisfy that server's own `EndpointDef`, so a
|
|
69
|
-
// codegen that outran the installed server is a type error in the project's
|
|
70
|
-
// `bun run check`, loudly, rather than something to detect here.
|
|
71
|
-
const startedAt = Date.now();
|
|
72
|
-
const result = await extractEndpoints({
|
|
73
|
-
swaggerUrls: urls,
|
|
74
|
-
domainsDir,
|
|
75
|
-
write: verb === "sync",
|
|
76
|
-
}).catch(function (error: unknown): never {
|
|
77
|
-
return failExtract({ error, configPath, verbose });
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// Reported once the run is over because that is when the count is known —
|
|
81
|
-
// the total is every file the walk considered, however it ended up.
|
|
82
|
-
const total =
|
|
83
|
-
result.matched + result.updated.length + result.drift.length + result.missing.length;
|
|
84
|
-
report.input(
|
|
85
|
-
"Domains",
|
|
86
|
-
`${configLabel(domainsDir)} — ${report.plural(total, "endpoint file")}`,
|
|
87
|
-
);
|
|
88
|
-
report.blank();
|
|
89
|
-
|
|
90
|
-
const elapsed = `(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`;
|
|
91
|
-
if (verb === "sync") return renderSync(result, domainsDir, elapsed);
|
|
92
|
-
renderCheck(
|
|
93
|
-
result,
|
|
94
|
-
domainsDir,
|
|
95
|
-
elapsed,
|
|
96
|
-
syncCommand({ path, explicit, domainsDir: domainsDirOption }),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The `sync` that fixes what `check` found has to act on the tree `check` read,
|
|
102
|
-
* so it carries whatever flags got it there. Defaults stay off the line — a hint
|
|
103
|
-
* is something to paste, not a transcript.
|
|
104
|
-
*/
|
|
105
|
-
function syncCommand(options: { path: string; explicit: boolean; domainsDir: string }) {
|
|
106
|
-
return [
|
|
107
|
-
"cortex swagger sync",
|
|
108
|
-
...(options.explicit ? [`--config ${options.path}`] : []),
|
|
109
|
-
...(options.domainsDir === DEFAULT_DOMAINS_DIR
|
|
110
|
-
? []
|
|
111
|
-
: [`--domains-dir ${options.domainsDir}`]),
|
|
112
|
-
].join(" ");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
type Report = {
|
|
116
|
-
matched: number;
|
|
117
|
-
missing: string[];
|
|
118
|
-
drift: string[];
|
|
119
|
-
updated: string[];
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* `sync` says what it did. Only changed files are printed — at 37 files a
|
|
124
|
-
* per-file log is 34 lines of `ok` — and `MISSING` goes last, because it is the
|
|
125
|
-
* one thing left for a human. Writing is the job, so it still exits 0.
|
|
126
|
-
*/
|
|
127
|
-
function renderSync(result: Report, domainsDir: string, elapsed: string) {
|
|
128
|
-
for (const file of result.updated) report.row("updated", file);
|
|
129
|
-
for (const file of result.missing) {
|
|
130
|
-
report.row("missing", file);
|
|
131
|
-
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
132
|
-
}
|
|
133
|
-
if (result.updated.length || result.missing.length) report.blank();
|
|
134
|
-
|
|
135
|
-
const parts = [`${result.updated.length} updated`];
|
|
136
|
-
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
137
|
-
parts.push(`${result.matched} unchanged`);
|
|
138
|
-
if (result.updated.length) parts.push("formatted with prettier");
|
|
139
|
-
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
140
|
-
|
|
141
|
-
if (result.missing.length) {
|
|
142
|
-
report.note(
|
|
143
|
-
`${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger check\` will keep failing until it is resolved.`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* `check` says what is wrong, and writes nothing. `drift` and `MISSING` are both
|
|
150
|
-
* findings — one is fixed by a tool, the other by a person — so either exits 2.
|
|
151
|
-
*/
|
|
152
|
-
function renderCheck(result: Report, domainsDir: string, elapsed: string, sync: string) {
|
|
153
|
-
for (const file of result.drift) {
|
|
154
|
-
report.row("drift", file);
|
|
155
|
-
report.row("", `${routeOf(join(domainsDir, file))} — no longer matches the spec`);
|
|
156
|
-
}
|
|
157
|
-
for (const file of result.missing) {
|
|
158
|
-
report.row("missing", file);
|
|
159
|
-
report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
|
|
160
|
-
}
|
|
161
|
-
if (result.drift.length || result.missing.length) report.blank();
|
|
162
|
-
|
|
163
|
-
const parts: string[] = [];
|
|
164
|
-
if (result.drift.length) parts.push(`${result.drift.length} drifted`);
|
|
165
|
-
if (result.missing.length) parts.push(`${result.missing.length} missing`);
|
|
166
|
-
parts.push(`${result.matched} up to date`);
|
|
167
|
-
report.footer(`${parts.join(" · ")} ${elapsed}`);
|
|
168
|
-
|
|
169
|
-
if (result.drift.length) {
|
|
170
|
-
report.note(
|
|
171
|
-
`Run \`${sync}\` to update the ${report.plural(result.drift.length, "drifted file")}.`,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
if (result.missing.length) {
|
|
175
|
-
report.note(
|
|
176
|
-
`${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger sync\` cannot fix it; it needs a human.`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
if (result.drift.length || result.missing.length) process.exit(2);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* The route a finding is about. The report is a list of paths; the route is
|
|
184
|
-
* what the reader has to act on, and it lives in the file.
|
|
185
|
-
*/
|
|
186
|
-
function routeOf(filePath: string) {
|
|
187
|
-
const { method = "?", path = "?" } = declaredRoute(readFileSync(filePath, "utf8"));
|
|
188
|
-
return `${method} ${path}`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Server level, then per agent, deduped — per-agent docs override on a key clash. */
|
|
192
|
-
function swaggerUrls(config: ProjectConfig) {
|
|
193
|
-
const urls = new Set<string>();
|
|
194
|
-
if (config.knowledge?.swagger?.url) urls.add(config.knowledge.swagger.url);
|
|
195
|
-
|
|
196
|
-
for (const agent of Object.values(config.agents ?? {})) {
|
|
197
|
-
if (agent?.knowledge?.swagger?.url) urls.add(agent.knowledge.swagger.url);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return [...urls];
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** `1 endpoint file has` / `3 endpoint files have` — both notes open with it. */
|
|
204
|
-
function missingLead(result: Report) {
|
|
205
|
-
return `${report.plural(result.missing.length, "endpoint file")} ${result.missing.length === 1 ? "has" : "have"}`;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* An absent domains dir is *couldn't run*, not "nothing to do": `extractEndpoints`
|
|
210
|
-
* walks a missing directory as an empty one and would report a clean 0 of 0.
|
|
211
|
-
*/
|
|
212
|
-
function failMissingDomainsDir(domainsDir: string, verb: string): never {
|
|
213
|
-
report.fail({
|
|
214
|
-
what: `No domains directory at ${configLabel(domainsDir)}`,
|
|
215
|
-
blocks: [
|
|
216
|
-
`\`swagger ${verb}\` reads .endpoint.ts files under a domains root — ${DEFAULT_DOMAINS_DIR} by\ndefault — resolved relative to the config file. Point it somewhere else:`,
|
|
217
|
-
` cortex swagger ${verb} --domains-dir ./lib/domains`,
|
|
218
|
-
],
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function failMissingSwaggerUrl(configPath: string, verb: string): never {
|
|
223
|
-
report.fail({
|
|
224
|
-
what: `${configLabel(configPath)} has no knowledge.swagger.url`,
|
|
225
|
-
blocks: [
|
|
226
|
-
`\`swagger ${verb}\` generates endpoint schemas from a Swagger spec. Add one:`,
|
|
227
|
-
' knowledge: { swagger: { url: "https://api.example.com/swagger/v1/swagger.json" } }',
|
|
228
|
-
"See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
|
|
229
|
-
],
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* The `◇ Swagger` lines above name every URL that was tried, so the error
|
|
235
|
-
* states the failure and points at the key behind it.
|
|
236
|
-
*/
|
|
237
|
-
function failExtract(options: { error: unknown; configPath: string; verbose: boolean }): never {
|
|
238
|
-
const { error, configPath, verbose } = options;
|
|
239
|
-
report.fail({
|
|
240
|
-
what: "Could not read the Swagger spec",
|
|
241
|
-
error,
|
|
242
|
-
blocks: [
|
|
243
|
-
`Is the API running? Otherwise check knowledge.swagger.url in\n${configLabel(configPath)}.`,
|
|
244
|
-
],
|
|
245
|
-
verbose,
|
|
246
|
-
});
|
|
247
|
-
}
|
package/src/config/load.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config discovery and loading. Env needs no step of its own:
|
|
3
|
-
* discovery is cwd-only and Bun's `.env` auto-load is cwd-relative, so the two
|
|
4
|
-
* are always colocated and `process.env` is populated before the config
|
|
5
|
-
* evaluates.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { relative, resolve } from "node:path";
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
|
-
|
|
12
|
-
import { Option } from "@commander-js/extra-typings";
|
|
13
|
-
import type { Command } from "@commander-js/extra-typings";
|
|
14
|
-
import { isRecord } from "@cortex/contracts/json";
|
|
15
|
-
|
|
16
|
-
import { validateConfig } from "@/config/validate";
|
|
17
|
-
import * as report from "@/ui/report";
|
|
18
|
-
|
|
19
|
-
// Discovery is this exact name in the cwd, with no upward walk, so the flag's
|
|
20
|
-
// default *is* the discovery rule.
|
|
21
|
-
const DEFAULT_CONFIG = "./cortex.config.ts";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* `-c, --config <path>` for a command group that reads the project: declared
|
|
25
|
-
* on the group, so `cortex new` and `cortex cc` never show it. Commander hands
|
|
26
|
-
* a verb's unknown options back to its parent, so `swagger -c x sync` and
|
|
27
|
-
* `swagger sync -c x` both land on the group.
|
|
28
|
-
*/
|
|
29
|
-
export function configOption() {
|
|
30
|
-
// The second argument is what help prints; without it commander would
|
|
31
|
-
// JSON.stringify the path and show quotes.
|
|
32
|
-
return new Option("-c, --config <path>", "Path to cortex.config.ts").default(
|
|
33
|
-
DEFAULT_CONFIG,
|
|
34
|
-
DEFAULT_CONFIG,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** How a config path is printed once found: relative to the cwd, `./`-prefixed. */
|
|
39
|
-
export function configLabel(path: string) {
|
|
40
|
-
const relativePath = relative(process.cwd(), path);
|
|
41
|
-
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* `./cortex.config.ts` in the cwd, that exact name — no upward walk and no
|
|
46
|
-
* extension fallback, so a run from the wrong directory fails loudly instead of
|
|
47
|
-
* silently picking up an ancestor's config. `--config` replaces the path
|
|
48
|
-
* outright and accepts anything Bun can import.
|
|
49
|
-
*/
|
|
50
|
-
function findConfig(options: { path: string; explicit: boolean; command: string }) {
|
|
51
|
-
const { path, explicit, command } = options;
|
|
52
|
-
const absolute = resolve(process.cwd(), path);
|
|
53
|
-
if (existsSync(absolute)) return absolute;
|
|
54
|
-
|
|
55
|
-
if (explicit) {
|
|
56
|
-
report.fail({
|
|
57
|
-
what: `No config file at ${path}`,
|
|
58
|
-
blocks: [
|
|
59
|
-
"--config takes a path to a file Bun can import, resolved from the\ndirectory you run in.",
|
|
60
|
-
],
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
report.fail({
|
|
65
|
-
what: `No cortex.config.ts in ${process.cwd()}`,
|
|
66
|
-
blocks: [
|
|
67
|
-
"The CLI looks for exactly ./cortex.config.ts in the directory you run in.\nIt does not search parent directories.",
|
|
68
|
-
"Run it from the project root, or point at the file:",
|
|
69
|
-
` ${command} --config ./server/cortex.config.ts`,
|
|
70
|
-
],
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* What every project command does before its own work: find the config from
|
|
76
|
-
* its group's `--config`, open the report, load and validate it, and report
|
|
77
|
-
* its path as the first input. Discovery runs before the report opens: with
|
|
78
|
-
* no config there is no run to report on, only the reason there isn't one.
|
|
79
|
-
*/
|
|
80
|
-
export async function openProject(group: Command, title: string) {
|
|
81
|
-
// The group and the root reach this file untyped: `Command` here is the
|
|
82
|
-
// parameter type, not cli.ts's inferred one.
|
|
83
|
-
const { config: path, verbose = false } = group.optsWithGlobals() as {
|
|
84
|
-
config: string;
|
|
85
|
-
verbose?: boolean;
|
|
86
|
-
};
|
|
87
|
-
const explicit = group.getOptionValueSource("config") !== "default";
|
|
88
|
-
const configPath = findConfig({ path, explicit, command: title });
|
|
89
|
-
|
|
90
|
-
report.header(title);
|
|
91
|
-
const config = await loadConfig(configPath, verbose);
|
|
92
|
-
validateConfig(config, configLabel(configPath));
|
|
93
|
-
report.input("Config", configLabel(configPath));
|
|
94
|
-
|
|
95
|
-
return { config, configPath, path, explicit, verbose };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The whole loader: one import, one default export, no unwrap branch. */
|
|
99
|
-
async function loadConfig(path: string, verbose: boolean) {
|
|
100
|
-
try {
|
|
101
|
-
const module = (await import(pathToFileURL(path).href)) as { default?: unknown };
|
|
102
|
-
const config = module.default;
|
|
103
|
-
|
|
104
|
-
if (config === undefined) throw new Error("The module has no default export.");
|
|
105
|
-
if (!isRecord(config)) throw new Error("The default export is not an object.");
|
|
106
|
-
|
|
107
|
-
return config;
|
|
108
|
-
} catch (error) {
|
|
109
|
-
report.fail({
|
|
110
|
-
what: `Could not load ${configLabel(path)}`,
|
|
111
|
-
error,
|
|
112
|
-
blocks: [
|
|
113
|
-
// The line above this one is the underlying error, and for the most
|
|
114
|
-
// common failure it reads `Cannot find package '@m6d/cortex-server'` —
|
|
115
|
-
// the config imports the server for `defineAgent`. So the install is
|
|
116
|
-
// named here rather than left to be inferred.
|
|
117
|
-
"Importing the file has to run cleanly on its own: it default-exports a\nplain CortexConfig object and nothing else, and every package it imports\nhas to be installed — `bun add @m6d/cortex-server` if that is what is\nmissing above.",
|
|
118
|
-
],
|
|
119
|
-
verbose,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
package/src/config/validate.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One generic rule: a key present with an `undefined` value
|
|
3
|
-
* is a failure, listed by full path. It is essentially always the
|
|
4
|
-
* `process.env["X"]!` pattern, where `!` silences the type error and `undefined`
|
|
5
|
-
* flows on to fail far from its cause. Absent keys are fine — that is how
|
|
6
|
-
* optional sections are expressed.
|
|
7
|
-
*
|
|
8
|
-
* The error names config paths only. Annotating each with the env var behind it
|
|
9
|
-
* is not implementable: the CLI imports an already-evaluated object, so the name
|
|
10
|
-
* is gone. Point at `.env` and stop.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import * as report from "@/ui/report";
|
|
14
|
-
|
|
15
|
-
/** `label` is how the config file is named in the failure: the `./`-relative path the loader prints. */
|
|
16
|
-
export function validateConfig(config: Record<string, unknown>, label: string) {
|
|
17
|
-
const paths = findUndefined(config, "", new WeakSet());
|
|
18
|
-
if (!paths.length) return;
|
|
19
|
-
|
|
20
|
-
report.fail({
|
|
21
|
-
what: `${label} has ${report.plural(paths.length, "undefined value")}`,
|
|
22
|
-
blocks: [
|
|
23
|
-
paths.map((entry) => ` ${entry}`).join("\n"),
|
|
24
|
-
"These keys are present but evaluate to undefined — usually a missing\nenvironment variable. Bun loads .env from the directory you run in; check\nthat ./.env exists and is complete.",
|
|
25
|
-
],
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// `seen` guards the domain graph, where concepts and endpoints reference each
|
|
30
|
-
// other in cycles.
|
|
31
|
-
function findUndefined(value: object, prefix: string, seen: WeakSet<object>): string[] {
|
|
32
|
-
if (seen.has(value)) return [];
|
|
33
|
-
seen.add(value);
|
|
34
|
-
|
|
35
|
-
return (Object.entries(value) as [string, unknown][]).flatMap(([key, child]) => {
|
|
36
|
-
const path = prefix ? `${prefix}.${key}` : key;
|
|
37
|
-
if (child === undefined) return [path];
|
|
38
|
-
if (typeof child !== "object" || child === null) return [];
|
|
39
|
-
// Domain data is built by the server's `define*` helpers, which write
|
|
40
|
-
// every optional key whether or not it was passed. `undefined` under a
|
|
41
|
-
// `__brand` means "this endpoint mutates nothing", never a missing env
|
|
42
|
-
// var, so the rule stops at the seed data it would otherwise drown in.
|
|
43
|
-
if ("__brand" in child) return [];
|
|
44
|
-
return findUndefined(child, path, seen);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { canCarrySecret } from "../egress/url";
|
|
3
|
-
|
|
4
|
-
export { canCarrySecret };
|
|
5
|
-
|
|
6
|
-
/*
|
|
7
|
-
* A credential is a value Cortex presents to an upstream: one it holds
|
|
8
|
-
* (`static`) or one it acquires (`oauth2-client-credentials`, RFC 6749 §4.4).
|
|
9
|
-
* The definition is the same on both sides; the Control Center stores it,
|
|
10
|
-
* a code config declares it, and each side resolves it to a bearer through
|
|
11
|
-
* the helper and cache below with its own egress-guarded fetch.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export const CREDENTIAL_KINDS = ["static", "oauth2-client-credentials"] as const;
|
|
15
|
-
export type CredentialKind = (typeof CREDENTIAL_KINDS)[number];
|
|
16
|
-
|
|
17
|
-
export const credentialDefinitionSchema = z.discriminatedUnion("kind", [
|
|
18
|
-
z.object({
|
|
19
|
-
kind: z.literal("static"),
|
|
20
|
-
value: z.string().min(1),
|
|
21
|
-
}),
|
|
22
|
-
z.object({
|
|
23
|
-
kind: z.literal("oauth2-client-credentials"),
|
|
24
|
-
/** The token endpoint; a client secret travels over TLS, to loopback, or to a host the egress policy admits. */
|
|
25
|
-
tokenUrl: z
|
|
26
|
-
.url()
|
|
27
|
-
.refine(
|
|
28
|
-
(url) => canCarrySecret(url),
|
|
29
|
-
"token URL must be https, or http to loopback or to a host the egress policy admits",
|
|
30
|
-
),
|
|
31
|
-
clientId: z.string().min(1),
|
|
32
|
-
clientSecret: z.string().min(1),
|
|
33
|
-
scope: z.string().min(1).optional(),
|
|
34
|
-
}),
|
|
35
|
-
]);
|
|
36
|
-
export type CredentialDefinition = z.infer<typeof credentialDefinitionSchema>;
|
|
37
|
-
export type OAuth2ClientCredentials = Extract<
|
|
38
|
-
CredentialDefinition,
|
|
39
|
-
{ kind: "oauth2-client-credentials" }
|
|
40
|
-
>;
|
|
41
|
-
|
|
42
|
-
/** One POST to the token endpoint through the caller's guarded fetch; `null` when the transport failed. */
|
|
43
|
-
export type TokenPost = (
|
|
44
|
-
url: string,
|
|
45
|
-
init: { headers: Record<string, string>; body: string },
|
|
46
|
-
) => Promise<{ status: number; body: string } | null>;
|
|
47
|
-
|
|
48
|
-
/** A bearer and when it stops being one; `null` when the issuer did not say. */
|
|
49
|
-
export type AcquiredToken = { token: string; expiresAt: number | null };
|
|
50
|
-
|
|
51
|
-
export type AcquireResult =
|
|
52
|
-
| { ok: true; value: AcquiredToken }
|
|
53
|
-
| {
|
|
54
|
-
ok: false;
|
|
55
|
-
error:
|
|
56
|
-
| "token_url_insecure"
|
|
57
|
-
| "token_request_failed"
|
|
58
|
-
| "token_rejected"
|
|
59
|
-
| "token_response_invalid";
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const tokenResponseSchema = z.object({
|
|
63
|
-
access_token: z.string().min(1),
|
|
64
|
-
token_type: z.string().optional(),
|
|
65
|
-
// Some issuers (Azure AD v1 among them) send the lifetime as a string.
|
|
66
|
-
expires_in: z
|
|
67
|
-
.union([z.number(), z.string().regex(/^\d+$/).transform(Number)])
|
|
68
|
-
.pipe(z.number().nonnegative())
|
|
69
|
-
.optional(),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The client credentials grant: a form-encoded POST with the client
|
|
74
|
-
* authenticated by `client_secret_basic` (RFC 6749 §2.3.1, each half
|
|
75
|
-
* form-encoded before base64). The endpoint's body never leaves this
|
|
76
|
-
* function: a failure is one of three literals, since the body may echo
|
|
77
|
-
* the client secret or carry the issuer's own error detail.
|
|
78
|
-
*/
|
|
79
|
-
export async function acquireOAuth2ClientCredentials(
|
|
80
|
-
definition: OAuth2ClientCredentials,
|
|
81
|
-
post: TokenPost,
|
|
82
|
-
) {
|
|
83
|
-
// Judged again here, not only when the definition was saved: the policy
|
|
84
|
-
// may have tightened since, and a secret never travels where it may not.
|
|
85
|
-
if (!canCarrySecret(definition.tokenUrl)) {
|
|
86
|
-
return { ok: false, error: "token_url_insecure" } satisfies AcquireResult;
|
|
87
|
-
}
|
|
88
|
-
const basic = Buffer.from(
|
|
89
|
-
`${encodeURIComponent(definition.clientId)}:${encodeURIComponent(definition.clientSecret)}`,
|
|
90
|
-
).toString("base64");
|
|
91
|
-
const answer = await post(definition.tokenUrl, {
|
|
92
|
-
headers: {
|
|
93
|
-
Authorization: `Basic ${basic}`,
|
|
94
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
95
|
-
Accept: "application/json",
|
|
96
|
-
},
|
|
97
|
-
body: new URLSearchParams({
|
|
98
|
-
grant_type: "client_credentials",
|
|
99
|
-
...(definition.scope ? { scope: definition.scope } : {}),
|
|
100
|
-
}).toString(),
|
|
101
|
-
});
|
|
102
|
-
if (!answer) return { ok: false, error: "token_request_failed" } satisfies AcquireResult;
|
|
103
|
-
if (answer.status < 200 || answer.status >= 300) {
|
|
104
|
-
return { ok: false, error: "token_rejected" } satisfies AcquireResult;
|
|
105
|
-
}
|
|
106
|
-
let parsed: z.ZodSafeParseResult<z.infer<typeof tokenResponseSchema>>;
|
|
107
|
-
try {
|
|
108
|
-
parsed = tokenResponseSchema.safeParse(JSON.parse(answer.body));
|
|
109
|
-
} catch {
|
|
110
|
-
return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
|
|
111
|
-
}
|
|
112
|
-
if (!parsed.success || (parsed.data.token_type ?? "bearer").toLowerCase() !== "bearer") {
|
|
113
|
-
return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
|
|
114
|
-
}
|
|
115
|
-
const issuedAt = Date.now();
|
|
116
|
-
return {
|
|
117
|
-
ok: true as const,
|
|
118
|
-
value: {
|
|
119
|
-
token: parsed.data.access_token,
|
|
120
|
-
expiresAt:
|
|
121
|
-
parsed.data.expires_in === undefined
|
|
122
|
-
? null
|
|
123
|
-
: issuedAt + parsed.data.expires_in * 1000,
|
|
124
|
-
},
|
|
125
|
-
} satisfies AcquireResult;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** A token is not reused inside this margin of its expiry: the call it is presented on still has to complete. */
|
|
129
|
-
const EXPIRY_MARGIN_MS = 30_000;
|
|
130
|
-
|
|
131
|
-
/** Equal definitions share one key; a rotated secret is another definition and another token. Kept in memory only, like the definitions themselves. */
|
|
132
|
-
function keyOf(definition: CredentialDefinition) {
|
|
133
|
-
return JSON.stringify(definition);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Acquired tokens by definition, reused until their expiry margin. Concurrent
|
|
138
|
-
* acquisitions of one definition share a single request; a token the issuer
|
|
139
|
-
* gave no expiry for is answered but not kept, since nothing says when it
|
|
140
|
-
* stops working. `invalidate` is for the caller that saw a 401: the next
|
|
141
|
-
* resolve acquires again. Pure, so the console's client bundle may import
|
|
142
|
-
* the schemas beside it.
|
|
143
|
-
*/
|
|
144
|
-
export function createCredentialCache(now: () => number = Date.now) {
|
|
145
|
-
const tokens = new Map<string, AcquiredToken>();
|
|
146
|
-
const inFlight = new Map<string, Promise<AcquireResult>>();
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
async get(definition: CredentialDefinition, acquire: () => Promise<AcquireResult>) {
|
|
150
|
-
const key = keyOf(definition);
|
|
151
|
-
const cached = tokens.get(key);
|
|
152
|
-
if (
|
|
153
|
-
cached &&
|
|
154
|
-
cached.expiresAt !== null &&
|
|
155
|
-
cached.expiresAt - EXPIRY_MARGIN_MS > now()
|
|
156
|
-
) {
|
|
157
|
-
return { ok: true, value: cached } satisfies AcquireResult;
|
|
158
|
-
}
|
|
159
|
-
tokens.delete(key);
|
|
160
|
-
const pending = inFlight.get(key);
|
|
161
|
-
if (pending) return pending;
|
|
162
|
-
const acquiring = acquire()
|
|
163
|
-
.then((result) => {
|
|
164
|
-
if (
|
|
165
|
-
result.ok &&
|
|
166
|
-
result.value.expiresAt !== null &&
|
|
167
|
-
result.value.expiresAt - EXPIRY_MARGIN_MS > now()
|
|
168
|
-
) {
|
|
169
|
-
tokens.set(key, result.value);
|
|
170
|
-
}
|
|
171
|
-
return result;
|
|
172
|
-
})
|
|
173
|
-
.finally(() => inFlight.delete(key));
|
|
174
|
-
inFlight.set(key, acquiring);
|
|
175
|
-
return acquiring;
|
|
176
|
-
},
|
|
177
|
-
invalidate(definition: CredentialDefinition) {
|
|
178
|
-
tokens.delete(keyOf(definition));
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export type CredentialCache = ReturnType<typeof createCredentialCache>;
|