bazis 0.99.6 → 0.100.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/cli/build.ts +171 -36
- package/cli/codegen.ts +155 -4
- package/cli/generateProject.ts +8 -2
- package/cli/main.ts +134 -51
- package/cli/naming.ts +3 -0
- package/cli/ui.ts +86 -0
- package/core/agent/index.ts +8 -0
- package/core/app/runApp.ts +4 -1
- package/core/di/extensions/hosted-service.ts +6 -0
- package/core/http/HttpServer.ts +33 -2
- package/core/http/Routing/RouterBuilder.ts +21 -0
- package/core/infra/Infra.ts +8 -2
- package/core/infra/InfraConnector.ts +13 -0
- package/core/infra/InfraLifecycle.ts +27 -0
- package/core/infra/connectorConfig.ts +10 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +23 -2
- package/core/infra/connectors/openaiCompatible.ts +26 -1
- package/core/infra/connectors/opensearch.ts +33 -6
- package/core/infra/connectors/postgres.ts +26 -2
- package/core/infra/connectors/redis.ts +23 -1
- package/core/infra/index.ts +2 -2
- package/core/kernel/Bazis.ts +21 -1
- package/core/kernel/Kernel.ts +54 -0
- package/core/kernel/KernelBuilder.ts +39 -1
- package/core/kernel/LifecycleCoordinator.ts +101 -9
- package/core/kernel/config/ConfigRegistry.ts +5 -0
- package/core/kernel/config/ConfigValidationError.ts +37 -0
- package/core/kernel/config/callerFile.ts +23 -0
- package/core/kernel/config/defineConfig.ts +21 -17
- package/core/kernel/errors/StartupFailuresError.ts +17 -0
- package/core/kernel/errors/index.ts +1 -0
- package/core/kernel/index.ts +12 -0
- package/core/kernel/report/StartupReport.ts +134 -0
- package/core/kernel/report/StartupScreen.ts +459 -0
- package/core/kernel/report/moduleTree.ts +97 -0
- package/core/kernel/report/startupProblems.ts +186 -0
- package/core/orm/OrmLifecycle.ts +29 -7
- package/core/orm/buildOrmProviders.ts +7 -3
- package/core/orm/databaseConnector.ts +17 -1
- package/core/orm/index.ts +11 -0
- package/core/scripts/di-generate.ts +58 -9
- package/library/terminal/index.ts +4 -0
- package/library/terminal/language.ts +60 -0
- package/library/terminal/messages.ts +400 -0
- package/library/terminal/screen.ts +358 -0
- package/library/terminal/style.ts +143 -0
- package/package.json +2 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { colorAt, formatBytes, formatDuration, MODE, PALETTE, Screen, padEnd, type Language, type ScreenOutput, type TreeRow } from "../../../library/terminal";
|
|
6
|
+
import type { ConfigInspection } from "../config/defineConfig";
|
|
7
|
+
import { ConfigValidationError } from "../config/ConfigValidationError";
|
|
8
|
+
import type { Kernel } from "../Kernel";
|
|
9
|
+
import { describeModules, type ModuleItem, type ModuleNode } from "./moduleTree";
|
|
10
|
+
import type { RouteFact, StartupReport } from "./StartupReport";
|
|
11
|
+
import { diagnoseStartupFailure, STARTUP_STAGES, type StartupStage } from "./startupProblems";
|
|
12
|
+
|
|
13
|
+
export interface StartupScreenOptions {
|
|
14
|
+
readonly stdout?: ScreenOutput;
|
|
15
|
+
readonly stderr?: ScreenOutput;
|
|
16
|
+
readonly language?: Language;
|
|
17
|
+
/** The logo and facts header; off when `bazis dev` already printed it. */
|
|
18
|
+
readonly header?: boolean;
|
|
19
|
+
/** The source directory `bazis dev --watch` restarts on. */
|
|
20
|
+
readonly watch?: string;
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the startup screen replaces the log line: `BAZIS_UI=1` turns it on,
|
|
26
|
+
* `BAZIS_UI=0` off; by default it is on for an interactive terminal outside CI.
|
|
27
|
+
*/
|
|
28
|
+
export function startupScreenEnabled(env: NodeJS.ProcessEnv = process.env, stdout: { isTTY?: boolean } = process.stdout): boolean {
|
|
29
|
+
const flag = env.BAZIS_UI?.trim().toLowerCase();
|
|
30
|
+
if (flag === "0" || flag === "false" || flag === "off" || flag === "log") return false;
|
|
31
|
+
if (flag === "1" || flag === "true" || flag === "on") return true;
|
|
32
|
+
return stdout.isTTY === true && !env.CI && env.TERM !== "dumb";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WAIT_DELAY_MS = 400;
|
|
36
|
+
const ROUTE_LIMIT = 30;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The application's startup screen: what was configured, connected, created
|
|
40
|
+
* and served, and a success banner with the links; or the failed stage with
|
|
41
|
+
* one panel per problem. `Bazis.run` uses it on an interactive terminal.
|
|
42
|
+
*/
|
|
43
|
+
export class StartupScreen {
|
|
44
|
+
private readonly out: Screen;
|
|
45
|
+
private readonly err: Screen;
|
|
46
|
+
private readonly header: boolean;
|
|
47
|
+
private readonly watch?: string;
|
|
48
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
49
|
+
|
|
50
|
+
constructor(options: StartupScreenOptions = {}) {
|
|
51
|
+
this.env = options.env ?? process.env;
|
|
52
|
+
this.out = new Screen(options.stdout ?? process.stdout, { language: options.language });
|
|
53
|
+
this.err = new Screen(options.stderr ?? process.stderr, { language: this.out.language });
|
|
54
|
+
this.header = options.header ?? this.env.BAZIS_UI_HEADER !== "0";
|
|
55
|
+
this.watch = options.watch ?? (this.env.BAZIS_UI_WATCH || undefined);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A spinner while the start takes long, naming what it waits for. Returns its stop. */
|
|
59
|
+
waiting(kernel: Kernel): () => void {
|
|
60
|
+
let stop: (() => void) | undefined;
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
stop = this.out.progress(this.out.t.step.connections, () => {
|
|
63
|
+
const pending = kernel.startingServices;
|
|
64
|
+
return pending.length === 0 ? "" : `${this.out.muted(this.out.t.infra.waiting)} ${this.out.strong(pending.join(", "))}`;
|
|
65
|
+
});
|
|
66
|
+
}, WAIT_DELAY_MS);
|
|
67
|
+
timer.unref?.();
|
|
68
|
+
return () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
stop?.();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── success ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
started(kernel: Kernel): void {
|
|
77
|
+
const s = this.out;
|
|
78
|
+
const t = s.t;
|
|
79
|
+
const report = kernel.report;
|
|
80
|
+
if (this.header) this.printHeader(s, kernel.environment.name, kernel.environment.isProduction);
|
|
81
|
+
|
|
82
|
+
const config = configRows(kernel);
|
|
83
|
+
const sections = groupSections(config);
|
|
84
|
+
const devSecrets = config.filter((row) => row.type === "secret" && (row.source === "default" || row.source === "development"));
|
|
85
|
+
s.section(t.section.configuration, s.muted(t.config.environment(kernel.environment.name)));
|
|
86
|
+
s.step("ok", t.step.config, [s.num(sections.size, t.noun("section", sections.size)), ...(devSecrets.length ? [s.num(devSecrets.length, t.config.devDefault(devSecrets.length), PALETTE.yellow)] : [])].join(s.dot), formatDuration(kernel.buildTimings.configMs));
|
|
87
|
+
s.tree([...sections].map(([name, rows]) => ({
|
|
88
|
+
text: `${padEnd(s.strong(name), 10)}${s.text(rows.filter((row) => row.type !== "secret").map((row) => `${shortKey(row.key)} ${String(row.value)}`).join(" · ") || "—")}`,
|
|
89
|
+
children: rows.filter((row) => row.type === "secret").map((row) => ({
|
|
90
|
+
text: devSecrets.includes(row)
|
|
91
|
+
? `${s.fg(PALETTE.yellow, shortKey(row.key))} ${s.muted(t.config.devDefaultNote(row.env[0] ?? ""))}`
|
|
92
|
+
: `${s.text(shortKey(row.key))} ${s.muted(`*** · ${row.source}`)}`,
|
|
93
|
+
})),
|
|
94
|
+
})), this.indent);
|
|
95
|
+
s.line();
|
|
96
|
+
|
|
97
|
+
const connections = report.connections;
|
|
98
|
+
if (connections.length > 0) {
|
|
99
|
+
const infraMs = stageMs(kernel, "infrastructure");
|
|
100
|
+
s.section(t.section.infrastructure);
|
|
101
|
+
s.step("ok", t.step.connections, `${s.num(connections.length, t.infra.up(connections.length), PALETTE.lime)}${connections.length > 1 ? `${s.dot}${s.muted(t.infra.parallel)}` : ""}`, formatDuration(infraMs));
|
|
102
|
+
s.tree(connections.map((connection) => ({
|
|
103
|
+
text: `${s.badge("up")} ${padEnd(s.strong(connection.name), 9)}${padEnd(s.strong(connection.kind ?? "", PALETTE.table), 20)}${[
|
|
104
|
+
...(connection.address ? [s.text(connection.address)] : []),
|
|
105
|
+
...(connection.details ?? []).map((detail) => s.muted(detail)),
|
|
106
|
+
...(connection.ms === undefined ? [] : [s.fg(PALETTE.lime, formatDuration(connection.ms))]),
|
|
107
|
+
].join(s.dot)}`,
|
|
108
|
+
})), this.indent);
|
|
109
|
+
s.line();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const schemas = report.schemas;
|
|
113
|
+
if (schemas.length > 0) {
|
|
114
|
+
const operations = schemas.flatMap((schema) => [...schema.migrated.map((name) => `migration ${name}`), ...schema.applied]);
|
|
115
|
+
s.section(t.section.schema, s.muted([...new Set(schemas.map((schema) => schema.mode))].join(", ")));
|
|
116
|
+
s.step("ok", t.step.schema, operations.length === 0 ? s.muted(t.schema.none) : [s.num(operations.length, t.noun("operation", operations.length)), s.num(schemas.length, t.noun("schema", schemas.length))].join(s.dot), formatDuration(stageMs(kernel, "schema")));
|
|
117
|
+
const shown = operations.slice(0, 12).map((operation) => ({ text: `${s.strong("+", PALETTE.lime)} ${s.text(operation)}` }));
|
|
118
|
+
if (operations.length > 12) shown.push({ text: s.muted(`… ${t.common.plus} ${operations.length - 12}`) });
|
|
119
|
+
s.tree(shown, this.indent);
|
|
120
|
+
for (const warning of schemas.flatMap((schema) => schema.warnings)) report.warn(warning);
|
|
121
|
+
s.line();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const routes = report.routeFacts.filter((route) => !route.builtIn);
|
|
125
|
+
const modules = kernel.rootModule === undefined ? [] : describeModules(kernel.rootModule);
|
|
126
|
+
const moduleCount = countModules(modules);
|
|
127
|
+
if (moduleCount > 0) {
|
|
128
|
+
s.section(t.section.modules, [s.num(moduleCount, t.noun("module", moduleCount)), s.num(routes.length, t.noun("route", routes.length))].join(s.dot));
|
|
129
|
+
s.line();
|
|
130
|
+
this.printModules(modules, routes);
|
|
131
|
+
s.line();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (routes.length > 0) {
|
|
135
|
+
const open = routes.filter((route) => route.open && route.access !== "anonymous");
|
|
136
|
+
s.section(t.section.routes, [s.num(routes.length, t.noun("route", routes.length)), ...(open.length ? [s.num(open.length, t.routes.open, PALETTE.orange)] : [])].join(s.dot));
|
|
137
|
+
s.line();
|
|
138
|
+
this.printRoutes(routes);
|
|
139
|
+
const builtIn = report.routeFacts.filter((route) => route.builtIn);
|
|
140
|
+
if (builtIn.length) s.line(` ${s.faint(t.common.plus)} ${builtIn.map((route) => s.text(`${route.method} ${route.path}`)).join(s.dot)}`);
|
|
141
|
+
s.line();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const warnings = [
|
|
145
|
+
...(routes.some((route) => route.open && route.access !== "anonymous")
|
|
146
|
+
? [t.warnings.openRoutes(routes.filter((route) => route.open && route.access !== "anonymous").length)] : []),
|
|
147
|
+
...devSecrets.map((row) => t.warnings.devSecret(row.key)),
|
|
148
|
+
...report.warnings,
|
|
149
|
+
];
|
|
150
|
+
if (warnings.length > 0) {
|
|
151
|
+
s.section(t.section.warnings, s.strong(String(warnings.length), PALETTE.orange));
|
|
152
|
+
for (const warning of warnings) s.warning(warning);
|
|
153
|
+
s.line();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.printTimings(kernel);
|
|
157
|
+
|
|
158
|
+
const app = appInfo().name ?? "app";
|
|
159
|
+
const total = formatDuration(kernel.startupDurationMs);
|
|
160
|
+
const listener = report.listeners[0];
|
|
161
|
+
s.banner("success", listener === undefined ? t.ready.worker(app, total) : t.ready.running(app, total));
|
|
162
|
+
const rows: string[] = [];
|
|
163
|
+
if (listener !== undefined) {
|
|
164
|
+
rows.push(s.panelRow(t.ready.local, s.link(listener.url)));
|
|
165
|
+
const local = ["127.0.0.1", "localhost", "::1"].includes(listener.hostname);
|
|
166
|
+
rows.push(s.panelRow(t.ready.network, local ? s.muted(t.ready.notExposed(listener.hostname)) : s.link(`http://${networkAddress() ?? listener.hostname}:${listener.port}`)));
|
|
167
|
+
if (listener.health) rows.push(s.panelRow(t.ready.health, s.link(listener.health)));
|
|
168
|
+
if (listener.docs) rows.push(s.panelRow(t.ready.docs, s.link(listener.docs)));
|
|
169
|
+
rows.push("");
|
|
170
|
+
}
|
|
171
|
+
rows.push(s.muted(this.watch === undefined ? t.ready.noWatch : t.ready.watching(this.watch)));
|
|
172
|
+
s.panel(rows);
|
|
173
|
+
s.line();
|
|
174
|
+
s.divider(t.common.logs);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private get indent(): number {
|
|
178
|
+
return this.out.style.color ? 9 : 4;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private printModules(modules: readonly ModuleNode[], routes: readonly RouteFact[]): void {
|
|
182
|
+
const s = this.out;
|
|
183
|
+
const t = s.t;
|
|
184
|
+
const icons: Record<ModuleItem["kind"], [string, string, string]> = {
|
|
185
|
+
controller: ["⇄", PALETTE.route, t.modules.controller],
|
|
186
|
+
gateway: ["⇄", PALETTE.route, "gateway"],
|
|
187
|
+
service: ["◆", PALETTE.service, t.modules.service],
|
|
188
|
+
table: ["▦", PALETTE.table, t.modules.table],
|
|
189
|
+
background: ["↻", PALETTE.job, t.modules.background],
|
|
190
|
+
tool: ["✦", PALETTE.tool, t.modules.tool],
|
|
191
|
+
exports: ["↗", PALETTE.export, t.modules.exports],
|
|
192
|
+
};
|
|
193
|
+
const itemText = (item: ModuleItem): string => {
|
|
194
|
+
const [icon, color, label] = icons[item.kind];
|
|
195
|
+
let text = item.text;
|
|
196
|
+
if (item.kind === "controller" && item.controller !== undefined) {
|
|
197
|
+
const own = routes.filter((route) => route.handler.startsWith(`${item.controller}.`));
|
|
198
|
+
text = [item.text, ...(own.length ? [commonPrefix(own.map((route) => route.path))] : []), `${own.length} ${t.noun("route", own.length)}`].join(" · ");
|
|
199
|
+
}
|
|
200
|
+
return `${s.strong(icon, color)} ${padEnd(s.fg(color, label), 11)}${s.text(text)}`;
|
|
201
|
+
};
|
|
202
|
+
const rows = (nodes: readonly ModuleNode[]): TreeRow[] => nodes.map((node) => {
|
|
203
|
+
const pack = node.pack ? ` ${s.style.color ? s.paint.gradientBg(" PACK ", ["#7b61ff", "#ff2ee6"], PALETTE.white) : "[PACK]"}` : "";
|
|
204
|
+
const items = node.items.map((item) => ({ text: itemText(item) }));
|
|
205
|
+
const empty = items.length === 0 && node.children.length === 0 ? [{ text: s.muted(t.modules.emptyHint) }] : [];
|
|
206
|
+
return { text: `${s.strong(node.name)}${pack}`, children: [...items, ...empty, ...rows(node.children)] };
|
|
207
|
+
});
|
|
208
|
+
const roots = modules.length === 1 ? modules[0]! : undefined;
|
|
209
|
+
if (roots !== undefined) {
|
|
210
|
+
s.line(` ${s.bold(s.style.color ? s.paint.gradient(`◉ ${roots.name}`, PALETTE.brand) : `◉ ${roots.name}`)}`);
|
|
211
|
+
const body = [...roots.items.map((item) => ({ text: itemText(item) })), ...rows(roots.children)];
|
|
212
|
+
s.tree(body.length > 0 ? body : [{ text: s.muted(t.modules.emptyHint) }], 5);
|
|
213
|
+
} else {
|
|
214
|
+
s.tree(rows(modules), 5);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private printRoutes(routes: readonly RouteFact[]): void {
|
|
219
|
+
const s = this.out;
|
|
220
|
+
const t = s.t;
|
|
221
|
+
const shown = routes.slice(0, ROUTE_LIMIT);
|
|
222
|
+
const pathWidth = Math.min(34, Math.max(...shown.map((route) => route.path.length)) + 2);
|
|
223
|
+
const handlerWidth = Math.min(34, Math.max(...shown.map((route) => route.handler.length)) + 2);
|
|
224
|
+
for (const route of shown) {
|
|
225
|
+
const color = PALETTE.methods[route.method] ?? PALETTE.muted;
|
|
226
|
+
const method = s.style.color ? s.paint.bg(colorAt([color, PALETTE.ink], 0.75), s.strong(padEnd(` ${route.method}`, 8), color)) : padEnd(route.method, 7);
|
|
227
|
+
const access = route.access === "anonymous" ? s.muted(`○ ${t.routes.open}`) : route.open ? s.fg(PALETTE.orange, `○ ${t.routes.open}`) : s.fg(PALETTE.lime, `🔒 ${t.routes.protected}`);
|
|
228
|
+
s.line(` ${method} ${padEnd(s.text(route.path), pathWidth)}${padEnd(s.muted(route.handler), handlerWidth)}${access}${route.notes?.length ? ` ${s.faint(route.notes.join(" · "))}` : ""}`);
|
|
229
|
+
}
|
|
230
|
+
if (routes.length > shown.length) s.line(` ${s.muted(`… ${t.common.plus} ${routes.length - shown.length}`)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private printTimings(kernel: Kernel): void {
|
|
234
|
+
const s = this.out;
|
|
235
|
+
const t = s.t;
|
|
236
|
+
const memory = process.memoryUsage().rss;
|
|
237
|
+
s.section(t.section.startup, `${s.muted(t.common.memory)} ${s.text(formatBytes(memory))}`);
|
|
238
|
+
const bars: [string, number, string][] = [
|
|
239
|
+
[t.startup.configuration, kernel.buildTimings.configMs, "#00f0ff"],
|
|
240
|
+
[t.startup.container, kernel.buildTimings.containerMs, "#4cc9f0"],
|
|
241
|
+
[t.startup.infrastructure, stageMs(kernel, "infrastructure"), "#7b61ff"],
|
|
242
|
+
[t.startup.schema, stageMs(kernel, "schema"), "#b15cff"],
|
|
243
|
+
[t.startup.services, stageMs(kernel, "services"), "#ff2ee6"],
|
|
244
|
+
];
|
|
245
|
+
const total = Math.max(kernel.startupDurationMs, ...bars.map((bar) => bar[1]));
|
|
246
|
+
for (const [label, ms, color] of bars) s.bar(label, ms, total, color, formatDuration(ms));
|
|
247
|
+
s.line();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private printHeader(s: Screen, environment: string, production: boolean): void {
|
|
251
|
+
const t = s.t;
|
|
252
|
+
const app = appInfo();
|
|
253
|
+
const mode = environment === "development" ? "dev" : environment === "test" ? "test" : "prod";
|
|
254
|
+
const main = Bun.main ? path.relative(process.cwd(), Bun.main) || Bun.main : "";
|
|
255
|
+
s.header(mode, MODE[mode as keyof typeof MODE] ?? MODE.prod, [
|
|
256
|
+
`${s.strong(app.name ?? "app")}${app.version ? ` ${s.muted(app.version)}` : ""}`,
|
|
257
|
+
[s.kv("bazis", s.text(bazisVersion() ?? "?")), s.kv("bun", s.text(Bun.version)), s.kv(t.meta.pid, s.text(String(process.pid)))].join(s.dot),
|
|
258
|
+
[
|
|
259
|
+
s.kv(t.meta.env, s.strong(environment, production ? PALETTE.lime : PALETTE.yellow)),
|
|
260
|
+
s.kv(t.meta.watch, this.watch === undefined ? s.muted(t.meta.off) : s.strong(t.meta.on, PALETTE.lime)),
|
|
261
|
+
...(main ? [s.kv(t.meta.src, s.text(main))] : []),
|
|
262
|
+
].join(s.dot),
|
|
263
|
+
]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── failure ───────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** The failed stage with the stages before and after it, and one panel per problem. */
|
|
269
|
+
failed(error: unknown, kernel?: Kernel, built?: StartupReport): void {
|
|
270
|
+
const s = this.err;
|
|
271
|
+
const t = s.t;
|
|
272
|
+
const report: StartupReport | undefined = kernel?.report ?? built;
|
|
273
|
+
const diagnosis = diagnoseStartupFailure(error, s, { report, failedServices: kernel?.failedServices });
|
|
274
|
+
if (this.header) this.printHeader(s, kernel?.environment.name ?? process.env.BAZIS_ENV ?? process.env.NODE_ENV ?? "development", (kernel?.environment.isProduction) ?? false);
|
|
275
|
+
|
|
276
|
+
const failedIndex = STARTUP_STAGES.indexOf(diagnosis.stage);
|
|
277
|
+
const titles: Record<StartupStage, [string, string]> = {
|
|
278
|
+
configuration: [t.section.configuration, t.step.config],
|
|
279
|
+
modules: [t.section.modules, t.step.modules],
|
|
280
|
+
infrastructure: [t.section.infrastructure, t.step.connections],
|
|
281
|
+
schema: [t.section.schema, t.step.schema],
|
|
282
|
+
services: [t.section.startup, t.step.services],
|
|
283
|
+
};
|
|
284
|
+
const skipReason: Partial<Record<StartupStage, string>> = {
|
|
285
|
+
configuration: t.failure.skipConfig,
|
|
286
|
+
infrastructure: t.failure.skipInfra,
|
|
287
|
+
};
|
|
288
|
+
STARTUP_STAGES.forEach((stage, index) => {
|
|
289
|
+
const [section, step] = titles[stage];
|
|
290
|
+
if (index < failedIndex) {
|
|
291
|
+
// Stages that did not run at all (no connections, no schema) stay out.
|
|
292
|
+
const detail = this.passedDetail(stage, kernel, report);
|
|
293
|
+
if (detail === undefined) return;
|
|
294
|
+
s.section(section);
|
|
295
|
+
s.step("ok", step, detail);
|
|
296
|
+
s.line();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (index === failedIndex) {
|
|
300
|
+
const problems = diagnosis.problems.length;
|
|
301
|
+
s.section(section, s.strong(`${problems} ${t.noun("problem", problems)}`, PALETTE.red), true);
|
|
302
|
+
s.step("fail", step, this.failedDetail(stage, kernel, diagnosis.problems.length));
|
|
303
|
+
this.failedTree(stage, error, kernel);
|
|
304
|
+
s.line();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (index === failedIndex + 1 || (stage === "services" && index > failedIndex)) {
|
|
308
|
+
s.section(section);
|
|
309
|
+
s.step("skip", step, s.muted(skipReason[diagnosis.stage] ?? t.failure.skipEarlier));
|
|
310
|
+
s.line();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
s.banner("failed", diagnosis.title);
|
|
315
|
+
s.problems(diagnosis.problems);
|
|
316
|
+
s.footer(this.watch === undefined ? t.failure.exitNothing(1) : t.failure.exitWatch(1, this.watch));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private passedDetail(stage: StartupStage, kernel: Kernel | undefined, report: StartupReport | undefined): string | undefined {
|
|
320
|
+
const s = this.err;
|
|
321
|
+
const t = s.t;
|
|
322
|
+
if (stage === "configuration") {
|
|
323
|
+
const count = groupSections(report?.config ?? []).size;
|
|
324
|
+
return s.num(count, t.noun("section", count));
|
|
325
|
+
}
|
|
326
|
+
if (kernel === undefined) return undefined;
|
|
327
|
+
if (stage === "modules") {
|
|
328
|
+
const count = kernel.rootModule === undefined ? 0 : countModules(describeModules(kernel.rootModule));
|
|
329
|
+
return s.num(count, t.noun("module", count));
|
|
330
|
+
}
|
|
331
|
+
if (stage === "infrastructure") {
|
|
332
|
+
const connections = kernel.report.connections;
|
|
333
|
+
return connections.length === 0 ? undefined : s.num(connections.length, t.infra.up(connections.length), PALETTE.lime);
|
|
334
|
+
}
|
|
335
|
+
if (stage === "schema") {
|
|
336
|
+
const operations = kernel.report.schemas.reduce((sum, schema) => sum + schema.applied.length + schema.migrated.length, 0);
|
|
337
|
+
return kernel.report.schemas.length === 0 ? undefined : s.num(operations, t.noun("operation", operations));
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private failedDetail(stage: StartupStage, kernel: Kernel | undefined, problems: number): string {
|
|
343
|
+
const s = this.err;
|
|
344
|
+
const t = s.t;
|
|
345
|
+
if (stage === "infrastructure" && kernel !== undefined) {
|
|
346
|
+
const connections = kernel.report.connections;
|
|
347
|
+
const down = connections.filter((connection) => connection.status !== "up").length;
|
|
348
|
+
return `${s.num(down, t.infra.failed(connections.length), PALETTE.red)}${connections.length > 1 ? `${s.dot}${s.muted(t.infra.parallel)}` : ""}`;
|
|
349
|
+
}
|
|
350
|
+
return s.num(problems, t.noun("problem", problems), PALETTE.red);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private failedTree(stage: StartupStage, error: unknown, kernel: Kernel | undefined): void {
|
|
354
|
+
const s = this.err;
|
|
355
|
+
if (stage === "configuration" && error instanceof ConfigValidationError) {
|
|
356
|
+
const sections = new Map<string, string[]>();
|
|
357
|
+
for (const issue of error.issues) sections.set(issue.section || "app", [...(sections.get(issue.section || "app") ?? []), shortKey(issue.key)]);
|
|
358
|
+
s.tree([...sections].map(([name, keys]) => ({ text: `${padEnd(s.strong(name, PALETTE.red), 10)}${keys.map((key) => s.fg(PALETTE.red, key)).join(s.dot)}` })), this.indent);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (stage !== "infrastructure" || kernel === undefined) return;
|
|
362
|
+
s.tree(kernel.report.connections.map((connection) => {
|
|
363
|
+
const status = connection.status === "up" ? "up" : connection.status === "wait" ? "wait" : "down";
|
|
364
|
+
const reason = connection.status === "up"
|
|
365
|
+
? [connection.kind ? s.text(connection.kind) : "", connection.ms === undefined ? "" : s.fg(PALETTE.lime, formatDuration(connection.ms))].filter(Boolean).join(s.dot)
|
|
366
|
+
: connection.status === "wait"
|
|
367
|
+
? s.fg(PALETTE.orange, s.t.infra.connectTimeout(error instanceof Error && "timeoutMs" in error ? `${Number((error as { timeoutMs: number }).timeoutMs) / 1000} s` : "…"))
|
|
368
|
+
: s.fg(PALETTE.red, firstLine(connection.error));
|
|
369
|
+
return { text: `${s.badge(status)} ${padEnd(s.strong(connection.name), 9)}${padEnd(s.fg(PALETTE.table, (connection.kind ?? "").split(" ")[0] ?? ""), 13)}${reason}` };
|
|
370
|
+
}), this.indent);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function configRows(kernel: Kernel): readonly ConfigInspection[] {
|
|
375
|
+
return kernel.report.config;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Rows by config prefix: `db.host` → `db`. */
|
|
379
|
+
function groupSections(rows: readonly ConfigInspection[]): Map<string, ConfigInspection[]> {
|
|
380
|
+
const sections = new Map<string, ConfigInspection[]>();
|
|
381
|
+
for (const row of rows) {
|
|
382
|
+
const name = row.key.includes(".") ? row.key.slice(0, row.key.indexOf(".")) : "app";
|
|
383
|
+
sections.set(name, [...(sections.get(name) ?? []), row]);
|
|
384
|
+
}
|
|
385
|
+
return sections;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function shortKey(key: string): string {
|
|
389
|
+
return key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stageMs(kernel: Kernel, stage: "infrastructure" | "schema" | "services"): number {
|
|
393
|
+
return kernel.startTimings
|
|
394
|
+
.filter((timing) => {
|
|
395
|
+
const name = timing.names[0] ?? "";
|
|
396
|
+
if (name.startsWith("infra:")) return stage === "infrastructure";
|
|
397
|
+
if (name.startsWith("schema:") || timing.phase < 0) return stage === "schema";
|
|
398
|
+
return stage === "services";
|
|
399
|
+
})
|
|
400
|
+
.reduce((sum, timing) => sum + timing.ms, 0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function countModules(nodes: readonly ModuleNode[]): number {
|
|
404
|
+
return nodes.reduce((sum, node) => sum + 1 + countModules(node.children), 0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function commonPrefix(paths: readonly string[]): string {
|
|
408
|
+
if (paths.length === 0) return "/";
|
|
409
|
+
const split = paths.map((value) => value.split("/").filter(Boolean));
|
|
410
|
+
const first = split[0]!;
|
|
411
|
+
let length = 0;
|
|
412
|
+
while (length < first.length && !first[length]!.startsWith(":") && split.every((parts) => parts[length] === first[length])) length += 1;
|
|
413
|
+
return `/${first.slice(0, length).join("/")}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function firstLine(error: unknown): string {
|
|
417
|
+
const text = error instanceof Error ? error.message : String(error ?? "");
|
|
418
|
+
return text.replace(/^Infra connector "[^"]+": /, "").split("\n")[0] ?? "";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let cachedApp: { name?: string; version?: string } | undefined;
|
|
422
|
+
|
|
423
|
+
/** Name and version of the application from `package.json` of the working directory. */
|
|
424
|
+
function appInfo(): { name?: string; version?: string } {
|
|
425
|
+
if (cachedApp !== undefined) return cachedApp;
|
|
426
|
+
try {
|
|
427
|
+
const json = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8")) as { name?: unknown; version?: unknown };
|
|
428
|
+
cachedApp = { ...(typeof json.name === "string" ? { name: json.name } : {}), ...(typeof json.version === "string" ? { version: json.version } : {}) };
|
|
429
|
+
} catch {
|
|
430
|
+
cachedApp = {};
|
|
431
|
+
}
|
|
432
|
+
return cachedApp;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let cachedBazis: string | undefined | null = null;
|
|
436
|
+
|
|
437
|
+
function bazisVersion(): string | undefined {
|
|
438
|
+
if (cachedBazis !== null) return cachedBazis;
|
|
439
|
+
try {
|
|
440
|
+
const file = fileURLToPath(new URL("../../../package.json", import.meta.url));
|
|
441
|
+
const version = (JSON.parse(readFileSync(file, "utf8")) as { version?: unknown }).version;
|
|
442
|
+
cachedBazis = typeof version === "string" ? version : undefined;
|
|
443
|
+
} catch {
|
|
444
|
+
cachedBazis = undefined;
|
|
445
|
+
}
|
|
446
|
+
return cachedBazis;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The first external IPv4 address, for the Network link. */
|
|
450
|
+
function networkAddress(): string | undefined {
|
|
451
|
+
try {
|
|
452
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
453
|
+
for (const item of list ?? []) if (item.family === "IPv4" && !item.internal) return item.address;
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { isBazisModuleClass } from "../../di/module/Module";
|
|
2
|
+
import { ProviderDefinition, type BazisModuleMetadata, type BazisModuleRef, type Token } from "../../di";
|
|
3
|
+
import { tokenToDebugName } from "../../di/token";
|
|
4
|
+
|
|
5
|
+
/** One line under a module of the startup screen's module tree. */
|
|
6
|
+
export interface ModuleItem {
|
|
7
|
+
readonly kind: "controller" | "service" | "table" | "background" | "tool" | "gateway" | "exports";
|
|
8
|
+
readonly text: string;
|
|
9
|
+
/** Controller class name, to match its routes. */
|
|
10
|
+
readonly controller?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ModuleNode {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
/** A composite module: only imports of other modules. */
|
|
16
|
+
readonly pack: boolean;
|
|
17
|
+
readonly items: readonly ModuleItem[];
|
|
18
|
+
readonly children: readonly ModuleNode[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Describes extra `@Module` keys of a higher layer (`ormBazis`, `tools`) for
|
|
23
|
+
* the module tree, the same way metadata expanders turn them into imports.
|
|
24
|
+
*/
|
|
25
|
+
export type ModuleDescriber = (metadata: BazisModuleMetadata) => readonly ModuleItem[];
|
|
26
|
+
|
|
27
|
+
const describers: ModuleDescriber[] = [];
|
|
28
|
+
|
|
29
|
+
export function registerModuleDescriber(describer: ModuleDescriber): void {
|
|
30
|
+
if (!describers.includes(describer)) describers.push(describer);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The application's own modules as a tree: classes with `@Module`, without
|
|
35
|
+
* global infrastructure and framework modules. Factory results without a
|
|
36
|
+
* class (`httpModule`, `memory()`) are passed through to the modules they import.
|
|
37
|
+
*/
|
|
38
|
+
export function describeModules(root: BazisModuleRef): readonly ModuleNode[] {
|
|
39
|
+
const seen = new Set<BazisModuleRef>();
|
|
40
|
+
const visit = (module: BazisModuleRef): ModuleNode[] => {
|
|
41
|
+
if (seen.has(module)) return [];
|
|
42
|
+
seen.add(module);
|
|
43
|
+
const children = (module.imports ?? []).flatMap(visit);
|
|
44
|
+
if (!isApplicationModule(module)) return children;
|
|
45
|
+
return [Object.freeze({
|
|
46
|
+
name: (module as { name: string }).name,
|
|
47
|
+
pack: module !== root && isPack(module, children),
|
|
48
|
+
items: Object.freeze(itemsOf(module)),
|
|
49
|
+
children: Object.freeze(children),
|
|
50
|
+
})];
|
|
51
|
+
};
|
|
52
|
+
return Object.freeze(visit(root));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isApplicationModule(module: BazisModuleRef): boolean {
|
|
56
|
+
if (!isBazisModuleClass(module) || module.global === true) return false;
|
|
57
|
+
const name = (module as { name?: string }).name ?? "";
|
|
58
|
+
return name.length > 0 && !name.startsWith("Bazis");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPack(module: BazisModuleMetadata, children: readonly ModuleNode[]): boolean {
|
|
62
|
+
return children.length >= 2 && itemsOf(module).length === 0 && (module.imports ?? []).every(isBazisModuleClass);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function itemsOf(module: BazisModuleMetadata): ModuleItem[] {
|
|
66
|
+
const items: ModuleItem[] = [];
|
|
67
|
+
for (const controller of module.controllers ?? []) items.push({ kind: "controller", text: controller.name, controller: controller.name });
|
|
68
|
+
for (const gateway of module.gateways ?? []) items.push({ kind: "gateway", text: gateway.name });
|
|
69
|
+
for (const provider of module.providers ?? []) {
|
|
70
|
+
const text = describeProvider(provider);
|
|
71
|
+
if (text !== undefined) items.push({ kind: "service", text });
|
|
72
|
+
}
|
|
73
|
+
for (const describer of describers) items.push(...describer(module));
|
|
74
|
+
for (const background of module.background ?? []) items.push({ kind: "background", text: background.name });
|
|
75
|
+
if (module.exports !== undefined && module.exports.length > 0) {
|
|
76
|
+
items.push({ kind: "exports", text: module.exports.map((token) => tokenName(token as Token<unknown>)).join(", ") });
|
|
77
|
+
}
|
|
78
|
+
return items;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `ITaskService → TaskService · scoped`. */
|
|
82
|
+
function describeProvider(definition: ProviderDefinition): string | undefined {
|
|
83
|
+
if (!(definition instanceof ProviderDefinition)) return undefined;
|
|
84
|
+
const provider = definition.provider as { provide: Token<unknown>; useClass?: { name: string } };
|
|
85
|
+
const token = tokenName(provider.provide);
|
|
86
|
+
const implementation = provider.useClass?.name;
|
|
87
|
+
const target = implementation !== undefined && implementation !== token ? `${token} → ${implementation}` : token;
|
|
88
|
+
return `${target} · ${definition.lifetime}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tokenName(token: Token<unknown>): string {
|
|
92
|
+
try {
|
|
93
|
+
return tokenToDebugName(token);
|
|
94
|
+
} catch {
|
|
95
|
+
return "token";
|
|
96
|
+
}
|
|
97
|
+
}
|