@m6d/cortex-cli 1.0.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 -0
- package/bin/cortex.js +52 -0
- package/package.json +37 -0
- package/src/cli.ts +63 -0
- package/src/commands/graph.ts +253 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +285 -0
- package/src/config/load.ts +76 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/README.md +23 -0
- package/src/contracts/graph/embed.ts +50 -0
- package/src/contracts/graph/helpers.ts +63 -0
- package/src/contracts/graph/neo4j.ts +97 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +131 -0
- package/src/contracts/graph.ts +36 -0
- package/src/contracts/runtime.ts +208 -0
- package/src/contracts/wire.ts +143 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +227 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +189 -0
- package/src/scaffold/files.ts +568 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/swagger/extract-endpoints.ts +258 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +149 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +102 -0
- package/src/ui/report.ts +102 -0
- package/tsconfig.json +45 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every file `cortex new` writes, as one pure function. The tooling configs are
|
|
3
|
+
* inlined rather than pulled from `@m6d/eslint-config`, `@m6d/prettier-config`
|
|
4
|
+
* and `@m6d/tsconfig`: those are `private: true` at `0.0.0` and resolvable only
|
|
5
|
+
* through this workspace, and a standalone scaffold has no workspace. See
|
|
6
|
+
* docs/design/cortex-cli.md §7.5-§7.6.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
|
|
10
|
+
import { applyPorts, type Ports } from "@/scaffold/ports";
|
|
11
|
+
|
|
12
|
+
export type Database = "postgres" | "mssql";
|
|
13
|
+
|
|
14
|
+
// The generated project depends on these directly, not transitively: its own code
|
|
15
|
+
// imports `zod` for tool schemas and `hono` for extra routes on `server.app`.
|
|
16
|
+
// Ranges come from packages/cortex-server's peers, which is the compatibility
|
|
17
|
+
// statement the server itself publishes.
|
|
18
|
+
const DEPENDENCIES = {
|
|
19
|
+
"@m6d/cortex-server": ">=0.1.0",
|
|
20
|
+
"@tanstack/ai": "^0.43.1",
|
|
21
|
+
"@tanstack/ai-openai": "^0.18.0",
|
|
22
|
+
hono: "^4.12.3",
|
|
23
|
+
jose: "^6.1.3",
|
|
24
|
+
openai: "^6",
|
|
25
|
+
zod: "^4.3.6",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEV_DEPENDENCIES = {
|
|
29
|
+
"@types/bun": "latest",
|
|
30
|
+
cspell: "^9.6.4",
|
|
31
|
+
eslint: "^9",
|
|
32
|
+
"eslint-config-prettier": "^10.1.8",
|
|
33
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
34
|
+
fallow: "^3.9.1",
|
|
35
|
+
prettier: "^3.6.2",
|
|
36
|
+
typescript: "^5",
|
|
37
|
+
"typescript-eslint": "^8.65.0",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Postgres is the one we run, so it gets a scaffolded port. MSSQL is normally an
|
|
41
|
+
// existing instance nobody moved, which is why it keeps 1433 and has no service.
|
|
42
|
+
const CONNECTION_STRING: Record<Database, string> = {
|
|
43
|
+
postgres: "postgres://postgres:postgres@localhost:{{port.postgres}}/cortex",
|
|
44
|
+
mssql: "Server=localhost,1433;Database=cortex;User Id=sa;Password=your-password;Encrypt=false",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function packageJson(name: string) {
|
|
48
|
+
return `${JSON.stringify(
|
|
49
|
+
{
|
|
50
|
+
name,
|
|
51
|
+
version: "0.1.0",
|
|
52
|
+
private: true,
|
|
53
|
+
type: "module",
|
|
54
|
+
scripts: {
|
|
55
|
+
// Not `fallow audit`: it scopes itself to files changed against a
|
|
56
|
+
// base branch, and a project on line one has no commits and no
|
|
57
|
+
// remote to derive one from. Bare `fallow` runs the same three
|
|
58
|
+
// analyses — dead code, duplication, complexity — over everything.
|
|
59
|
+
audit: "fallow",
|
|
60
|
+
check: "tsc --noEmit",
|
|
61
|
+
dev: "bun run --hot index.ts",
|
|
62
|
+
format: "prettier --write .",
|
|
63
|
+
"format:check": "prettier --check .",
|
|
64
|
+
lint: "eslint",
|
|
65
|
+
spellcheck: 'cspell "**/*.{ts,mjs,json,md}"',
|
|
66
|
+
verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
|
|
67
|
+
},
|
|
68
|
+
dependencies: DEPENDENCIES,
|
|
69
|
+
devDependencies: DEV_DEPENDENCIES,
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
4,
|
|
73
|
+
)}\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Never toggled, so they only ever appear commented out: `context` is fully
|
|
78
|
+
* defaulted, and the other two are what you reach for once a real problem shows
|
|
79
|
+
* up — noisy graph retrieval, a cheap-model path — not on day one.
|
|
80
|
+
*/
|
|
81
|
+
const NEVER_SCAFFOLDED: [note: string, config: string][] = [
|
|
82
|
+
[
|
|
83
|
+
"A cheaper, faster model for auxiliary passes. Same shape as `model`.",
|
|
84
|
+
`fastModel: {
|
|
85
|
+
baseURL: process.env["CORTEX_FAST_MODEL_URL"]!,
|
|
86
|
+
apiKey: process.env["CORTEX_FAST_MODEL_KEY"]!,
|
|
87
|
+
modelName: process.env["CORTEX_FAST_MODEL_NAME"]!,
|
|
88
|
+
},`,
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
"Re-ranks knowledge-graph retrieval when it comes back noisy.",
|
|
92
|
+
`reranker: {
|
|
93
|
+
url: process.env["CORTEX_RERANKER_URL"]!,
|
|
94
|
+
apiKey: process.env["CORTEX_RERANKER_KEY"]!,
|
|
95
|
+
},`,
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
"Context-window tuning. Every field is defaulted, so override only what you\nhave measured a reason to change.",
|
|
99
|
+
`context: {
|
|
100
|
+
maxContextTokens: 120_000,
|
|
101
|
+
recentMessagesToKeep: 6,
|
|
102
|
+
},`,
|
|
103
|
+
],
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/** Fragments are authored at zero indentation and placed by these two. */
|
|
107
|
+
function indented(block: string) {
|
|
108
|
+
return block.replace(/^(?!$)/gm, " ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function commented(block: string) {
|
|
112
|
+
return block.replace(/^(?!$)/gm, " // ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A note, then its fragment — written live, or written disabled. */
|
|
116
|
+
function block(note: string, config: string, enabled: boolean) {
|
|
117
|
+
return `\n${commented(note)}\n${enabled ? indented(config) : commented(config)}\n`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cortexConfig(database: Database, features: Feature[]) {
|
|
121
|
+
const fragment = (feature: Feature) =>
|
|
122
|
+
block(FEATURES[feature].note, FEATURES[feature].config, features.includes(feature));
|
|
123
|
+
|
|
124
|
+
const on = FEATURE_NAMES.filter((feature) => features.includes(feature))
|
|
125
|
+
.map(fragment)
|
|
126
|
+
.join("");
|
|
127
|
+
const off = FEATURE_NAMES.filter((feature) => !features.includes(feature))
|
|
128
|
+
.map(fragment)
|
|
129
|
+
.join("");
|
|
130
|
+
const never = NEVER_SCAFFOLDED.map(([note, config]) => block(note, config, false)).join("");
|
|
131
|
+
|
|
132
|
+
// cspell.json ships an empty word list on purpose, so the Cortex jargon the
|
|
133
|
+
// config names teaches the checker from the file that uses it.
|
|
134
|
+
return `import { defineAgent } from "@m6d/cortex-server";
|
|
135
|
+
import type { CortexConfig } from "@m6d/cortex-server";
|
|
136
|
+
|
|
137
|
+
// cspell:words reranker
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Inert on purpose: this file default-exports data and nothing else, so importing
|
|
141
|
+
* it never opens a connection or binds a port. Construction lives in index.ts,
|
|
142
|
+
* which is what lets tooling read this config without booting the server.
|
|
143
|
+
*/
|
|
144
|
+
export default {
|
|
145
|
+
port: 3331,
|
|
146
|
+
database: {
|
|
147
|
+
type: "${database}",
|
|
148
|
+
connectionString: process.env["CORTEX_DATABASE_URL"]!,
|
|
149
|
+
},
|
|
150
|
+
model: {
|
|
151
|
+
baseURL: process.env["CORTEX_MODEL_URL"]!,
|
|
152
|
+
apiKey: process.env["CORTEX_MODEL_KEY"]!,
|
|
153
|
+
modelName: process.env["CORTEX_MODEL_NAME"]!,
|
|
154
|
+
},
|
|
155
|
+
${on === "" ? "" : `${on}\n`} agents: {
|
|
156
|
+
assistant: defineAgent({
|
|
157
|
+
systemPrompt: "You are a helpful assistant.",
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// Everything below is off, written out so the file documents what else the
|
|
162
|
+
// server does. Uncomment a block and fill in its keys in .env to enable it.
|
|
163
|
+
${off}${never}} satisfies CortexConfig;
|
|
164
|
+
`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function indexTs() {
|
|
168
|
+
return `import { createCortex } from "@m6d/cortex-server";
|
|
169
|
+
import config from "./cortex.config";
|
|
170
|
+
|
|
171
|
+
const cortex = createCortex(config);
|
|
172
|
+
|
|
173
|
+
const server = await cortex.serve();
|
|
174
|
+
|
|
175
|
+
// Your own routes hang off the same Hono app the agent runtime serves from:
|
|
176
|
+
// server.app.get("/hello", function (c) {
|
|
177
|
+
// return c.text("hello");
|
|
178
|
+
// });
|
|
179
|
+
|
|
180
|
+
export default {
|
|
181
|
+
port: server.port,
|
|
182
|
+
fetch: server.fetch,
|
|
183
|
+
websocket: server.websocket,
|
|
184
|
+
idleTimeout: 0,
|
|
185
|
+
};
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whether this selection runs anything of its own — which is the same question as
|
|
191
|
+
* "did any port get chosen", since a chosen port is always some service's.
|
|
192
|
+
*/
|
|
193
|
+
function hasServices(database: Database, features: Feature[]) {
|
|
194
|
+
return (
|
|
195
|
+
database === "postgres" ||
|
|
196
|
+
features.some((feature) => FEATURES[feature].compose !== undefined)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function env(database: Database, features: Feature[]) {
|
|
201
|
+
const blocks = FEATURE_NAMES.filter((feature) => features.includes(feature)).map(
|
|
202
|
+
(feature) => `\n${FEATURES[feature].env}\n`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Only the URLs pointing at scaffolded services carry a chosen port, and those
|
|
206
|
+
// are exactly the ones docker-compose.yml publishes.
|
|
207
|
+
const note = hasServices(database, features)
|
|
208
|
+
? `#
|
|
209
|
+
# The service ports below are not the canonical ones. They were picked when this
|
|
210
|
+
# project was scaffolded, from what was free on the machine, so it does not
|
|
211
|
+
# collide with anything else you run. Change them freely — in step with
|
|
212
|
+
# docker-compose.yml.
|
|
213
|
+
`
|
|
214
|
+
: "";
|
|
215
|
+
|
|
216
|
+
return `# Environment for this Cortex server. Bun loads this file from the directory you
|
|
217
|
+
# run in, so it has to sit next to cortex.config.ts.
|
|
218
|
+
#
|
|
219
|
+
# .env.example is the committed copy. Keep the two in step; keep secrets in .env.
|
|
220
|
+
${note}
|
|
221
|
+
# --- Model -------------------------------------------------------------------
|
|
222
|
+
# Any OpenAI-compatible endpoint: base URL, key, and model name.
|
|
223
|
+
CORTEX_MODEL_URL=https://api.openai.com/v1
|
|
224
|
+
CORTEX_MODEL_KEY=your-model-api-key
|
|
225
|
+
CORTEX_MODEL_NAME=your-model-name
|
|
226
|
+
|
|
227
|
+
# --- Database ----------------------------------------------------------------
|
|
228
|
+
# The dialect is ${database}, set in cortex.config.ts. Change it there, not here.
|
|
229
|
+
CORTEX_DATABASE_URL=${CONNECTION_STRING[database]}
|
|
230
|
+
${blocks.join("")}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Services the selections actually need, and nothing else. `auth` and the
|
|
235
|
+
* Control Center are external, the embedding endpoint is a URL like the main
|
|
236
|
+
* model, and MSSQL is normally an existing instance — so with `--yes --database
|
|
237
|
+
* mssql` there is no service at all, and no file is written.
|
|
238
|
+
*/
|
|
239
|
+
function dockerCompose(name: string, database: Database, features: Feature[]) {
|
|
240
|
+
const services: string[] = [];
|
|
241
|
+
const volumes: string[] = [];
|
|
242
|
+
|
|
243
|
+
if (database === "postgres") {
|
|
244
|
+
services.push(` postgres:
|
|
245
|
+
image: postgres:17-alpine
|
|
246
|
+
environment:
|
|
247
|
+
POSTGRES_USER: postgres
|
|
248
|
+
POSTGRES_PASSWORD: postgres
|
|
249
|
+
POSTGRES_DB: cortex
|
|
250
|
+
ports:
|
|
251
|
+
- "{{port.postgres}}:5432"
|
|
252
|
+
volumes:
|
|
253
|
+
- postgres-data:/var/lib/postgresql/data`);
|
|
254
|
+
volumes.push("postgres-data");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const feature of FEATURE_NAMES.filter((feature) => features.includes(feature))) {
|
|
258
|
+
const { compose, volume } = FEATURES[feature];
|
|
259
|
+
if (compose === undefined) continue;
|
|
260
|
+
services.push(compose);
|
|
261
|
+
if (volume !== undefined) volumes.push(volume);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (services.length === 0) return undefined;
|
|
265
|
+
|
|
266
|
+
const volumeBlock =
|
|
267
|
+
volumes.length === 0
|
|
268
|
+
? ""
|
|
269
|
+
: `\nvolumes:\n${volumes.map((name) => ` ${name}:\n`).join("")}`;
|
|
270
|
+
|
|
271
|
+
return `# Local infrastructure for this project. The .env this was scaffolded with
|
|
272
|
+
# already points at these, so \`docker compose up -d\` is enough.
|
|
273
|
+
#
|
|
274
|
+
# The host ports were picked at scaffold time from what was free on the machine —
|
|
275
|
+
# the container ports next to them are the canonical ones and should stay put.
|
|
276
|
+
# Move a host port and .env has to follow.
|
|
277
|
+
|
|
278
|
+
# Explicit so the project never collides with another checkout whose directory
|
|
279
|
+
# happens to share this one's name (Compose defaults to the directory name, and
|
|
280
|
+
# a name clash silently replaces the other project's containers and volumes).
|
|
281
|
+
name: ${name}-cortex-server
|
|
282
|
+
|
|
283
|
+
services:
|
|
284
|
+
${services.join("\n\n")}
|
|
285
|
+
${volumeBlock}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `--knowledge` scaffolds the directory `domainsDir` defaults to, which is what
|
|
290
|
+
* makes the convention true by construction: `cortex swagger sync` finds it with
|
|
291
|
+
* no config key.
|
|
292
|
+
*/
|
|
293
|
+
function domainsReadme() {
|
|
294
|
+
return `# Domains
|
|
295
|
+
|
|
296
|
+
Domain definitions live here, one directory each, and are what \`cortex graph seed\` loads into Neo4j.
|
|
297
|
+
|
|
298
|
+
\`\`\`
|
|
299
|
+
src/domains/
|
|
300
|
+
leaves/
|
|
301
|
+
index.ts defineDomain({ concepts, endpoints, rules, services })
|
|
302
|
+
concepts/leave.concept.ts
|
|
303
|
+
endpoints/listLeaves.endpoint.ts
|
|
304
|
+
services/leaveRequests.service.ts
|
|
305
|
+
\`\`\`
|
|
306
|
+
|
|
307
|
+
Export each domain from its \`index.ts\`, then list it under \`knowledge.domains\` in
|
|
308
|
+
\`cortex.config.ts\`. \`cortex swagger sync\` fills in the params, body, and response of every
|
|
309
|
+
\`.endpoint.ts\` from your Swagger spec.
|
|
310
|
+
`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function tsconfig() {
|
|
314
|
+
return `{
|
|
315
|
+
"compilerOptions": {
|
|
316
|
+
// Environment setup & latest features
|
|
317
|
+
"lib": ["ESNext"],
|
|
318
|
+
// Named explicitly rather than left to @types auto-inclusion, which is an
|
|
319
|
+
// editor-dependent heuristic. The \`Bun\` global comes from here.
|
|
320
|
+
"types": ["bun"],
|
|
321
|
+
"target": "ESNext",
|
|
322
|
+
"module": "Preserve",
|
|
323
|
+
"moduleDetection": "force",
|
|
324
|
+
|
|
325
|
+
// Bundler mode
|
|
326
|
+
"moduleResolution": "bundler",
|
|
327
|
+
"verbatimModuleSyntax": true,
|
|
328
|
+
"noEmit": true,
|
|
329
|
+
|
|
330
|
+
// Best practices
|
|
331
|
+
"strict": true,
|
|
332
|
+
"skipLibCheck": true,
|
|
333
|
+
"noFallthroughCasesInSwitch": true,
|
|
334
|
+
"noImplicitOverride": true,
|
|
335
|
+
"noUncheckedIndexedAccess": true
|
|
336
|
+
},
|
|
337
|
+
"include": ["**/*.ts"]
|
|
338
|
+
}
|
|
339
|
+
`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function gitignore() {
|
|
343
|
+
return `# Dependencies
|
|
344
|
+
node_modules/
|
|
345
|
+
|
|
346
|
+
# Environment variables — .env.example is the committed one
|
|
347
|
+
.env
|
|
348
|
+
.env.local
|
|
349
|
+
.env.*.local
|
|
350
|
+
|
|
351
|
+
# TypeScript
|
|
352
|
+
*.tsbuildinfo
|
|
353
|
+
|
|
354
|
+
# Tooling caches
|
|
355
|
+
.fallow/
|
|
356
|
+
|
|
357
|
+
# Logs
|
|
358
|
+
*.log
|
|
359
|
+
|
|
360
|
+
# OS files
|
|
361
|
+
.DS_Store
|
|
362
|
+
`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function eslintConfig() {
|
|
366
|
+
return `import { defineConfig, globalIgnores } from "eslint/config";
|
|
367
|
+
import prettierConfig from "eslint-config-prettier";
|
|
368
|
+
import prettierPlugin from "eslint-plugin-prettier";
|
|
369
|
+
import tseslint from "typescript-eslint";
|
|
370
|
+
|
|
371
|
+
export default defineConfig([
|
|
372
|
+
globalIgnores(["**/node_modules/**", "**/dist/**", "**/.fallow/**"]),
|
|
373
|
+
prettierConfig,
|
|
374
|
+
{
|
|
375
|
+
plugins: { prettier: prettierPlugin },
|
|
376
|
+
rules: { "prettier/prettier": "error" },
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
files: ["**/*.ts"],
|
|
380
|
+
extends: [tseslint.configs.recommendedTypeChecked],
|
|
381
|
+
languageOptions: {
|
|
382
|
+
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
]);
|
|
386
|
+
`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function prettierConfig() {
|
|
390
|
+
return `export default {
|
|
391
|
+
printWidth: 100,
|
|
392
|
+
singleQuote: false,
|
|
393
|
+
tabWidth: 4,
|
|
394
|
+
};
|
|
395
|
+
`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function cspellConfig() {
|
|
399
|
+
return `${JSON.stringify(
|
|
400
|
+
{
|
|
401
|
+
version: "0.2",
|
|
402
|
+
language: "en",
|
|
403
|
+
// Yours to fill in. Project jargon belongs here; nothing is seeded,
|
|
404
|
+
// because a stranger's vocabulary is noise in your repo.
|
|
405
|
+
words: [],
|
|
406
|
+
ignorePaths: [
|
|
407
|
+
"node_modules/**",
|
|
408
|
+
"out/**",
|
|
409
|
+
"build/**",
|
|
410
|
+
"**/dist/**",
|
|
411
|
+
"coverage/**",
|
|
412
|
+
"*.tsbuildinfo",
|
|
413
|
+
"bun.lock",
|
|
414
|
+
"package-lock.json",
|
|
415
|
+
"pnpm-lock.yaml",
|
|
416
|
+
"yarn.lock",
|
|
417
|
+
"*.db",
|
|
418
|
+
"*.sqlite",
|
|
419
|
+
"scratch.*",
|
|
420
|
+
".env*",
|
|
421
|
+
"**/*.min.js",
|
|
422
|
+
"**/*.min.css",
|
|
423
|
+
"**/db/migrations/**",
|
|
424
|
+
"**/i18n/**",
|
|
425
|
+
"docs",
|
|
426
|
+
".claude",
|
|
427
|
+
".angular",
|
|
428
|
+
".vercel/**",
|
|
429
|
+
"vendor/**",
|
|
430
|
+
".gitignore",
|
|
431
|
+
"**/domains/**/*.concept.ts",
|
|
432
|
+
],
|
|
433
|
+
dictionaries: [
|
|
434
|
+
"typescript",
|
|
435
|
+
"node",
|
|
436
|
+
"npm",
|
|
437
|
+
"html",
|
|
438
|
+
"css",
|
|
439
|
+
"bash",
|
|
440
|
+
"companies",
|
|
441
|
+
"softwareTerms",
|
|
442
|
+
],
|
|
443
|
+
},
|
|
444
|
+
null,
|
|
445
|
+
4,
|
|
446
|
+
)}\n`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function fallowrc() {
|
|
450
|
+
return `{
|
|
451
|
+
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
|
452
|
+
|
|
453
|
+
// The single root everything else hangs off. Left to auto-detection, framework
|
|
454
|
+
// heuristics mark far too much as reachable and unused exports stop being
|
|
455
|
+
// reportable at all.
|
|
456
|
+
"entry": ["index.ts"],
|
|
457
|
+
|
|
458
|
+
// These are @m6d/cortex-server's peer dependencies. The server needs them
|
|
459
|
+
// resolvable and it is this package.json's job to install them, but nothing
|
|
460
|
+
// here imports them until you write the code that does — a tool schema in
|
|
461
|
+
// zod, an extra route on \`server.app\`. A requirement that lives in another
|
|
462
|
+
// package's \`peerDependencies\` is not something reachability analysis can
|
|
463
|
+
// see, so it is stated here instead. Once your own code imports one, its
|
|
464
|
+
// entry is doing nothing and can go.
|
|
465
|
+
"ignoreDependencies": ["@tanstack/ai", "@tanstack/ai-openai", "hono", "jose", "openai", "zod"],
|
|
466
|
+
|
|
467
|
+
"duplicates": {
|
|
468
|
+
// fallow's default is 0, which means "no limit": clone groups get printed
|
|
469
|
+
// and the run still exits 0, so duplication would be reported and never
|
|
470
|
+
// blocked. A percentage is the only gate duplication has.
|
|
471
|
+
//
|
|
472
|
+
// Read it knowing the denominator is your whole project: 10% of a young
|
|
473
|
+
// one is a handful of lines, so early on this is close to "no copy-paste
|
|
474
|
+
// at all", which is the right setting for code nobody has had time to
|
|
475
|
+
// duplicate yet. Raise it once the number stops meaning anything.
|
|
476
|
+
"threshold": 10,
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
"health": {
|
|
480
|
+
// fallow's defaults, kept as-is: they are the standard we want to hold.
|
|
481
|
+
"maxCyclomatic": 20,
|
|
482
|
+
"maxCognitive": 15,
|
|
483
|
+
|
|
484
|
+
// CRAP is complexity weighted by test coverage, and a project with no
|
|
485
|
+
// coverage scores 0% everywhere, which collapses it to
|
|
486
|
+
// \`cyclomatic² + cyclomatic\`. 420 is what cyclomatic 20 scores at zero
|
|
487
|
+
// coverage, so this ceiling sits exactly on maxCyclomatic and adds nothing
|
|
488
|
+
// of its own. Give it coverage and lower it.
|
|
489
|
+
"maxCrap": 420,
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function readme(name: string, database: Database, features: Feature[], compose: boolean) {
|
|
496
|
+
const enabled = FEATURE_NAMES.filter((feature) => features.includes(feature));
|
|
497
|
+
|
|
498
|
+
return `# ${name}
|
|
499
|
+
|
|
500
|
+
A Cortex server: one agent, a ${database} database, and an OpenAI-compatible model.${
|
|
501
|
+
enabled.length === 0 ? "" : ` Also on: ${enabled.join(", ")}.`
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
## Getting started
|
|
505
|
+
|
|
506
|
+
\`\`\`sh
|
|
507
|
+
bun install
|
|
508
|
+
${compose ? "docker compose up -d\n" : ""}# fill in CORTEX_MODEL_* in .env
|
|
509
|
+
bun run dev
|
|
510
|
+
\`\`\`
|
|
511
|
+
|
|
512
|
+
The server listens on http://localhost:3331. Change \`port\` in \`cortex.config.ts\` to move it.${
|
|
513
|
+
compose
|
|
514
|
+
? `
|
|
515
|
+
|
|
516
|
+
The service ports in \`docker-compose.yml\` are not the canonical ones: they were picked at scaffold
|
|
517
|
+
time from what was free on this machine, so this project does not fight whatever else you run.
|
|
518
|
+
\`.env\` already points at them.`
|
|
519
|
+
: ""
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
## Layout
|
|
523
|
+
|
|
524
|
+
- \`cortex.config.ts\` — inert data. It default-exports a \`CortexConfig\` and never constructs
|
|
525
|
+
anything, so tooling can import it without booting a server.
|
|
526
|
+
- \`index.ts\` — construction. It reads the config, calls \`createCortex\`, serves, and is where your
|
|
527
|
+
own routes go.
|
|
528
|
+
- \`.env\` — local secrets, loaded by Bun from the directory you run in. \`.env.example\` is the
|
|
529
|
+
committed copy.
|
|
530
|
+
|
|
531
|
+
Commented-out sections in \`cortex.config.ts\` document what else the server can do.
|
|
532
|
+
|
|
533
|
+
## Checks
|
|
534
|
+
|
|
535
|
+
\`bun run verify\` runs formatting, lint, spelling, types, and the fallow audit — the same set a
|
|
536
|
+
pre-commit hook or CI job should run. Each is also available on its own: \`format:check\`, \`lint\`,
|
|
537
|
+
\`spellcheck\`, \`check\`, \`audit\`.
|
|
538
|
+
`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Relative path → contents. Nothing here touches the filesystem. */
|
|
542
|
+
export function scaffoldFiles(name: string, database: Database, features: Feature[], ports: Ports) {
|
|
543
|
+
const envFile = env(database, features);
|
|
544
|
+
const compose = dockerCompose(name, database, features);
|
|
545
|
+
|
|
546
|
+
const files: Record<string, string> = {
|
|
547
|
+
"cortex.config.ts": cortexConfig(database, features),
|
|
548
|
+
"index.ts": indexTs(),
|
|
549
|
+
"package.json": packageJson(name),
|
|
550
|
+
"tsconfig.json": tsconfig(),
|
|
551
|
+
".env": envFile,
|
|
552
|
+
".env.example": envFile,
|
|
553
|
+
".gitignore": gitignore(),
|
|
554
|
+
"README.md": readme(name, database, features, compose !== undefined),
|
|
555
|
+
"eslint.config.mjs": eslintConfig(),
|
|
556
|
+
"prettier.config.mjs": prettierConfig(),
|
|
557
|
+
"cspell.json": cspellConfig(),
|
|
558
|
+
".fallowrc.jsonc": fallowrc(),
|
|
559
|
+
...(compose === undefined ? {} : { "docker-compose.yml": compose }),
|
|
560
|
+
...(features.includes("knowledge") ? { "src/domains/README.md": domainsReadme() } : {}),
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// One pass at the end, over everything: no generator holds a port number of
|
|
564
|
+
// its own, so compose, .env and the README cannot disagree about one.
|
|
565
|
+
return Object.fromEntries(
|
|
566
|
+
Object.entries(files).map(([path, contents]) => [path, applyPorts(contents, ports)]),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host ports for the services a generated project runs, chosen once at scaffold
|
|
3
|
+
* time.
|
|
4
|
+
*
|
|
5
|
+
* The canonical ports — 5432, 6379, 9000/9001, 7474/7687 — are precisely the ones
|
|
6
|
+
* a developer already running Postgres or Redis has bound, so a scaffold that
|
|
7
|
+
* hardcoded them collides on its first `docker compose up -d`. Only the host side
|
|
8
|
+
* moves: container ports stay canonical, so images, health checks and anything
|
|
9
|
+
* inside the compose network are unaffected. The server's own port is not one of
|
|
10
|
+
* these — it is the project's to choose, and stays the documented default.
|
|
11
|
+
*
|
|
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 `.env` cannot
|
|
14
|
+
* drift apart, because neither holds a number of its own.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** High, unprivileged, and clear of the ephemeral range macOS and Linux hand out. */
|
|
18
|
+
const FIRST = 20_000;
|
|
19
|
+
const LAST = 39_999;
|
|
20
|
+
|
|
21
|
+
/** Enough tries to step over a busy port. Not a retry framework. */
|
|
22
|
+
const ATTEMPTS = 20;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A bind that succeeds is the only honest answer about a port, and it costs one
|
|
26
|
+
* syscall. Anything that stops the bind from being meaningful — no socket
|
|
27
|
+
* permission, a host that is not Bun — reads as "not free" and leaves the caller
|
|
28
|
+
* on its random pick, which is the fallback either way.
|
|
29
|
+
*/
|
|
30
|
+
function isFree(port: number) {
|
|
31
|
+
try {
|
|
32
|
+
Bun.listen({ hostname: "0.0.0.0", port, socket: { data() {} } }).stop(true);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A random free port, or — if nothing probes free — the first one we picked. */
|
|
40
|
+
function allocate(taken: Set<number>) {
|
|
41
|
+
let first: number | undefined;
|
|
42
|
+
|
|
43
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
44
|
+
const port = FIRST + Math.floor(Math.random() * (LAST - FIRST + 1));
|
|
45
|
+
if (taken.has(port)) continue;
|
|
46
|
+
|
|
47
|
+
first ??= port;
|
|
48
|
+
if (isFree(port)) return port;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return first ?? FIRST;
|
|
52
|
+
}
|
|
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
|
+
*/
|
|
59
|
+
export function allocatePorts() {
|
|
60
|
+
const taken = new Set<number>();
|
|
61
|
+
|
|
62
|
+
function next() {
|
|
63
|
+
const port = allocate(taken);
|
|
64
|
+
taken.add(port);
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
postgres: next(),
|
|
70
|
+
redis: next(),
|
|
71
|
+
minio: next(),
|
|
72
|
+
minioConsole: next(),
|
|
73
|
+
neo4j: next(),
|
|
74
|
+
neo4jBolt: next(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type Ports = ReturnType<typeof allocatePorts>;
|
|
79
|
+
|
|
80
|
+
/** Fills in `{{port.postgres}}` and friends. Applied to every file, once, at the end. */
|
|
81
|
+
export function applyPorts(contents: string, ports: Ports) {
|
|
82
|
+
return Object.entries(ports).reduce(
|
|
83
|
+
(text, [name, port]) => text.replaceAll(`{{port.${name}}}`, String(port)),
|
|
84
|
+
contents,
|
|
85
|
+
);
|
|
86
|
+
}
|