@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.
Files changed (61) hide show
  1. package/README.md +175 -0
  2. package/bin/cortex.js +52 -0
  3. package/package.json +41 -0
  4. package/src/cc/client.ts +295 -0
  5. package/src/cc/flags.ts +135 -0
  6. package/src/cc/output.ts +77 -0
  7. package/src/cc/profiles.ts +105 -0
  8. package/src/cli.ts +56 -0
  9. package/src/commands/cc.ts +504 -0
  10. package/src/commands/graph.ts +229 -0
  11. package/src/commands/new.ts +333 -0
  12. package/src/commands/swagger.ts +261 -0
  13. package/src/config/load.ts +122 -0
  14. package/src/config/validate.ts +46 -0
  15. package/src/contracts/charts/chart-spec.ts +201 -0
  16. package/src/contracts/charts/index.ts +7 -0
  17. package/src/contracts/client-tools/index.ts +82 -0
  18. package/src/contracts/egress/index.ts +161 -0
  19. package/src/contracts/egress/scrub.ts +59 -0
  20. package/src/contracts/graph/clients/embed.ts +45 -0
  21. package/src/contracts/graph/clients/neo4j.ts +85 -0
  22. package/src/contracts/graph/helpers.ts +57 -0
  23. package/src/contracts/graph/index.ts +30 -0
  24. package/src/contracts/graph/schema.ts +65 -0
  25. package/src/contracts/graph/types.ts +125 -0
  26. package/src/contracts/markdown/index.ts +39 -0
  27. package/src/contracts/rich-text/index.ts +220 -0
  28. package/src/contracts/runtime/api.ts +328 -0
  29. package/src/contracts/runtime/auth.ts +41 -0
  30. package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
  31. package/src/contracts/runtime/dataset-sql.ts +2522 -0
  32. package/src/contracts/runtime/datasets.fixtures.json +2849 -0
  33. package/src/contracts/runtime/datasets.ts +1391 -0
  34. package/src/contracts/runtime/grammar.ts +161 -0
  35. package/src/contracts/runtime/index.ts +14 -0
  36. package/src/contracts/runtime/signature.ts +237 -0
  37. package/src/contracts/runtime/sync.ts +172 -0
  38. package/src/contracts/runtime/triggers.ts +224 -0
  39. package/src/contracts/wire/index.ts +264 -0
  40. package/src/graph/expand-domains.ts +288 -0
  41. package/src/graph/generate-cypher.ts +201 -0
  42. package/src/graph/seed.ts +207 -0
  43. package/src/graph/validate.ts +78 -0
  44. package/src/scaffold/features.ts +190 -0
  45. package/src/scaffold/files.ts +411 -0
  46. package/src/scaffold/ports.ts +86 -0
  47. package/src/scaffold/templates/domains-README.md.txt +16 -0
  48. package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
  49. package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
  50. package/src/scaffold/templates/gitignore.txt +19 -0
  51. package/src/scaffold/templates/index.ts.txt +18 -0
  52. package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
  53. package/src/scaffold/templates/tsconfig.json.txt +25 -0
  54. package/src/swagger/extract-endpoints.ts +266 -0
  55. package/src/swagger/generated-block.ts +74 -0
  56. package/src/swagger/openapi-schema.ts +161 -0
  57. package/src/swagger/parse-response.ts +88 -0
  58. package/src/swagger/types.ts +25 -0
  59. package/src/ui/prompts.ts +125 -0
  60. package/src/ui/report.ts +107 -0
  61. package/tsconfig.json +46 -0
@@ -0,0 +1,229 @@
1
+ import type { Command } from "@commander-js/extra-typings";
2
+ import {
3
+ GRAPH_SCHEMA,
4
+ GRAPH_SCHEMA_VERSION,
5
+ createEmbedder,
6
+ createNeo4jClient,
7
+ type DomainDef,
8
+ type EmbeddingProviderConfig,
9
+ type Neo4jClient,
10
+ type Neo4jConfig,
11
+ } from "@cortex/contracts/graph";
12
+ import pc from "picocolors";
13
+
14
+ import { configLabel, configOption, openProject } from "@/config/load";
15
+ import { seedGraph } from "@/graph/seed";
16
+ import * as report from "@/ui/report";
17
+
18
+ const TITLE = "cortex graph seed";
19
+
20
+ /** Domain names are longer than swagger's finding labels, so the body is wider. */
21
+ const DOMAIN_COLUMN = 14;
22
+
23
+ /** A failing statement is worth one line: enough to identify, not enough to drown. */
24
+ const DETAIL_WIDTH = 60;
25
+
26
+ /** Only the parts of a loaded config `graph seed` reads. */
27
+ type ProjectConfig = {
28
+ neo4j?: Neo4jConfig;
29
+ embedding?: EmbeddingProviderConfig & { dimension: number };
30
+ knowledge?: { domains?: Record<string, DomainDef> };
31
+ agents?: Record<string, { knowledge?: { domains?: Record<string, DomainDef> } | null }>;
32
+ };
33
+
34
+ // Connection failures reach us as whatever `fetch` threw, so match on shape
35
+ // rather than on a driver's error class.
36
+ const UNREACHABLE =
37
+ /unable to connect|connection refused|econnrefused|failed to fetch|fetch failed|enotfound|getaddrinfo|timed out/i;
38
+
39
+ export function registerGraph(program: Command) {
40
+ const graph = program
41
+ .command("graph")
42
+ .helpGroup("Resources:")
43
+ .summary("The Neo4j knowledge graph")
44
+ .description(
45
+ "The Neo4j knowledge graph.\n\nReads `knowledge.domains` and `neo4j` from your cortex.config.ts.",
46
+ )
47
+ .addOption(configOption())
48
+ .helpCommand(false);
49
+
50
+ // No flags. `--domain <name>` is flag-shaped design work, not a flag:
51
+ // `expandDomains` resolves cross-domain references and the node pass creates every
52
+ // node before any edge, so a subset seed risks dangling edges.
53
+ graph
54
+ .command("seed")
55
+ .description("Load concepts, endpoints, services, and rules into Neo4j")
56
+ .action(async function () {
57
+ await seed(graph);
58
+ });
59
+ }
60
+
61
+ async function seed(group: Command) {
62
+ const { config: loaded, configPath, verbose } = await openProject(group, TITLE);
63
+ const config = loaded as ProjectConfig;
64
+
65
+ // Inputs are reported as they resolve, and only when the config has them:
66
+ // an absent section is `runSeed`'s error to throw, not ours to predict.
67
+ if (config.neo4j?.url) report.input("Neo4j", config.neo4j.url);
68
+ const domains = collectDomains(config);
69
+ const names = Object.keys(domains);
70
+ if (names.length) report.input("Domains", `${names.length} — ${names.join(", ")}`);
71
+
72
+ const startedAt = Date.now();
73
+ const result = await runSeed(config, domains).catch(function (error: unknown): never {
74
+ return failSeed({ error, config, configPath, verbose });
75
+ });
76
+
77
+ // A domain is the unit the user edits, so it is the unit the report counts
78
+ // against: a wrong number next to `attendance` points at src/domains/attendance/.
79
+ report.blank();
80
+ for (const [name, domain] of Object.entries(result.domains)) {
81
+ const counts = [report.plural(domain.nodes, "node"), report.plural(domain.edges, "edge")];
82
+ if (domain.failed) counts.push(`${domain.failed} failed`);
83
+ report.row(name, counts.join(" · "), DOMAIN_COLUMN);
84
+
85
+ // The first failure only: enough to identify what broke, not enough to
86
+ // drown the run — the count above says how many followed it.
87
+ const failure = domain.failures[0];
88
+ if (failure) {
89
+ report.row("", `${pc.red("✗")} ${truncate(failure.error)}`, DOMAIN_COLUMN);
90
+ report.row("", ` ${truncate(oneLine(failure.statement))}`, DOMAIN_COLUMN);
91
+ }
92
+ }
93
+
94
+ // Embeddings are a second pass over the whole graph, so they get their own
95
+ // step rather than a row among the counts.
96
+ report.blank();
97
+ report.input(
98
+ "Embeddings",
99
+ `${report.plural(result.embeddings.concepts, "concept")}, dimension ${result.embeddings.dimension}`,
100
+ );
101
+ report.blank();
102
+
103
+ const counts = [report.plural(result.nodes, "node"), report.plural(result.edges, "edge")];
104
+ if (result.failed) counts.push(`${result.failed} failed`);
105
+ report.footer(`${counts.join(" · ")} (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
106
+
107
+ if (result.failed) {
108
+ report.note("The graph is partially seeded. Re-running is safe — seeding is MERGE-based.");
109
+ process.exit(1);
110
+ }
111
+ }
112
+
113
+ /** Cypher is generated multi-line and indented; a report row is neither. */
114
+ function oneLine(statement: string) {
115
+ return statement.replace(/\s+/g, " ").trim();
116
+ }
117
+
118
+ function truncate(text: string) {
119
+ return text.length > DETAIL_WIDTH ? `${text.slice(0, DETAIL_WIDTH - 2)} …` : text;
120
+ }
121
+
122
+ /** Server level first, then per agent — an agent's domain of the same name wins. */
123
+ function collectDomains(config: ProjectConfig) {
124
+ const all: Record<string, DomainDef> = { ...config.knowledge?.domains };
125
+
126
+ for (const agent of Object.values(config.agents ?? {})) {
127
+ Object.assign(all, agent.knowledge?.domains);
128
+ }
129
+
130
+ return all;
131
+ }
132
+
133
+ /**
134
+ * The whole pipeline is the CLI's, built on the graph contract it depends on:
135
+ * `@cortex/contracts/graph` is where the vocabulary, the Neo4j client and the embedder
136
+ * come from, and the version it seeds at is the version of that dependency.
137
+ */
138
+ async function runSeed(config: ProjectConfig, domains: Record<string, DomainDef>) {
139
+ if (!Object.keys(domains).length) throw new Error("No domains found in knowledge config");
140
+ if (!config.neo4j) throw new Error("neo4j config is required for graph seed");
141
+ if (!config.embedding) throw new Error("embedding config is required for graph seed");
142
+
143
+ const embed = createEmbedder(config.embedding);
144
+ const neo4j = createNeo4jClient(config.neo4j, embed);
145
+
146
+ await warnOnRestamp(neo4j);
147
+
148
+ return await seedGraph(
149
+ {
150
+ neo4j,
151
+ embedding: { embed, dimension: config.embedding.dimension },
152
+ schema: GRAPH_SCHEMA,
153
+ schemaVersion: GRAPH_SCHEMA_VERSION,
154
+ },
155
+ domains,
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Seeding is MERGE-based, so a graph written under an older schema is not cleaned
161
+ * up — its nodes stay, under labels nothing reads. Worth saying out loud; not
162
+ * worth blocking on, since re-seeding is exactly how that graph gets fixed.
163
+ */
164
+ async function warnOnRestamp(neo4j: Neo4jClient) {
165
+ // An unreachable graph is the seed's to report, far better than a warning could.
166
+ const rows = await neo4j
167
+ .query(
168
+ `MATCH (g:${GRAPH_SCHEMA.label.marker}) RETURN g.${GRAPH_SCHEMA.prop.version} AS version`,
169
+ )
170
+ .catch(() => undefined);
171
+ if (!rows) return;
172
+
173
+ const stamped = (rows as { version?: number }[])[0]?.version;
174
+ if (stamped === undefined || stamped === GRAPH_SCHEMA_VERSION) return;
175
+
176
+ report.blank();
177
+ report.row(
178
+ "",
179
+ `${pc.yellow("!")} Graph was written with schema v${stamped}; restamping as v${GRAPH_SCHEMA_VERSION}.`,
180
+ DOMAIN_COLUMN,
181
+ );
182
+ report.row("", " Nodes from the old schema are not removed.", DOMAIN_COLUMN);
183
+ }
184
+
185
+ /**
186
+ * Section requirements are `runSeed`'s to enforce — it already throws
187
+ * `"neo4j config is required for graph seed"`. This renders what it threw.
188
+ */
189
+ function failSeed(options: {
190
+ error: unknown;
191
+ config: ProjectConfig;
192
+ configPath: string;
193
+ verbose: boolean;
194
+ }): never {
195
+ const { error, config, configPath, verbose } = options;
196
+ const label = configLabel(configPath);
197
+ const message = error instanceof Error ? error.message : String(error);
198
+
199
+ if (message === "No domains found in knowledge config") {
200
+ report.fail({
201
+ what: `${label} has no knowledge.domains`,
202
+ blocks: [
203
+ "`graph seed` loads domain definitions into Neo4j. Add them:",
204
+ " knowledge: { domains: { leaves: leavesDomain } }",
205
+ "See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
206
+ ],
207
+ });
208
+ }
209
+
210
+ const url = config.neo4j?.url;
211
+ if (url !== undefined && UNREACHABLE.test(message)) {
212
+ report.fail({
213
+ what: `Cannot reach Neo4j at ${url}`,
214
+ error,
215
+ blocks: [
216
+ "Is it running? docker compose up -d neo4j",
217
+ `Otherwise check neo4j.url / neo4j.user / neo4j.password in\n${label}.`,
218
+ ],
219
+ verbose,
220
+ });
221
+ }
222
+
223
+ report.fail({
224
+ what: "Could not seed the graph",
225
+ error,
226
+ blocks: [`Check neo4j, embedding, and knowledge.domains in ${label}.`],
227
+ verbose,
228
+ });
229
+ }
@@ -0,0 +1,333 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ import { Option, type Command } from "@commander-js/extra-typings";
6
+
7
+ import pkg from "../../package.json";
8
+ import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
9
+ import { scaffoldFiles, type Database } from "@/scaffold/files";
10
+ import { allocatePorts } from "@/scaffold/ports";
11
+ import { askDatabase, askFeatures, askName, intro, log, outro, runSteps } from "@/ui/prompts";
12
+ import * as report from "@/ui/report";
13
+
14
+ /** npm's rule for an unscoped package name. The name is the directory too. */
15
+ const VALID_NAME = /^[a-z0-9][a-z0-9._-]*$/;
16
+ const MAX_NAME = 214;
17
+
18
+ /** The two names npm rejects outright, however well-formed they otherwise are. */
19
+ const RESERVED_NAMES = ["node_modules", "favicon.ico"];
20
+
21
+ export function registerNew(program: Command) {
22
+ const command = program
23
+ .command("new")
24
+ .helpGroup("Project:")
25
+ .description("Scaffold a new Cortex server")
26
+ .argument("[name]", "Directory to create, and the generated package name")
27
+ .addOption(
28
+ new Option("--database <type>", "Database the project is configured for")
29
+ .choices(["postgres", "mssql"] as const)
30
+ .default("postgres" as const),
31
+ )
32
+ // Both halves of every toggle, so a flag can answer its question either
33
+ // way and `--yes` is not the only way to say "no". Declaring the positive
34
+ // first leaves the value `undefined` when neither is passed, which is
35
+ // what marks the question as still unanswered.
36
+ .option("--attachments", FEATURES.attachments.hint)
37
+ .option("--no-attachments", "Leave attachments out")
38
+ .option("--redis", FEATURES.redis.hint)
39
+ .option("--no-redis", "Leave Redis out")
40
+ .option("--auth", FEATURES.auth.hint)
41
+ .option("--no-auth", "Leave auth out")
42
+ .option("--knowledge", FEATURES.knowledge.hint)
43
+ .option("--no-knowledge", "Leave the knowledge graph out")
44
+ .option("--control-center", FEATURES.controlCenter.hint)
45
+ .option("--no-control-center", "Leave the Control Center out")
46
+ .option("--yes", "Accept the defaults instead of asking");
47
+
48
+ command.action(async function (name, options) {
49
+ // The globals live on the root command, which reaches this file untyped —
50
+ // `Command` here is the parameter type, not cli.ts's inferred one.
51
+ const { verbose = false } = program.opts() as { verbose?: boolean };
52
+
53
+ await scaffold(name, {
54
+ ...options,
55
+ verbose,
56
+ databasePassed: command.getOptionValueSource("database") !== "default",
57
+ });
58
+ });
59
+ }
60
+
61
+ type Options = {
62
+ database: Database;
63
+ yes?: boolean;
64
+ verbose: boolean;
65
+ databasePassed: boolean;
66
+ } & Partial<Record<Feature, boolean>>;
67
+
68
+ async function scaffold(name: string | undefined, options: Options) {
69
+ const { verbose, databasePassed } = options;
70
+
71
+ // No TTY ⇒ never prompt and behave as `--yes`. On Bun a
72
+ // piped stdin reports `undefined` rather than `false`, which is why this
73
+ // compares against `true`.
74
+ const interactive = process.stdin.isTTY === true && options.yes !== true;
75
+
76
+ if (name === undefined && !interactive) missingName();
77
+
78
+ // A name that came from a flag is checked before anything prints; one that
79
+ // is prompted for is checked by the prompt, which can ask again.
80
+ if (name !== undefined && !isValidName(name)) failInvalidName(name);
81
+ if (name !== undefined && isTaken(resolve(process.cwd(), name))) failTaken(name);
82
+
83
+ const started = Date.now();
84
+ intro(`cortex new · ${pkg.name} ${pkg.version}`);
85
+
86
+ const project = name ?? (await askName(checkName)).trim();
87
+
88
+ const database = interactive && !databasePassed ? await askDatabase() : options.database;
89
+ const features = await chooseFeatures(options, interactive);
90
+
91
+ const target = resolve(process.cwd(), project);
92
+
93
+ // Allocated here rather than inside the generators, which stay pure: picking a
94
+ // port means probing the machine for a free one.
95
+ const ports = allocatePorts();
96
+ const files = scaffoldFiles(project, database, features, ports);
97
+ let failedInstall: string | undefined;
98
+
99
+ await runSteps([
100
+ {
101
+ title: `Writing ${Object.keys(files).length} files`,
102
+ run: function () {
103
+ for (const [path, contents] of Object.entries(files)) {
104
+ const file = join(target, path);
105
+ mkdirSync(dirname(file), { recursive: true });
106
+ writeFileSync(file, contents);
107
+ }
108
+
109
+ const summary = [database, ...features, "model"].join(" · ");
110
+ return `Wrote ${Object.keys(files).length} files to ./${project}\n${summary}`;
111
+ },
112
+ },
113
+ {
114
+ // Always, no flag: running it is what separates a scaffold from a
115
+ // template dump.
116
+ title: "Installing dependencies",
117
+ run: async function () {
118
+ // Timed from here, not from the run: the prompts above are the
119
+ // user's time, and "in 41.0s" of which 38 were spent answering
120
+ // questions is a number nobody can act on.
121
+ const startedInstall = Date.now();
122
+ const result = await install(target);
123
+
124
+ if (!result.ok) {
125
+ failedInstall = result.output;
126
+ // What went wrong is reported below, once the run is over;
127
+ // --verbose still owes the reader the command that failed.
128
+ return verbose ? "$ bun install" : undefined;
129
+ }
130
+
131
+ const packages = /(\d+) packages? installed/.exec(result.output)?.[1];
132
+ const what = packages === undefined ? "dependencies" : `${packages} packages`;
133
+ const done = `Installed ${what} in ${seconds(startedInstall)}`;
134
+ return verbose ? `$ bun install\n${done}` : done;
135
+ },
136
+ },
137
+ {
138
+ // A repo nested inside another is a mess to discover later, and the
139
+ // .gitignore just written is inert without one. Silent either way,
140
+ // which is why this step reports nothing.
141
+ title: "Initializing git",
142
+ run: function () {
143
+ if (isInsideWorkTree(target)) {
144
+ return verbose ? "git init skipped — already inside a work tree" : undefined;
145
+ }
146
+
147
+ spawnSync("git", ["init"], { cwd: target, stdio: "ignore" });
148
+ return verbose ? "$ git init" : undefined;
149
+ },
150
+ },
151
+ ]);
152
+
153
+ if (failedInstall !== undefined) warnInstall(project, failedInstall, verbose);
154
+
155
+ outro(`Done in ${seconds(started)}`);
156
+ process.stdout.write(nextSteps(project, features, files));
157
+ }
158
+
159
+ /**
160
+ * The files are correct; only the install did not happen, so this warns and the
161
+ * run still exits 0. What bun said is the whole diagnosis, so it is kept: the
162
+ * last line by default, the captured log under --verbose.
163
+ */
164
+ function warnInstall(project: string, output: string, verbose: boolean) {
165
+ const detail = verbose ? output.trim() : lastLine(output);
166
+
167
+ log.warn(
168
+ // clack indents continuation lines by 3, so 2 more puts the command in
169
+ // the same column the error grammar uses.
170
+ `bun install did not finish. The files are written — run it yourself:\n\n` +
171
+ ` cd ${project} && bun install` +
172
+ (detail === "" ? "" : `\n\n${detail}`),
173
+ );
174
+ }
175
+
176
+ function nextSteps(project: string, features: Feature[], files: ReturnType<typeof scaffoldFiles>) {
177
+ const keys = [
178
+ "CORTEX_DATABASE_URL",
179
+ "CORTEX_MODEL_*",
180
+ ...features.map((feature) => FEATURES[feature].secrets).filter((key) => key !== undefined),
181
+ ];
182
+
183
+ return (
184
+ `\nNext steps:\n\n` +
185
+ step(`cd ${project}`) +
186
+ step("edit .env", `fill in ${keys.length === 2 ? keys.join(" and ") : keys.join(", ")}`) +
187
+ (files["docker-compose.yml"] === undefined ? "" : step("docker compose up -d")) +
188
+ // The `port` the generated cortex.config.ts sets, which is the server's
189
+ // own default. Service ports move at scaffold time; this one does not.
190
+ step("bun run dev", "→ http://localhost:3331") +
191
+ `\n`
192
+ );
193
+ }
194
+
195
+ /**
196
+ * One multi-select for the whole catalogue, minus whatever the flags already
197
+ * settled: a passed flag suppresses its question, the rest are still asked.
198
+ */
199
+ async function chooseFeatures(options: Options, interactive: boolean) {
200
+ const chosen = new Set(FEATURE_NAMES.filter((feature) => options[feature] === true));
201
+ const undecided = FEATURE_NAMES.filter((feature) => options[feature] === undefined);
202
+
203
+ if (interactive && undecided.length > 0) {
204
+ for (const feature of await askFeatures(undecided)) chosen.add(feature);
205
+ }
206
+
207
+ // Catalogue order, not the order the flags or the checkboxes came in.
208
+ return FEATURE_NAMES.filter((feature) => chosen.has(feature));
209
+ }
210
+
211
+ async function install(cwd: string) {
212
+ try {
213
+ // Piped rather than inherited: the spinner owns the screen while this runs.
214
+ // bun prints its summary to stderr, so both streams are read.
215
+ const child = Bun.spawn(["bun", "install"], { cwd, stdout: "pipe", stderr: "pipe" });
216
+ const output = await Promise.all([
217
+ new Response(child.stdout).text(),
218
+ new Response(child.stderr).text(),
219
+ ]);
220
+
221
+ return { ok: (await child.exited) === 0, output: output.join("") };
222
+ } catch (error) {
223
+ // A spawn that never starts — EACCES, EMFILE — is still just an install
224
+ // that did not happen, and the files are already written. It takes the
225
+ // same warn-and-exit-0 path as a bun install that ran and failed, rather
226
+ // than surfacing as "cortex hit an unexpected error".
227
+ return { ok: false, output: error instanceof Error ? error.message : String(error) };
228
+ }
229
+ }
230
+
231
+ function step(command: string, annotation?: string) {
232
+ // 27 aligns the annotation column across all report rows.
233
+ return annotation ? ` ${command.padEnd(27)}${annotation}\n` : ` ${command}\n`;
234
+ }
235
+
236
+ function seconds(started: number) {
237
+ return `${((Date.now() - started) / 1000).toFixed(1)}s`;
238
+ }
239
+
240
+ /** bun puts the reason last, and that one line is what gets pasted into a search. */
241
+ function lastLine(output: string) {
242
+ return output.trim().split("\n").at(-1)?.trim() ?? "";
243
+ }
244
+
245
+ function isValidName(name: string) {
246
+ return name.length <= MAX_NAME && VALID_NAME.test(name) && !RESERVED_NAMES.includes(name);
247
+ }
248
+
249
+ /** The prompt's rules, which are the flag path's two failures asked one at a time. */
250
+ function checkName(candidate: string) {
251
+ // One line each: clack prints them inside the prompt, which will not wrap.
252
+ if (RESERVED_NAMES.includes(candidate)) return `npm does not allow the name ${candidate}.`;
253
+ if (!isValidName(candidate)) return "Lowercase letters, digits, and . _ - only.";
254
+ if (isTaken(resolve(process.cwd(), candidate))) {
255
+ return `./${candidate} already exists and is not an empty directory.`;
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ /** `My App` → `my-app`, so the suggested command is one the user can paste. */
261
+ function slugify(name: string) {
262
+ const slug = name
263
+ .trim()
264
+ .toLowerCase()
265
+ .replace(/[^a-z0-9._-]+/g, "-")
266
+ .replace(/^[^a-z0-9]+/, "")
267
+ .slice(0, MAX_NAME);
268
+
269
+ return slug === "" || RESERVED_NAMES.includes(slug) ? "my-app" : slug;
270
+ }
271
+
272
+ /**
273
+ * A missing path is the only clean go-ahead, and an empty directory the one
274
+ * tolerated exception. Anything else — a regular file of that name, a directory
275
+ * with contents, one we lack permission to read — is a conflict: swallowing it
276
+ * scaffolds halfway and then dies on the first write.
277
+ */
278
+ function isTaken(target: string) {
279
+ try {
280
+ return readdirSync(target).length > 0;
281
+ } catch (error) {
282
+ return !(error instanceof Error && "code" in error && error.code === "ENOENT");
283
+ }
284
+ }
285
+
286
+ function isInsideWorkTree(target: string) {
287
+ return (
288
+ spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
289
+ cwd: target,
290
+ stdio: "ignore",
291
+ }).status === 0
292
+ );
293
+ }
294
+
295
+ function missingName(): never {
296
+ // No TTY ⇒ never prompt, so a missing required argument is a hard error
297
+ // With a TTY this branch is only reached under `--yes`,
298
+ // which answers every question including the one that has no default.
299
+ report.fail({
300
+ what:
301
+ process.stdin.isTTY === true
302
+ ? "cortex new needs a project name."
303
+ : "cortex new needs a project name when stdin is not a TTY.",
304
+ blocks: [
305
+ "Pass it as an argument, and --yes to accept defaults for the rest:",
306
+ " cortex new my-app --yes",
307
+ ],
308
+ });
309
+ }
310
+
311
+ function failTaken(name: string): never {
312
+ report.fail({
313
+ what: `./${name} already exists and is not an empty directory.`,
314
+ blocks: [
315
+ "cortex new never merges into an existing project. Pick another name,\nor remove it first:",
316
+ ` rm -rf ${name}`,
317
+ ],
318
+ });
319
+ }
320
+
321
+ /**
322
+ * The name is the directory *and* the generated `package.json` name, so an
323
+ * illegal one produces a project npm refuses and a `cd` that does not parse.
324
+ */
325
+ function failInvalidName(name: string): never {
326
+ report.fail({
327
+ what: `"${name}" is not a usable project name`,
328
+ blocks: [
329
+ "It becomes the directory and the generated package.json `name`, so it has\nto be one npm accepts: lowercase, no spaces, not a name npm reserves. Try:",
330
+ ` cortex new ${slugify(name)}`,
331
+ ],
332
+ });
333
+ }