@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.
- package/README.md +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- package/tsconfig.json +46 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an OpenAPI document: following $ref, deciding a schema's type, and turning
|
|
3
|
+
* a schema into the flat property list the generator emits. Nothing here knows what
|
|
4
|
+
* the properties are for.
|
|
5
|
+
*/
|
|
6
|
+
import type { Obj, Prop } from "./types";
|
|
7
|
+
|
|
8
|
+
const MAX_DEPTH = 8;
|
|
9
|
+
|
|
10
|
+
export function isObj(v: unknown): v is Obj {
|
|
11
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function obj(v: unknown) {
|
|
15
|
+
return isObj(v) ? v : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function strings(v: unknown) {
|
|
19
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function dedupe(items: Prop[]) {
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
return items.filter((x) => (seen.has(x.name) ? false : (seen.add(x.name), true)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolvePointer(doc: unknown, ref: string) {
|
|
28
|
+
if (!ref.startsWith("#/")) return null;
|
|
29
|
+
let cur: unknown = doc;
|
|
30
|
+
for (const part of ref.slice(2).split("/")) {
|
|
31
|
+
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
32
|
+
if (!isObj(cur) || !(key in cur)) return null;
|
|
33
|
+
cur = cur[key];
|
|
34
|
+
}
|
|
35
|
+
return cur;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function deref(doc: unknown, v: unknown) {
|
|
39
|
+
if (!isObj(v)) return {};
|
|
40
|
+
let x = v;
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
while (typeof x.$ref === "string") {
|
|
43
|
+
if (seen.has(x.$ref)) return {};
|
|
44
|
+
seen.add(x.$ref);
|
|
45
|
+
x = obj(resolvePointer(doc, x.$ref));
|
|
46
|
+
}
|
|
47
|
+
return x;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function schemaType(s: Obj) {
|
|
51
|
+
const t = Array.isArray(s.type)
|
|
52
|
+
? (s.type as unknown[]).find((x) => typeof x === "string" && x !== "null")
|
|
53
|
+
: s.type;
|
|
54
|
+
if (typeof t === "string") return t;
|
|
55
|
+
if (isObj(s.properties)) return "object";
|
|
56
|
+
if (isObj(s.items)) return "array";
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isJsonNodeRef(v: unknown) {
|
|
61
|
+
return (
|
|
62
|
+
isObj(v) &&
|
|
63
|
+
typeof v.$ref === "string" &&
|
|
64
|
+
/\/System\.Text\.Json\.Nodes\.JsonNode$/.test(v.$ref)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isJsonNodeSchema(s: Obj) {
|
|
69
|
+
const p = obj(s.properties);
|
|
70
|
+
return (
|
|
71
|
+
schemaType(s) === "object" &&
|
|
72
|
+
"options" in p &&
|
|
73
|
+
"parent" in p &&
|
|
74
|
+
"root" in p &&
|
|
75
|
+
isJsonNodeRef(p.parent) &&
|
|
76
|
+
isJsonNodeRef(p.root)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function scalarType(s: Obj) {
|
|
81
|
+
if (Array.isArray(s.enum) && s.enum.every((v) => typeof v === "string")) {
|
|
82
|
+
return s.enum.map((v) => `'${String(v).replace(/'/g, "\\'")}'`).join(" | ");
|
|
83
|
+
}
|
|
84
|
+
const t = schemaType(s);
|
|
85
|
+
const f = typeof s.format === "string" ? s.format : "";
|
|
86
|
+
if (f === "uuid") return "uuid";
|
|
87
|
+
if (f === "date") return "date";
|
|
88
|
+
if (f === "date-time") return "datetime";
|
|
89
|
+
if (t === "integer" || t === "number") return "number";
|
|
90
|
+
if (t === "boolean") return "boolean";
|
|
91
|
+
if (t === "string") return "string";
|
|
92
|
+
if (t === "object") return "object";
|
|
93
|
+
return "unknown";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function toProps(doc: unknown, schemaIn: unknown, depth = 0) {
|
|
97
|
+
if (depth > MAX_DEPTH) return [];
|
|
98
|
+
const s = deref(doc, schemaIn);
|
|
99
|
+
if (isJsonNodeRef(schemaIn) || isJsonNodeSchema(s)) return [];
|
|
100
|
+
const req = new Set(strings(s.required));
|
|
101
|
+
return dedupe(
|
|
102
|
+
Object.entries(obj(s.properties)).map(([name, child]) =>
|
|
103
|
+
toProp(doc, name, child, req.has(name), depth + 1),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function toProp(
|
|
109
|
+
doc: unknown,
|
|
110
|
+
name: string,
|
|
111
|
+
schemaIn: unknown,
|
|
112
|
+
required: boolean,
|
|
113
|
+
depth = 0,
|
|
114
|
+
// eslint-disable-next-line m6d/no-explicit-return-type -- mutually recursive with toProps, so the return type cannot be inferred
|
|
115
|
+
): Prop {
|
|
116
|
+
if (isJsonNodeRef(schemaIn)) return { name, required, type: "object" };
|
|
117
|
+
|
|
118
|
+
const s = deref(doc, schemaIn);
|
|
119
|
+
const req = required && s.nullable !== true;
|
|
120
|
+
|
|
121
|
+
if (isJsonNodeSchema(s)) return { name, required: req, type: "object" };
|
|
122
|
+
|
|
123
|
+
const t = schemaType(s);
|
|
124
|
+
if (t === "array") {
|
|
125
|
+
const item = deref(doc, s.items);
|
|
126
|
+
if (schemaType(item) === "object") {
|
|
127
|
+
const properties = toProps(doc, item, depth + 1);
|
|
128
|
+
return {
|
|
129
|
+
name,
|
|
130
|
+
required: req,
|
|
131
|
+
type: "object",
|
|
132
|
+
isArray: true,
|
|
133
|
+
...(properties.length ? { properties } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { name, required: req, type: scalarType(item), isArray: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (t === "object") {
|
|
140
|
+
const properties = toProps(doc, s, depth + 1);
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
required: req,
|
|
144
|
+
type: "object",
|
|
145
|
+
...(properties.length ? { properties } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { name, required: req, type: scalarType(s) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function pickContent(contentIn: unknown) {
|
|
153
|
+
const content = obj(contentIn);
|
|
154
|
+
const mimes = Object.keys(content);
|
|
155
|
+
if (!mimes.length) return null;
|
|
156
|
+
const mime =
|
|
157
|
+
mimes.find((x) => x.toLowerCase() === "application/json") ??
|
|
158
|
+
mimes.find((x) => x.toLowerCase().includes("json")) ??
|
|
159
|
+
mimes[0];
|
|
160
|
+
return { schema: obj(content[mime!]).schema, mimes };
|
|
161
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding what an endpoint answers with: which statuses it uses, and what shape the
|
|
3
|
+
* success body takes. The shape is what drives code generation, so a paged envelope
|
|
4
|
+
* is reported as the rows it wraps rather than as its own page fields.
|
|
5
|
+
*/
|
|
6
|
+
import type { Obj, Prop, ResponseKind } from "./types";
|
|
7
|
+
import { deref, obj, pickContent, schemaType, toProp, toProps } from "./openapi-schema";
|
|
8
|
+
|
|
9
|
+
type ResponseBody = { responseKind: ResponseKind; response: Prop[] };
|
|
10
|
+
|
|
11
|
+
const NO_BODY: ResponseBody = { responseKind: "none", response: [] };
|
|
12
|
+
|
|
13
|
+
const FILE_MIME = /(application\/octet-stream|application\/pdf|application\/vnd\.|text\/csv)/i;
|
|
14
|
+
|
|
15
|
+
/** An object contributes its own fields; anything else stands as a single `item`. */
|
|
16
|
+
function itemProps(doc: unknown, item: Obj) {
|
|
17
|
+
return schemaType(item) === "object" ? toProps(doc, item) : [toProp(doc, "item", item, true)];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isFileResponse(mimes: string[], schema: Obj) {
|
|
21
|
+
if (mimes.some((mime) => FILE_MIME.test(mime))) return true;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
schemaType(schema) === "string" &&
|
|
25
|
+
typeof schema.format === "string" &&
|
|
26
|
+
["binary", "base64"].includes(schema.format)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A paged envelope is described by the items it wraps, not by its own page fields. */
|
|
31
|
+
function parseObjectBody(doc: unknown, schema: Obj) {
|
|
32
|
+
const props = obj(schema.properties);
|
|
33
|
+
const hasPaging = ["totalCount", "pageNumber", "pageSize", "totalPages"].some(
|
|
34
|
+
(k) => k in props,
|
|
35
|
+
);
|
|
36
|
+
const list = deref(doc, props.items ?? props.data ?? props.results);
|
|
37
|
+
|
|
38
|
+
if (hasPaging && schemaType(list) === "array") {
|
|
39
|
+
return {
|
|
40
|
+
responseKind: "paginated",
|
|
41
|
+
response: itemProps(doc, deref(doc, list.items)),
|
|
42
|
+
} satisfies ResponseBody;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { responseKind: "object", response: toProps(doc, schema) } satisfies ResponseBody;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseResponseBody(doc: unknown, schema: Obj, mimes: string[]) {
|
|
49
|
+
if (isFileResponse(mimes, schema))
|
|
50
|
+
return { responseKind: "file", response: [] } satisfies ResponseBody;
|
|
51
|
+
|
|
52
|
+
const type = schemaType(schema);
|
|
53
|
+
if (type === "array") {
|
|
54
|
+
return {
|
|
55
|
+
responseKind: "array",
|
|
56
|
+
response: itemProps(doc, deref(doc, schema.items)),
|
|
57
|
+
} satisfies ResponseBody;
|
|
58
|
+
}
|
|
59
|
+
if (type === "object") return parseObjectBody(doc, schema);
|
|
60
|
+
if (Object.keys(schema).length) {
|
|
61
|
+
return {
|
|
62
|
+
responseKind: "object",
|
|
63
|
+
response: [toProp(doc, "value", schema, true)],
|
|
64
|
+
} satisfies ResponseBody;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return NO_BODY;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseResponse(doc: unknown, responsesIn: unknown) {
|
|
71
|
+
const responses = obj(responsesIn);
|
|
72
|
+
const statuses = Object.keys(responses)
|
|
73
|
+
.map(Number)
|
|
74
|
+
.filter(Number.isInteger)
|
|
75
|
+
.sort((a, b) => a - b);
|
|
76
|
+
const successStatus = statuses.find((s) => s >= 200 && s < 300) ?? 200;
|
|
77
|
+
const errorStatuses = [...new Set(statuses.filter((s) => s >= 400))];
|
|
78
|
+
|
|
79
|
+
// 204 says there is no body at all, so there is nothing further to read.
|
|
80
|
+
if (successStatus === 204) return { successStatus, errorStatuses, ...NO_BODY };
|
|
81
|
+
|
|
82
|
+
const success = deref(doc, responses[String(successStatus)]);
|
|
83
|
+
const content = pickContent(success.content);
|
|
84
|
+
if (!content) return { successStatus, errorStatuses, ...NO_BODY };
|
|
85
|
+
|
|
86
|
+
const body = parseResponseBody(doc, deref(doc, content.schema), content.mimes);
|
|
87
|
+
return { successStatus, errorStatuses, ...body };
|
|
88
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes shared across endpoint extraction: what an OpenAPI document looks like
|
|
3
|
+
* on the way in, and what one generated endpoint looks like on the way out.
|
|
4
|
+
*/
|
|
5
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
|
6
|
+
export type ResponseKind = "object" | "array" | "paginated" | "file" | "none";
|
|
7
|
+
export type Obj = Record<string, unknown>;
|
|
8
|
+
export type Prop = {
|
|
9
|
+
name: string;
|
|
10
|
+
required: boolean;
|
|
11
|
+
type: string;
|
|
12
|
+
isArray?: boolean;
|
|
13
|
+
properties?: Prop[];
|
|
14
|
+
};
|
|
15
|
+
export type Endpoint = {
|
|
16
|
+
key: string;
|
|
17
|
+
method: HttpMethod;
|
|
18
|
+
path: string;
|
|
19
|
+
params: Prop[];
|
|
20
|
+
body: Prop[];
|
|
21
|
+
response: Prop[];
|
|
22
|
+
responseKind: ResponseKind;
|
|
23
|
+
successStatus: number;
|
|
24
|
+
errorStatuses: number[];
|
|
25
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only file that imports `@clack/prompts`. Clack signals Ctrl-C by
|
|
3
|
+
* *returning* a cancel symbol rather than throwing, so an unwrapped call reads
|
|
4
|
+
* an abandoned prompt as an answer and scaffolds the project the user just
|
|
5
|
+
* tried to escape. Every prompt goes through `orExit`, and the rest of the CLI
|
|
6
|
+
* reaches the library through this module or not at all.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
cancel,
|
|
11
|
+
intro,
|
|
12
|
+
isCancel,
|
|
13
|
+
log,
|
|
14
|
+
multiselect,
|
|
15
|
+
outro,
|
|
16
|
+
password,
|
|
17
|
+
select,
|
|
18
|
+
spinner,
|
|
19
|
+
text,
|
|
20
|
+
} from "@clack/prompts";
|
|
21
|
+
|
|
22
|
+
import { FEATURES, type Feature } from "@/scaffold/features";
|
|
23
|
+
import type { Database } from "@/scaffold/files";
|
|
24
|
+
|
|
25
|
+
export { intro, log, outro };
|
|
26
|
+
|
|
27
|
+
async function orExit<T>(answer: Promise<T | symbol>) {
|
|
28
|
+
const value = await answer;
|
|
29
|
+
|
|
30
|
+
if (isCancel(value)) {
|
|
31
|
+
cancel("Cancelled — nothing was written.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prompted only when the positional is omitted; it is the directory name too.
|
|
40
|
+
* What makes a name usable is the `new` command's to say — this asks, re-asks
|
|
41
|
+
* while `check` returns a complaint, and knows nothing else about names.
|
|
42
|
+
*/
|
|
43
|
+
export function askName(check: (name: string) => string | undefined) {
|
|
44
|
+
return orExit(
|
|
45
|
+
text({
|
|
46
|
+
message: "Project name",
|
|
47
|
+
placeholder: "my-app",
|
|
48
|
+
validate: function (value) {
|
|
49
|
+
const candidate = (value ?? "").trim();
|
|
50
|
+
if (candidate.length === 0) return "A project name is required.";
|
|
51
|
+
return check(candidate);
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one structural pick: it changes a `cortex.config.ts` line and decides
|
|
59
|
+
* whether the generated compose file has a database service at all.
|
|
60
|
+
*/
|
|
61
|
+
export function askDatabase() {
|
|
62
|
+
return orExit(
|
|
63
|
+
select<Database>({
|
|
64
|
+
message: "Database",
|
|
65
|
+
initialValue: "postgres",
|
|
66
|
+
options: [
|
|
67
|
+
{ value: "postgres", hint: "docker-compose.yml runs one for you" },
|
|
68
|
+
{ value: "mssql", hint: "normally an existing external instance" },
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One multi-select for the whole catalogue — minus anything a flag settled. */
|
|
75
|
+
export function askFeatures(undecided: Feature[]) {
|
|
76
|
+
return orExit(
|
|
77
|
+
multiselect<Feature>({
|
|
78
|
+
message: "Which optional features?",
|
|
79
|
+
required: false,
|
|
80
|
+
options: undecided.map((feature) => ({ value: feature, hint: FEATURES[feature].hint })),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A required value a command was not given: asked by name, re-asked while empty. */
|
|
86
|
+
export function askValue(name: string, hint?: string) {
|
|
87
|
+
return orExit(
|
|
88
|
+
text({
|
|
89
|
+
message: name,
|
|
90
|
+
placeholder: hint,
|
|
91
|
+
validate: (value) =>
|
|
92
|
+
(value ?? "").trim().length === 0 ? `${name} is required.` : undefined,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A credential: typed masked, never echoed. */
|
|
98
|
+
export function askSecret(name: string) {
|
|
99
|
+
return orExit(
|
|
100
|
+
password({
|
|
101
|
+
message: name,
|
|
102
|
+
validate: (value) => ((value ?? "").length === 0 ? `${name} is required.` : undefined),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type Step = { title: string; run: () => string | undefined | Promise<string | undefined> };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Write, install, git init. The spinner only runs on a TTY: it redraws in place
|
|
111
|
+
* with cursor escapes, which is noise in a piped log, and the non-interactive
|
|
112
|
+
* transcript is plain `◇` lines. A step that returns nothing — `git init` is
|
|
113
|
+
* silent by design — leaves none.
|
|
114
|
+
*/
|
|
115
|
+
export async function runSteps(steps: Step[]) {
|
|
116
|
+
for (const step of steps) {
|
|
117
|
+
// `withGuide: false` because `log.step` writes the `│` between steps; the
|
|
118
|
+
// spinner adding its own leaves a doubled bar behind every silent step.
|
|
119
|
+
const spin = process.stdout.isTTY === true ? spinner({ withGuide: false }) : undefined;
|
|
120
|
+
spin?.start(step.title);
|
|
121
|
+
const done = await step.run();
|
|
122
|
+
spin?.clear();
|
|
123
|
+
if (done !== undefined) log.step(done);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/ui/report.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The output grammar, shared by every command that runs against a project.
|
|
3
|
+
* The report layout is deliberately frozen; this file is the only
|
|
4
|
+
* place that knows the box characters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inspect } from "node:util";
|
|
8
|
+
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
|
|
11
|
+
const BAR = pc.gray("│");
|
|
12
|
+
|
|
13
|
+
function write(stream: NodeJS.WriteStream, lines: string[]) {
|
|
14
|
+
stream.write(`${lines.join("\n")}\n`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Every block inside an error sits three spaces in, commands two further. */
|
|
18
|
+
function indent(block: string) {
|
|
19
|
+
return block
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map((line) => ` ${line}`)
|
|
22
|
+
.join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function describe(error: unknown) {
|
|
26
|
+
if (!(error instanceof Error)) return String(error);
|
|
27
|
+
return `${error.name}: ${error.message}`.split("\n")[0] ?? error.name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// An error hugs whatever the run last printed, so it needs to know whether
|
|
31
|
+
// anything opened the box above it.
|
|
32
|
+
let open = false;
|
|
33
|
+
|
|
34
|
+
/** `┌ cortex graph seed` — opens a run. */
|
|
35
|
+
/** `1 node` / `3 nodes` — every count the box prints goes through this. */
|
|
36
|
+
export function plural(count: number, noun: string) {
|
|
37
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function header(title: string) {
|
|
41
|
+
open = true;
|
|
42
|
+
write(process.stdout, ["", `${pc.gray("┌")} ${title}`, BAR]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A resolved input. These double as progress: whatever was printed last says how
|
|
47
|
+
* far the run got, which is most of the diagnosis when the next step fails.
|
|
48
|
+
*/
|
|
49
|
+
export function input(label: string, value: string) {
|
|
50
|
+
write(process.stdout, [`${pc.green("◇")} ${label.padEnd(9)} ${value}`]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function blank() {
|
|
54
|
+
write(process.stdout, [BAR]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `│ updated leaves/listLeaves.endpoint.ts` — a row in the body. An empty
|
|
59
|
+
* label indents to the same gutter, for the second line a finding gets. `width`
|
|
60
|
+
* is the label column: findings fit in 10, domain names need 14.
|
|
61
|
+
*/
|
|
62
|
+
export function row(label: string, text: string, width = 10) {
|
|
63
|
+
write(process.stdout, [`${BAR} ${label.padEnd(width - 2)} ${text}`]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `└ 87 nodes · 124 edges (6.2s)` — closes a run. */
|
|
67
|
+
export function footer(text: string) {
|
|
68
|
+
write(process.stdout, [`${pc.gray("└")} ${text}`, ""]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A paragraph below the box, for the one thing the reader has to act on. */
|
|
72
|
+
export function note(text: string) {
|
|
73
|
+
write(process.stdout, [text, ""]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A note beside machine-readable output: stderr, so `-o json | jq` stays valid. */
|
|
77
|
+
export function aside(text: string) {
|
|
78
|
+
write(process.stderr, [text, ""]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One error grammar: what happened → why → a runnable command. One line of the
|
|
83
|
+
* underlying error is kept — it is what gets pasted into a search — and the rest
|
|
84
|
+
* waits behind `--verbose`.
|
|
85
|
+
*/
|
|
86
|
+
export function fail(options: {
|
|
87
|
+
what: string;
|
|
88
|
+
blocks?: string[];
|
|
89
|
+
error?: unknown;
|
|
90
|
+
verbose?: boolean;
|
|
91
|
+
}): never {
|
|
92
|
+
const { what, blocks = [], error, verbose = false } = options;
|
|
93
|
+
const parts = [`${pc.red("■")} ${what}`];
|
|
94
|
+
|
|
95
|
+
if (error !== undefined) parts.push(indent(describe(error)));
|
|
96
|
+
parts.push(...blocks.map(indent));
|
|
97
|
+
if (error !== undefined) {
|
|
98
|
+
// `inspect` rather than `error.stack`: a Bun `fetch` failure carries no
|
|
99
|
+
// stack at all, and its `code` is the useful part.
|
|
100
|
+
parts.push(
|
|
101
|
+
indent(verbose ? inspect(error) : "Run with --verbose for the full stack trace."),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
write(process.stderr, open ? [parts.join("\n\n"), ""] : ["", parts.join("\n\n"), ""]);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Self-contained rather than extending @m6d/tsconfig/bun.json: this file ships
|
|
3
|
+
// in the tarball, where a private workspace package does not resolve.
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
// Environment setup & latest features
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
// Named explicitly: auto-inclusion of @types/* is an editor-dependent
|
|
8
|
+
// heuristic, and `Bun`/`Response` come from here. See @cortex/contracts'
|
|
9
|
+
// tsconfig for the same fix.
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
"target": "ESNext",
|
|
12
|
+
"module": "Preserve",
|
|
13
|
+
"moduleDetection": "force",
|
|
14
|
+
|
|
15
|
+
// Bundler mode
|
|
16
|
+
"moduleResolution": "bundler",
|
|
17
|
+
"verbatimModuleSyntax": true,
|
|
18
|
+
"resolveJsonModule": true,
|
|
19
|
+
"noEmit": true,
|
|
20
|
+
"paths": {
|
|
21
|
+
// The graph contract is @cortex/contracts, a private workspace package
|
|
22
|
+
// that never publishes, so the CLI carries its own copy: `prepack`
|
|
23
|
+
// vendors it into src/contracts, where the first candidate finds it in
|
|
24
|
+
// the published tarball. Inside the repo that copy doesn't exist and
|
|
25
|
+
// resolution falls through to the workspace source.
|
|
26
|
+
"@cortex/contracts/*": ["./src/contracts/*", "../../internal/contracts/src/*"],
|
|
27
|
+
// Internal-only, and the reason this file ships in package.json `files`:
|
|
28
|
+
// the CLI runs as raw TypeScript from inside node_modules, so Bun has to
|
|
29
|
+
// read this config to resolve `@/`.
|
|
30
|
+
"@/*": ["./src/*"]
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Best practices
|
|
34
|
+
"strict": true,
|
|
35
|
+
"skipLibCheck": true,
|
|
36
|
+
"noFallthroughCasesInSwitch": true,
|
|
37
|
+
"noUncheckedIndexedAccess": true,
|
|
38
|
+
"noImplicitOverride": true,
|
|
39
|
+
|
|
40
|
+
// Some stricter flags (disabled by default)
|
|
41
|
+
"noUnusedLocals": false,
|
|
42
|
+
"noUnusedParameters": false,
|
|
43
|
+
"noPropertyAccessFromIndexSignature": false
|
|
44
|
+
},
|
|
45
|
+
"include": ["src/**/*.ts"]
|
|
46
|
+
}
|