@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.
@@ -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
+ }