@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,135 @@
1
+ import { InvalidArgumentError, Option } from "@commander-js/extra-typings";
2
+
3
+ import type { JsonSchema, OpenApiDocument } from "./client";
4
+
5
+ export type Flag = {
6
+ name: string;
7
+ required: boolean;
8
+ kind: "string" | "number" | "boolean" | "enum" | "array" | "json";
9
+ choices?: string[];
10
+ description: string;
11
+ /** A secret: never an argv flag (argv is world-readable), prompted masked or read from stdin. */
12
+ sensitive: boolean;
13
+ };
14
+
15
+ /** Follows a local `$ref` into `components/schemas`; anything else is returned as is. */
16
+ function resolve(schema: JsonSchema, document: OpenApiDocument) {
17
+ const ref = schema.$ref?.match(/^#\/components\/schemas\/(.+)$/)?.[1];
18
+ return ref ? (document.components?.schemas?.[ref] ?? schema) : schema;
19
+ }
20
+
21
+ function typeOf(schema: JsonSchema) {
22
+ const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type;
23
+ if (type) return type;
24
+ const alternative = schema.anyOf?.find((s) => s.type !== "null");
25
+ return alternative && !Array.isArray(alternative.type) ? alternative.type : undefined;
26
+ }
27
+
28
+ /**
29
+ * A secret is `format: password`, on the property itself or, for a nullable
30
+ * or optional one, on the non-null alternative zod puts inside `anyOf`.
31
+ */
32
+ function isSensitive(property: JsonSchema, document: OpenApiDocument): boolean {
33
+ if (property.format === "password") return true;
34
+ return (property.anyOf ?? []).some((alternative) =>
35
+ isSensitive(resolve(alternative, document), document),
36
+ );
37
+ }
38
+
39
+ /** The flag kind a property's schema maps to; scalars and scalar arrays get typed flags, the rest is JSON. */
40
+ function kindOf(property: JsonSchema, document: OpenApiDocument) {
41
+ if (property.enum) return "enum" as const;
42
+ const type = typeOf(property);
43
+ if (type === "string") return "string" as const;
44
+ if (type === "number" || type === "integer") return "number" as const;
45
+ if (type === "boolean") return "boolean" as const;
46
+ if (type === "array") {
47
+ const item = property.items ? resolve(property.items, document) : undefined;
48
+ const scalar = item && ["string", "number", "integer"].includes(typeOf(item) ?? "");
49
+ return scalar ? ("array" as const) : ("json" as const);
50
+ }
51
+ return "json" as const;
52
+ }
53
+
54
+ /**
55
+ * One flag per top-level property of an object input. Scalars, enums, and
56
+ * arrays of scalars become typed flags; anything nested is taken as JSON,
57
+ * with `--json <file|->` (see the command builder) as the way to pass a
58
+ * whole body. A property named `id` is what `[id]` positionals map to.
59
+ */
60
+ export function flagsFor(input: JsonSchema, document: OpenApiDocument) {
61
+ const schema = resolve(input, document);
62
+ if (typeOf(schema) !== "object" || !schema.properties) return [];
63
+ const required = new Set(schema.required ?? []);
64
+ return Object.entries(schema.properties).map(([name, raw]) => {
65
+ const property = resolve(raw, document);
66
+ return {
67
+ name,
68
+ required: required.has(name),
69
+ kind: kindOf(property, document),
70
+ choices: property.enum?.map(String),
71
+ description: property.description ?? "",
72
+ sensitive: isSensitive(property, document),
73
+ } satisfies Flag;
74
+ });
75
+ }
76
+
77
+ /**
78
+ * A prompted answer, parsed and checked exactly as the flag's option would
79
+ * be; `undefined` when it does not fit, so the caller can ask again.
80
+ */
81
+ export function parseAnswer(flag: Flag, answer: string) {
82
+ // A string is taken verbatim: a secret's whitespace is part of it. Every
83
+ // other kind is lexical and trimmed before parsing.
84
+ const text = flag.kind === "string" ? answer : answer.trim();
85
+ if (text === "") return undefined;
86
+ return ANSWER_PARSERS[flag.kind](text, flag);
87
+ }
88
+
89
+ const finite = (text: string) => (Number.isFinite(Number(text)) ? Number(text) : undefined);
90
+ const json = (text: string) => {
91
+ try {
92
+ return JSON.parse(text) as unknown;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ };
97
+
98
+ /** One parser per flag kind; booleans and enums must match a choice, arrays split on commas. */
99
+ const ANSWER_PARSERS = {
100
+ boolean: (text) => ({ true: true, false: false })[text],
101
+ enum: (text, flag) => (flag.choices?.includes(text) ? text : undefined),
102
+ number: finite,
103
+ array: (text) => text.split(",").map((item) => item.trim()),
104
+ json,
105
+ string: (text) => text,
106
+ } satisfies Record<Flag["kind"], (text: string, flag: Flag) => unknown>;
107
+
108
+ export function toOption(flag: Flag) {
109
+ const long = `--${flag.name}`;
110
+ const label = flag.description || `${flag.kind}${flag.required ? "" : " (optional)"}`;
111
+ switch (flag.kind) {
112
+ case "boolean":
113
+ return new Option(`${long} <true|false>`, label)
114
+ .choices(["true", "false"])
115
+ .argParser((value: string) => value === "true");
116
+ case "enum":
117
+ return new Option(`${long} <value>`, label).choices(flag.choices ?? []);
118
+ case "number":
119
+ return new Option(`${long} <number>`, label).argParser(Number);
120
+ case "array":
121
+ return new Option(`${long} <value...>`, `${label} (repeatable)`);
122
+ case "json":
123
+ return new Option(`${long} <json>`, `${label} (JSON)`).argParser((value: string) => {
124
+ try {
125
+ return JSON.parse(value) as unknown;
126
+ } catch (error) {
127
+ throw new InvalidArgumentError(
128
+ error instanceof Error ? error.message : String(error),
129
+ );
130
+ }
131
+ });
132
+ default:
133
+ return new Option(`${long} <value>`, label);
134
+ }
135
+ }
@@ -0,0 +1,77 @@
1
+ import * as report from "@/ui/report";
2
+
3
+ type Listing = { items: Array<Record<string, unknown>>; count: number; filteredCount: number };
4
+
5
+ function isListing(value: unknown): value is Listing {
6
+ return (
7
+ typeof value === "object" &&
8
+ value !== null &&
9
+ "items" in value &&
10
+ Array.isArray(value.items)
11
+ );
12
+ }
13
+
14
+ /**
15
+ * One table cell. Whatever the value's type, the rendered text is whatever a
16
+ * resource writer stored, so control characters (escape sequences, newlines)
17
+ * are flattened to spaces before they reach the terminal. JSON.stringify
18
+ * escapes only the C0 range, so the sweep runs on the final text; `-o json`
19
+ * hands the raw value to the pipe instead.
20
+ */
21
+ function cell(value: unknown) {
22
+ if (value === null || value === undefined) return "";
23
+ const text =
24
+ typeof value === "string"
25
+ ? value
26
+ : typeof value === "number" || typeof value === "boolean" || typeof value === "bigint"
27
+ ? String(value)
28
+ : (JSON.stringify(value) ?? "");
29
+ return text.replace(/\p{Cc}/gu, " ");
30
+ }
31
+
32
+ /**
33
+ * `-o json` prints the raw result for pipes. Otherwise a listing is a table
34
+ * of its first-level scalar columns and an object is key/value rows; the
35
+ * report grammar every other command uses.
36
+ */
37
+ export function print(result: unknown, format: "json" | "table") {
38
+ if (format === "json") {
39
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
40
+ return;
41
+ }
42
+ if (isListing(result)) {
43
+ const columns = Object.keys(result.items[0] ?? {}).filter(
44
+ (key) => typeof result.items[0]?.[key] !== "object" || result.items[0]?.[key] === null,
45
+ );
46
+ const widths = columns.map((column) =>
47
+ Math.min(
48
+ 40,
49
+ Math.max(column.length, ...result.items.map((row) => cell(row[column]).length)),
50
+ ),
51
+ );
52
+ report.row("", columns.map((column, i) => column.padEnd(widths[i] ?? 0)).join(" "), 4);
53
+ for (const item of result.items) {
54
+ report.row(
55
+ "",
56
+ columns
57
+ .map((column, i) =>
58
+ cell(item[column])
59
+ .slice(0, 40)
60
+ .padEnd(widths[i] ?? 0),
61
+ )
62
+ .join(" "),
63
+ 4,
64
+ );
65
+ }
66
+ report.footer(`${result.items.length} of ${result.filteredCount} (${result.count} total)`);
67
+ return;
68
+ }
69
+ if (typeof result === "object" && result !== null) {
70
+ const entries = Object.entries(result as Record<string, unknown>);
71
+ const width = Math.max(10, ...entries.map(([key]) => key.length + 2));
72
+ for (const [key, value] of entries) report.row(key, cell(value), width);
73
+ report.footer("done");
74
+ return;
75
+ }
76
+ report.footer(cell(result));
77
+ }
@@ -0,0 +1,105 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ type Profile = { url: string; key: string };
6
+ type Store = { current: string; profiles: Record<string, Profile> };
7
+
8
+ /** `~/.cortex/cc.json`; `CORTEX_HOME` relocates it (tests, CI). */
9
+ export function profilesPath() {
10
+ return join(process.env["CORTEX_HOME"] ?? join(homedir(), ".cortex"), "cc.json");
11
+ }
12
+
13
+ /** Thrown when the profile file exists but is not one this CLI wrote. */
14
+ export class ProfileFileError extends Error {
15
+ public constructor(path: string, reason: string) {
16
+ super(`${path} is not a valid profile file: ${reason}`);
17
+ this.name = "ProfileFileError";
18
+ }
19
+ }
20
+
21
+ function isProfile(value: unknown): value is Profile {
22
+ return (
23
+ typeof value === "object" &&
24
+ value !== null &&
25
+ typeof (value as Record<string, unknown>)["url"] === "string" &&
26
+ typeof (value as Record<string, unknown>)["key"] === "string"
27
+ );
28
+ }
29
+
30
+ function isStore(value: unknown): value is Store {
31
+ if (typeof value !== "object" || value === null) return false;
32
+ const { current, profiles } = value as Record<string, unknown>;
33
+ return (
34
+ typeof current === "string" &&
35
+ typeof profiles === "object" &&
36
+ profiles !== null &&
37
+ !Array.isArray(profiles) &&
38
+ Object.values(profiles).every(isProfile)
39
+ );
40
+ }
41
+
42
+ /** A profile by name, own properties only: a name like `constructor` is not a profile. */
43
+ function lookup(store: Store, name: string) {
44
+ return Object.hasOwn(store.profiles, name) ? store.profiles[name] : undefined;
45
+ }
46
+
47
+ function readStore() {
48
+ const path = profilesPath();
49
+ if (!existsSync(path)) return { current: "default", profiles: {} } satisfies Store;
50
+ let parsed: unknown;
51
+ try {
52
+ parsed = JSON.parse(readFileSync(path, "utf8"));
53
+ } catch (error) {
54
+ throw new ProfileFileError(path, error instanceof Error ? error.message : String(error));
55
+ }
56
+ if (!isStore(parsed)) throw new ProfileFileError(path, "unexpected shape");
57
+ return parsed;
58
+ }
59
+
60
+ /** The key is a credential, so the file and its directory are owner-only. */
61
+ function writeStore(store: Store) {
62
+ const path = profilesPath();
63
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
64
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
65
+ chmodSync(path, 0o600);
66
+ }
67
+
68
+ // Names are user input, so the store is rebuilt from entries rather than
69
+ // assigned into: `__proto__` and friends stay ordinary keys.
70
+ export function saveProfile(name: string, profile: Profile) {
71
+ const store = readStore();
72
+ writeStore({
73
+ current: name,
74
+ profiles: Object.fromEntries([
75
+ ...Object.entries(store.profiles).filter(([key]) => key !== name),
76
+ [name, profile],
77
+ ]),
78
+ });
79
+ }
80
+
81
+ export function removeProfile(name: string) {
82
+ const store = readStore();
83
+ const existed = lookup(store, name) !== undefined;
84
+ const remaining = Object.entries(store.profiles).filter(([key]) => key !== name);
85
+ writeStore({
86
+ current: store.current === name ? (remaining[0]?.[0] ?? "default") : store.current,
87
+ profiles: Object.fromEntries(remaining),
88
+ });
89
+ return existed;
90
+ }
91
+
92
+ /**
93
+ * The profile a `cc` command runs against: `CORTEX_CC_URL` + `CORTEX_CC_KEY`
94
+ * when both are set (CI, containers), else the named or current entry from
95
+ * the file. `undefined` means nobody has logged in.
96
+ */
97
+ export function resolveProfile(name?: string) {
98
+ const url = process.env["CORTEX_CC_URL"];
99
+ const key = process.env["CORTEX_CC_KEY"];
100
+ if (url && key) return { name: "env", url: url.replace(/\/+$/, ""), key };
101
+ const store = readStore();
102
+ const chosen = name ?? store.current;
103
+ const profile = lookup(store, chosen);
104
+ return profile ? { name: chosen, ...profile } : undefined;
105
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { Command, Help } from "@commander-js/extra-typings";
2
+
3
+ import pkg from "../package.json";
4
+ import { registerCc } from "@/commands/cc";
5
+ import { registerGraph } from "@/commands/graph";
6
+ import { registerNew } from "@/commands/new";
7
+ import { registerSwagger } from "@/commands/swagger";
8
+ import * as report from "@/ui/report";
9
+
10
+ const program = new Command()
11
+ .name("cortex")
12
+ .description("Scaffold and operate Cortex servers.")
13
+ .option("-v, --verbose", "Print full errors and the commands being run")
14
+ .version(pkg.version, "-V, --version", "Output the version number")
15
+ .helpOption("-h, --help", "Display help for command")
16
+ // The implicit `help [command]` would print as a third, ungrouped section and
17
+ // break the two-group help screen. `cortex <cmd> --help` covers it.
18
+ .helpCommand(false)
19
+ // Commander appends ` [options]` to any command that has some. Left in, the
20
+ // first command to grow a flag widens the whole list and the two-group screen
21
+ // stops matching. `cortex <command> --help` lists them.
22
+ .configureHelp({
23
+ subcommandTerm: function (cmd) {
24
+ return new Help().subcommandTerm(cmd).replace(" [options]", "");
25
+ },
26
+ })
27
+ .addHelpText("after", "\nRun `cortex <resource> --help` to see what you can do with one.");
28
+
29
+ // Each command owns its help group, so the two headings survive these files being
30
+ // rewritten. Registration order fixes the order the groups print in.
31
+ registerNew(program);
32
+ registerSwagger(program);
33
+ registerGraph(program);
34
+ // The Control Center's commands come from its API, so this one may fetch
35
+ // before parsing; it looks at argv to decide whether it needs to.
36
+ await registerCc(program, process.argv.slice(2));
37
+
38
+ // Commander's own errors print and exit on their own, and every failure the
39
+ // commands predict goes through `report.fail`. Anything still reaching here is a
40
+ // bug — in the CLI, or in the project's server — and it gets the same grammar
41
+ // rather than a raw Bun stack dump.
42
+ await program.parseAsync().catch(function (error: unknown) {
43
+ const verbose = program.opts().verbose === true;
44
+ const rerun = process.argv.slice(2);
45
+ if (!verbose) rerun.push("--verbose");
46
+
47
+ report.fail({
48
+ what: "cortex hit an unexpected error",
49
+ error,
50
+ blocks: [
51
+ "That is a bug — the CLI should have explained this. Report it at\nhttps://github.com/boring91/cortex/issues with the output of:",
52
+ ` cortex ${rerun.join(" ")}`,
53
+ ],
54
+ verbose,
55
+ });
56
+ });