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,358 @@
|
|
|
1
|
+
import { detectLanguage, type Language } from "./language";
|
|
2
|
+
import { messages, type Messages } from "./messages";
|
|
3
|
+
import { detectTerminal, Paint, PALETTE, colorAt, padEnd, padStart, widthOf, type TerminalStyle } from "./style";
|
|
4
|
+
|
|
5
|
+
/** Where a screen writes: `process.stdout`, `process.stderr` or a test buffer. */
|
|
6
|
+
export interface ScreenOutput {
|
|
7
|
+
write(text: string): unknown;
|
|
8
|
+
readonly isTTY?: boolean;
|
|
9
|
+
readonly columns?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Status = "ok" | "fail" | "skip" | "warn" | "up" | "down" | "wait" | "info";
|
|
13
|
+
|
|
14
|
+
/** One problem of a failure screen: what, where, why and how to fix it. */
|
|
15
|
+
export interface Problem {
|
|
16
|
+
/** Short class of the problem: `CONFIG`, `INFRA`, `DI`, `TIMEOUT`, `CODEGEN`. */
|
|
17
|
+
readonly tag: string;
|
|
18
|
+
/** Key, connection or `file:line`. */
|
|
19
|
+
readonly where: string;
|
|
20
|
+
readonly cause: string;
|
|
21
|
+
readonly fixes?: readonly string[];
|
|
22
|
+
readonly frame?: CodeFrame;
|
|
23
|
+
/** Stable error code, such as `BAZIS_DI_DEPENDENCY_UNKNOWN`. */
|
|
24
|
+
readonly code?: string;
|
|
25
|
+
readonly declaredIn?: string;
|
|
26
|
+
/** Extra lines (for example a stack) printed after the fixes. */
|
|
27
|
+
readonly details?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Lines around an error position, with the marked column range. */
|
|
31
|
+
export interface CodeFrame {
|
|
32
|
+
/** `[lineNumber, text]` pairs in order. */
|
|
33
|
+
readonly lines: readonly (readonly [number, string])[];
|
|
34
|
+
readonly line: number;
|
|
35
|
+
/** 1-based start column and length of the marked span. */
|
|
36
|
+
readonly column?: number;
|
|
37
|
+
readonly length?: number;
|
|
38
|
+
readonly label?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TreeRow {
|
|
42
|
+
readonly text: string;
|
|
43
|
+
/** Rows nested under this one. */
|
|
44
|
+
readonly children?: readonly TreeRow[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LOGO = Object.freeze(["█▀▀▄ ▄▀▀▄ ▀▀▀█ ▀█▀ █▀▀▀", "█▀▀▄ █▀▀█ ▄▀▀ ░█░ ▀▀▀█", "▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀ ▀▀▀▀"]);
|
|
48
|
+
const SPINNER = Object.freeze(["◜", "◠", "◝", "◞", "◡", "◟"]);
|
|
49
|
+
const STEP_LABEL = 16;
|
|
50
|
+
const TIME = 8;
|
|
51
|
+
|
|
52
|
+
/** Mode badges of the logo: the command that runs. */
|
|
53
|
+
export const MODE = Object.freeze({
|
|
54
|
+
dev: [PALETTE.yellow, PALETTE.orange] as readonly string[],
|
|
55
|
+
test: ["#4cc9f0", "#7b61ff"] as readonly string[],
|
|
56
|
+
build: ["#00f0ff", "#3dff7a"] as readonly string[],
|
|
57
|
+
prod: [PALETTE.green, PALETTE.lime] as readonly string[],
|
|
58
|
+
new: ["#ff2ee6", "#7b61ff"] as readonly string[],
|
|
59
|
+
gen: ["#c77dff", "#ff2ee6"] as readonly string[],
|
|
60
|
+
codegen: ["#00f0ff", "#c77dff"] as readonly string[],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One terminal screen: the logo header, sections, steps with a spinner,
|
|
65
|
+
* success and failure banners, panels and problem boxes. The same calls give
|
|
66
|
+
* plain text without colour or animation when the output is not a terminal,
|
|
67
|
+
* so CI logs keep the structure.
|
|
68
|
+
*/
|
|
69
|
+
export class Screen {
|
|
70
|
+
readonly paint: Paint;
|
|
71
|
+
readonly t: Messages;
|
|
72
|
+
readonly style: TerminalStyle;
|
|
73
|
+
readonly language: Language;
|
|
74
|
+
private readonly badgeWidth: number;
|
|
75
|
+
|
|
76
|
+
constructor(readonly output: ScreenOutput = process.stdout, options: { style?: TerminalStyle; language?: Language } = {}) {
|
|
77
|
+
this.style = options.style ?? detectTerminal(output);
|
|
78
|
+
this.language = options.language ?? detectLanguage();
|
|
79
|
+
this.t = messages(this.language);
|
|
80
|
+
this.paint = new Paint(this.style);
|
|
81
|
+
this.badgeWidth = Math.max(...Object.values(this.t.badge).map((label) => widthOf(label))) + 2;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get width(): number {
|
|
85
|
+
return this.style.width;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
line(text = ""): void {
|
|
89
|
+
this.output.write(`${text}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── text helpers ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
fg(color: string, text: string): string { return this.paint.fg(color, text); }
|
|
95
|
+
bold(text: string): string { return this.paint.bold(text); }
|
|
96
|
+
strong(text: string, color: string = PALETTE.white): string { return this.paint.bold(this.paint.fg(color, text)); }
|
|
97
|
+
muted(text: string): string { return this.paint.fg(PALETTE.muted, text); }
|
|
98
|
+
faint(text: string): string { return this.paint.fg(PALETTE.faint, text); }
|
|
99
|
+
text(text: string): string { return this.paint.fg(PALETTE.text, text); }
|
|
100
|
+
/** Code, a variable or a command inside a sentence. */
|
|
101
|
+
code(text: string): string { return this.paint.bold(this.paint.fg(PALETTE.yellow, text)); }
|
|
102
|
+
link(url: string): string { return this.paint.link(url, PALETTE.link); }
|
|
103
|
+
get dot(): string { return this.faint(" · "); }
|
|
104
|
+
/** `12 files`: the number stands out. */
|
|
105
|
+
num(value: number | string, word: string, color: string = PALETTE.white): string {
|
|
106
|
+
return `${this.strong(String(value), color)} ${this.muted(word)}`;
|
|
107
|
+
}
|
|
108
|
+
kv(key: string, value: string): string { return `${this.muted(key)} ${value}`; }
|
|
109
|
+
cause(text: string): string { return `${this.strong("✖", PALETTE.red)} ${this.text(text)}`; }
|
|
110
|
+
fix(text: string): string { return `${this.strong("➜", PALETTE.lime)} ${text}`; }
|
|
111
|
+
|
|
112
|
+
badge(status: Status): string {
|
|
113
|
+
const label = this.t.badge[status];
|
|
114
|
+
const text = this.bold(padEnd(` ${label}`, this.badgeWidth));
|
|
115
|
+
if (!this.style.color) return `[${padEnd(label, this.badgeWidth - 2)}]`;
|
|
116
|
+
switch (status) {
|
|
117
|
+
case "ok": return this.paint.bg(PALETTE.green, this.paint.fg(PALETTE.ink, text));
|
|
118
|
+
case "fail": return this.paint.bg(PALETTE.red, this.paint.fg(PALETTE.white, text));
|
|
119
|
+
case "skip": return this.paint.bg("#2a3350", this.paint.fg(PALETTE.muted, text));
|
|
120
|
+
case "warn": return this.paint.bg("#b45309", this.paint.fg(PALETTE.white, text));
|
|
121
|
+
case "up": return this.paint.bg("#0e3b2a", this.paint.fg(PALETTE.lime, text));
|
|
122
|
+
case "down": return this.paint.bg("#4a0f1d", this.paint.fg(PALETTE.red, text));
|
|
123
|
+
case "wait": return this.paint.bg("#4a3a0f", this.paint.fg(PALETTE.orange, text));
|
|
124
|
+
case "info": return this.paint.bg("#1b2a4a", this.paint.fg(PALETTE.link, text));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── blocks ────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** The logo with a mode badge and three lines of facts next to it. */
|
|
131
|
+
header(mode: string, stops: readonly string[], meta: readonly string[]): void {
|
|
132
|
+
this.line();
|
|
133
|
+
const badge = this.style.color ? this.paint.gradientBg(` ${mode.toUpperCase()} `, stops, PALETTE.ink) : `[${mode.toUpperCase()}]`;
|
|
134
|
+
for (let index = 0; index < LOGO.length; index += 1) {
|
|
135
|
+
const logo = this.style.color ? this.paint.gradient(LOGO[index]!, PALETTE.brand) : LOGO[index]!;
|
|
136
|
+
const lead = index === 0 ? `${badge} ` : "";
|
|
137
|
+
this.line(` ${logo} ${lead}${meta[index] ?? ""}`);
|
|
138
|
+
}
|
|
139
|
+
this.line();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `── TITLE ────── aside`; red when the stage failed. */
|
|
143
|
+
section(title: string, aside = "", failed = false): void {
|
|
144
|
+
const label = ` ${title} `;
|
|
145
|
+
const rule = "─".repeat(Math.max(4, this.width - widthOf(label) - widthOf(aside) - 6));
|
|
146
|
+
const lead = failed ? this.fg(PALETTE.red, "──") : this.paint.gradient("──", PALETTE.brand);
|
|
147
|
+
const tail = this.style.color ? this.paint.gradient(rule, failed ? ["#5c1a2a", "#26304d"] : [PALETTE.faint, "#26304d"]) : rule;
|
|
148
|
+
this.line(` ${lead}${this.strong(label, failed ? PALETTE.red : PALETTE.white)}${tail}${aside ? ` ${aside}` : ""}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A finished step: badge, label, details and the time on the right. */
|
|
152
|
+
step(status: Status, label: string, detail = "", time = ""): void {
|
|
153
|
+
const color = status === "fail" ? PALETTE.red : status === "skip" ? PALETTE.muted : PALETTE.white;
|
|
154
|
+
const width = Math.max(10, this.width - STEP_LABEL - TIME - this.badgeWidth - 4);
|
|
155
|
+
this.line(` ${this.badge(status)} ${padEnd(this.strong(label, color), STEP_LABEL)}${padEnd(detail, width)}${padStart(this.muted(time), TIME)}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A step in progress: a spinner on a terminal, nothing elsewhere. The
|
|
160
|
+
* returned function clears the line; the caller then prints the result
|
|
161
|
+
* with {@link step}.
|
|
162
|
+
*/
|
|
163
|
+
progress(label: string, note?: () => string): () => void {
|
|
164
|
+
if (!this.style.animate) return () => undefined;
|
|
165
|
+
let frame = 0;
|
|
166
|
+
const draw = () => {
|
|
167
|
+
const spinner = this.paint.bg(PALETTE.panel, this.strong(` ${SPINNER[frame % SPINNER.length]} `, colorAt(PALETTE.brand, (frame % 12) / 11)));
|
|
168
|
+
frame += 1;
|
|
169
|
+
this.output.write(`\r\x1b[2K ${spinner} ${this.strong(label, PALETTE.text)}${this.muted(" …")}${note ? ` ${note()}` : ""}`);
|
|
170
|
+
};
|
|
171
|
+
draw();
|
|
172
|
+
const timer = setInterval(draw, 80);
|
|
173
|
+
timer.unref?.();
|
|
174
|
+
let stopped = false;
|
|
175
|
+
return () => {
|
|
176
|
+
if (stopped) return;
|
|
177
|
+
stopped = true;
|
|
178
|
+
clearInterval(timer);
|
|
179
|
+
this.output.write("\r\x1b[2K");
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Runs `work` with a spinner and prints its step line. */
|
|
184
|
+
async run<T>(label: string, work: () => Promise<T>, done: (result: T, ms: number) => { status?: Status; detail?: string } = () => ({})): Promise<T> {
|
|
185
|
+
const started = performance.now();
|
|
186
|
+
const stop = this.progress(label);
|
|
187
|
+
try {
|
|
188
|
+
const result = await work();
|
|
189
|
+
stop();
|
|
190
|
+
const ms = performance.now() - started;
|
|
191
|
+
const shown = done(result, ms);
|
|
192
|
+
this.step(shown.status ?? "ok", label, shown.detail ?? "", formatDuration(ms));
|
|
193
|
+
return result;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
stop();
|
|
196
|
+
this.step("fail", label, "", formatDuration(performance.now() - started));
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Rows under a step, drawn as a tree: `├─`, `│`, `└─`. */
|
|
202
|
+
tree(rows: readonly TreeRow[], indent = 9): void {
|
|
203
|
+
const walk = (items: readonly TreeRow[], prefix: string) => {
|
|
204
|
+
items.forEach((row, index) => {
|
|
205
|
+
const last = index === items.length - 1;
|
|
206
|
+
this.line(`${" ".repeat(indent)}${this.faint(prefix + (last ? "└─ " : "├─ "))}${row.text}`);
|
|
207
|
+
if (row.children?.length) walk(row.children, prefix + (last ? " " : "│ "));
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
walk(rows, "");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A full-width gradient banner: SUCCESS / FAILED and what it means. */
|
|
214
|
+
banner(kind: "success" | "failed", text: string): void {
|
|
215
|
+
const word = kind === "success" ? this.t.banner.success : this.t.banner.failed;
|
|
216
|
+
const icon = kind === "success" ? "✔" : "✖";
|
|
217
|
+
const content = ` ${icon} ${word} ${text}`;
|
|
218
|
+
if (!this.style.color) {
|
|
219
|
+
const rule = "═".repeat(this.width);
|
|
220
|
+
this.line(` ${rule}`);
|
|
221
|
+
this.line(` ${content}`);
|
|
222
|
+
this.line(` ${rule}`);
|
|
223
|
+
this.line();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const stops = kind === "success" ? PALETTE.success : PALETTE.failure;
|
|
227
|
+
const ink = kind === "success" ? PALETTE.ink : PALETTE.white;
|
|
228
|
+
const width = Math.max(this.width, widthOf(content) + 2);
|
|
229
|
+
this.line(` ${this.paint.gradientBg(" ".repeat(width), stops, ink)}`);
|
|
230
|
+
this.line(` ${this.paint.gradientBg(padEnd(content, width), stops, ink)}`);
|
|
231
|
+
this.line(` ${this.paint.gradientBg(" ".repeat(width), stops, ink)}`);
|
|
232
|
+
this.line();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A rounded box with gradient borders: links and next steps. */
|
|
236
|
+
panel(rows: readonly string[]): void {
|
|
237
|
+
const width = this.width - 2;
|
|
238
|
+
const stops = ["#00f0ff", "#7b61ff"];
|
|
239
|
+
const border = (text: string) => this.style.color ? this.paint.gradient(text, stops) : text;
|
|
240
|
+
this.line(` ${border(`╭${"─".repeat(width)}╮`)}`);
|
|
241
|
+
for (const row of rows) {
|
|
242
|
+
this.line(` ${this.fg("#00f0ff", "│")} ${padEnd(row, width - 1)}${this.fg("#7b61ff", "│")}`);
|
|
243
|
+
}
|
|
244
|
+
this.line(` ${border(`╰${"─".repeat(width)}╯`)}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `➜ Label value` row of a {@link panel}. */
|
|
248
|
+
panelRow(label: string, value: string): string {
|
|
249
|
+
return `${this.strong("➜", PALETTE.lime)} ${padEnd(this.strong(label), 11)}${value}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** One problem box: `1/2 CONFIG db.password`, the cause, a code frame, the fixes. */
|
|
253
|
+
problem(index: number, total: number, problem: Problem): void {
|
|
254
|
+
const width = this.width - 2;
|
|
255
|
+
const stops = [PALETTE.red, PALETTE.pink];
|
|
256
|
+
const counter = this.style.color ? this.paint.bg(PALETTE.red, this.strong(` ${index}/${total} `)) : `${index}/${total}`;
|
|
257
|
+
const tag = this.style.color ? this.paint.bg("#4a0f1d", this.strong(` ${problem.tag} `, PALETTE.red)) : `[${problem.tag}]`;
|
|
258
|
+
const title = ` ${counter} ${tag} ${this.strong(problem.where)} `;
|
|
259
|
+
const fill = "─".repeat(Math.max(2, width - widthOf(title) - 1));
|
|
260
|
+
this.line(` ${this.fg(PALETTE.red, "╭─")}${title}${this.style.color ? this.paint.gradient(fill, stops) : fill}${this.fg(PALETTE.pink, "╮")}`);
|
|
261
|
+
const rows: string[] = [];
|
|
262
|
+
for (const [lineIndex, causeLine] of problem.cause.split("\n").entries()) rows.push(lineIndex === 0 ? this.cause(causeLine) : ` ${this.text(causeLine)}`);
|
|
263
|
+
if (problem.frame) rows.push("", ...this.frame(problem.frame));
|
|
264
|
+
if (problem.fixes?.length) rows.push("", ...problem.fixes.map((fix) => this.fix(fix)));
|
|
265
|
+
if (problem.declaredIn) rows.push(`${this.faint(` ${this.t.common.declaredIn}`)} ${this.text(problem.declaredIn)}`);
|
|
266
|
+
if (problem.code) rows.push(`${this.faint(` ${this.t.common.code}`)} ${this.muted(problem.code)}`);
|
|
267
|
+
if (problem.details?.length) rows.push("", ...problem.details.map((detail) => this.faint(detail)));
|
|
268
|
+
for (const row of rows) {
|
|
269
|
+
for (const piece of wrap(row, width - 1)) this.line(` ${this.fg(PALETTE.red, "│")} ${padEnd(piece, width - 1)}${this.fg(PALETTE.pink, "│")}`);
|
|
270
|
+
}
|
|
271
|
+
this.line(` ${this.style.color ? this.paint.gradient(`╰${"─".repeat(width)}╯`, stops) : `╰${"─".repeat(width)}╯`}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Every problem with a blank line between them. */
|
|
275
|
+
problems(problems: readonly Problem[]): void {
|
|
276
|
+
problems.forEach((problem, index) => {
|
|
277
|
+
this.problem(index + 1, problems.length, problem);
|
|
278
|
+
this.line();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
frame(frame: CodeFrame): string[] {
|
|
283
|
+
const numberWidth = Math.max(...frame.lines.map(([number]) => String(number).length));
|
|
284
|
+
const rows: string[] = [];
|
|
285
|
+
for (const [number, text] of frame.lines) {
|
|
286
|
+
const gutter = `${padStart(String(number), numberWidth + 2)} │`;
|
|
287
|
+
if (number !== frame.line) {
|
|
288
|
+
rows.push(`${this.faint(gutter)} ${this.muted(text)}`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const column = frame.column ?? 0;
|
|
292
|
+
const length = Math.max(1, frame.length ?? 1);
|
|
293
|
+
const marked = column > 0
|
|
294
|
+
? `${this.text(text.slice(0, column - 1))}${this.paint.bg("#4a0f1d", this.strong(text.slice(column - 1, column - 1 + length), PALETTE.red))}${this.text(text.slice(column - 1 + length))}`
|
|
295
|
+
: this.text(text);
|
|
296
|
+
rows.push(`${this.strong(gutter, PALETTE.red)} ${marked}`);
|
|
297
|
+
if (column > 0) {
|
|
298
|
+
rows.push(`${this.faint(`${" ".repeat(numberWidth + 2)} │`)} ${this.fg(PALETTE.red, `${" ".repeat(column - 1)}${"^".repeat(length)}${frame.label ? ` ${frame.label}` : ""}`)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return rows;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
warning(text: string): void {
|
|
305
|
+
this.line(` ${this.badge("warn")} ${this.text(text)}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
footer(text: string): void {
|
|
309
|
+
this.line(` ${this.muted(text)}`);
|
|
310
|
+
this.line();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A horizontal bar of a timing chart. */
|
|
314
|
+
bar(label: string, value: number, total: number, color: string, suffix: string): void {
|
|
315
|
+
const size = 46;
|
|
316
|
+
const filled = total > 0 ? Math.max(1, Math.round((value / total) * size)) : 1;
|
|
317
|
+
const bar = this.style.color ? `${this.fg(color, "█".repeat(filled))}${this.fg(PALETTE.panel, "█".repeat(size - filled))}` : `${"#".repeat(filled)}${".".repeat(size - filled)}`;
|
|
318
|
+
this.line(` ${padEnd(this.muted(label), 18)}${bar} ${padStart(this.text(suffix), 9)}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** `─── logs ─────` before the application's own output. */
|
|
322
|
+
divider(title: string): void {
|
|
323
|
+
this.line(` ${this.faint(`─── ${title} ${"─".repeat(Math.max(4, this.width - widthOf(title) - 5))}`)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** `0.4 ms`, `41 ms`, `1.2 s`. */
|
|
328
|
+
export function formatDuration(ms: number): string {
|
|
329
|
+
if (ms < 1) return `${ms.toFixed(1)} ms`;
|
|
330
|
+
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
331
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** `512 B`, `48 MB`. */
|
|
335
|
+
export function formatBytes(bytes: number): string {
|
|
336
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
337
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
338
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Splits a styled line by visible width, keeping words together where possible. */
|
|
342
|
+
function wrap(text: string, width: number): string[] {
|
|
343
|
+
if (widthOf(text) <= width) return [text];
|
|
344
|
+
const words = text.split(" ");
|
|
345
|
+
const lines: string[] = [];
|
|
346
|
+
let current = "";
|
|
347
|
+
for (const word of words) {
|
|
348
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
349
|
+
if (current && widthOf(candidate) > width) {
|
|
350
|
+
lines.push(current);
|
|
351
|
+
current = ` ${word}`;
|
|
352
|
+
} else {
|
|
353
|
+
current = candidate;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (current) lines.push(current);
|
|
357
|
+
return lines;
|
|
358
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal capabilities and colour helpers. Every helper degrades to plain
|
|
3
|
+
* text when colour is off, so the same rendering code serves a terminal, a CI
|
|
4
|
+
* log and a file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TerminalStyle {
|
|
8
|
+
/** 24-bit colour escapes. */
|
|
9
|
+
readonly color: boolean;
|
|
10
|
+
/** Spinners and in-place redraws: only on an interactive terminal. */
|
|
11
|
+
readonly animate: boolean;
|
|
12
|
+
/** Clickable OSC 8 links. */
|
|
13
|
+
readonly links: boolean;
|
|
14
|
+
/** Columns for full-width lines (clamped to 72..110). */
|
|
15
|
+
readonly width: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface StreamLike {
|
|
19
|
+
readonly isTTY?: boolean;
|
|
20
|
+
readonly columns?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Colour on an interactive terminal; `NO_COLOR` turns it off and
|
|
25
|
+
* `FORCE_COLOR` (anything but `0`) turns it on, as in other CLIs. CI logs and
|
|
26
|
+
* redirected output get plain text without animation.
|
|
27
|
+
*/
|
|
28
|
+
export function detectTerminal(stream: StreamLike = process.stdout, env: NodeJS.ProcessEnv = process.env): TerminalStyle {
|
|
29
|
+
const interactive = stream.isTTY === true && env.TERM !== "dumb" && !env.CI;
|
|
30
|
+
const forced = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0";
|
|
31
|
+
const color = env.NO_COLOR === undefined && (forced || interactive);
|
|
32
|
+
const columns = typeof stream.columns === "number" && stream.columns > 0 ? stream.columns - 4 : 96;
|
|
33
|
+
return Object.freeze({ color, animate: interactive, links: color && interactive, width: Math.max(72, Math.min(110, columns)) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const PLAIN: TerminalStyle = Object.freeze({ color: false, animate: false, links: false, width: 96 });
|
|
37
|
+
|
|
38
|
+
type Rgb = readonly [number, number, number];
|
|
39
|
+
|
|
40
|
+
function rgb(hex: string): Rgb {
|
|
41
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
42
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mix(a: string, b: string, t: number): string {
|
|
46
|
+
const [r1, g1, b1] = rgb(a), [r2, g2, b2] = rgb(b);
|
|
47
|
+
const channel = (x: number, y: number) => Math.round(x + (y - x) * t).toString(16).padStart(2, "0");
|
|
48
|
+
return `#${channel(r1, r2)}${channel(g1, g2)}${channel(b1, b2)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Colour at position `t` (0..1) of a gradient through `stops`. */
|
|
52
|
+
export function colorAt(stops: readonly string[], t: number): string {
|
|
53
|
+
if (stops.length === 1) return stops[0]!;
|
|
54
|
+
const segment = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
|
|
55
|
+
return mix(stops[segment]!, stops[segment + 1]!, t * (stops.length - 1) - segment);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ANSI = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g;
|
|
59
|
+
|
|
60
|
+
/** Text as the terminal shows it: escapes removed. */
|
|
61
|
+
export function plain(text: string): string {
|
|
62
|
+
return text.replace(ANSI, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Visible width in columns. */
|
|
66
|
+
export function widthOf(text: string): number {
|
|
67
|
+
return [...plain(text)].length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function padEnd(text: string, width: number): string {
|
|
71
|
+
return text + " ".repeat(Math.max(0, width - widthOf(text)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function padStart(text: string, width: number): string {
|
|
75
|
+
return " ".repeat(Math.max(0, width - widthOf(text))) + text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Colour primitives bound to one terminal: no-ops when colour is off. */
|
|
79
|
+
export class Paint {
|
|
80
|
+
constructor(readonly style: TerminalStyle) {}
|
|
81
|
+
|
|
82
|
+
fg(hex: string, text: string): string {
|
|
83
|
+
if (!this.style.color) return text;
|
|
84
|
+
const [r, g, b] = rgb(hex);
|
|
85
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
bg(hex: string, text: string): string {
|
|
89
|
+
if (!this.style.color) return text;
|
|
90
|
+
const [r, g, b] = rgb(hex);
|
|
91
|
+
return `\x1b[48;2;${r};${g};${b}m${text}\x1b[49m`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
bold(text: string): string {
|
|
95
|
+
return this.style.color ? `\x1b[1m${text}\x1b[22m` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
gradient(text: string, stops: readonly string[]): string {
|
|
99
|
+
if (!this.style.color) return text;
|
|
100
|
+
const chars = [...text];
|
|
101
|
+
return chars.map((char, index) => this.fg(colorAt(stops, chars.length === 1 ? 0 : index / (chars.length - 1)), char)).join("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
gradientBg(text: string, stops: readonly string[], ink: string): string {
|
|
105
|
+
if (!this.style.color) return text;
|
|
106
|
+
const chars = [...text];
|
|
107
|
+
return chars.map((char, index) => this.bg(colorAt(stops, chars.length === 1 ? 0 : index / (chars.length - 1)), this.fg(ink, this.bold(char)))).join("");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A clickable link where the terminal supports it, otherwise the URL itself. */
|
|
111
|
+
link(url: string, color: string): string {
|
|
112
|
+
const text = this.bold(this.fg(color, url));
|
|
113
|
+
if (!this.style.links) return text;
|
|
114
|
+
return `\x1b]8;;${url}\x1b\\\x1b[4m${text}\x1b[24m\x1b]8;;\x1b\\`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The bazis palette. */
|
|
119
|
+
export const PALETTE = Object.freeze({
|
|
120
|
+
brand: ["#00f0ff", "#7b61ff", "#ff2ee6"] as readonly string[],
|
|
121
|
+
success: ["#00c853", "#3dff7a", "#00f0ff"] as readonly string[],
|
|
122
|
+
failure: ["#ff2e5b", "#ff3d8b", "#ff2ee6"] as readonly string[],
|
|
123
|
+
lime: "#3dff7a",
|
|
124
|
+
green: "#00c853",
|
|
125
|
+
yellow: "#ffe14d",
|
|
126
|
+
orange: "#ff9f1c",
|
|
127
|
+
red: "#ff2e5b",
|
|
128
|
+
pink: "#ff2ee6",
|
|
129
|
+
white: "#ffffff",
|
|
130
|
+
text: "#f1f5f9",
|
|
131
|
+
muted: "#8b9bb4",
|
|
132
|
+
faint: "#4b5a78",
|
|
133
|
+
ink: "#0b1020",
|
|
134
|
+
panel: "#1b2140",
|
|
135
|
+
link: "#5ce1ff",
|
|
136
|
+
route: "#ffd60a",
|
|
137
|
+
service: "#c77dff",
|
|
138
|
+
table: "#00d4ff",
|
|
139
|
+
job: "#ff8c42",
|
|
140
|
+
tool: "#ff4fd8",
|
|
141
|
+
export: "#7ee8fa",
|
|
142
|
+
methods: { GET: "#3dff7a", POST: "#ffd60a", PUT: "#4cc9f0", PATCH: "#c77dff", DELETE: "#ff2e5b", HEAD: "#8b9bb4", OPTIONS: "#8b9bb4" } as Readonly<Record<string, string>>,
|
|
143
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.100.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"./library/openapi": "./library/openapi/index.ts",
|
|
51
51
|
"./library/orm": "./library/orm/index.ts",
|
|
52
52
|
"./library/redaction": "./library/redaction/index.ts",
|
|
53
|
+
"./library/terminal": "./library/terminal/index.ts",
|
|
53
54
|
"./library/ui": "./library/ui/index.ts",
|
|
54
55
|
"./library/validation": "./library/validation/index.ts"
|
|
55
56
|
},
|