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
package/cli/main.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { useTypeScriptCompilerApi } from "../core/scripts/typescriptApi";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { detectLanguage, formatDuration, padEnd, PALETTE, type Language, type ScreenOutput } from "../library/terminal";
|
|
5
|
+
import { parseCliArgs, type ParsedGenerateArgs } from "./parseCli";
|
|
6
|
+
import { runCodegen, type CodegenOptions } from "./codegen";
|
|
5
7
|
import { generateProject } from "./generateProject";
|
|
6
8
|
import { runBuild, runDev, runTest } from "./build";
|
|
9
|
+
import { commandFailed, commandHeader, createScreens, lineOutput, type CliOutput } from "./ui";
|
|
7
10
|
|
|
8
11
|
export const USAGE = `Usage:
|
|
9
12
|
bazis new <Name> [options] Create a new application project
|
|
@@ -19,6 +22,7 @@ export const USAGE = `Usage:
|
|
|
19
22
|
bazis build --bin --dotenv The executable also reads .env files from its working directory
|
|
20
23
|
bazis --help
|
|
21
24
|
g can also be written as generate.
|
|
25
|
+
--lang <en|ru> sets the output language (also BAZIS_LANG, "language" in bazis.config.json).
|
|
22
26
|
|
|
23
27
|
Generation options:
|
|
24
28
|
--path <directory> Exact project directory (new only; default: ./<name>)
|
|
@@ -62,75 +66,154 @@ Read AGENTS.md and docs/architecture/MODULE_ARCHITECTURE.md before implementing.
|
|
|
62
66
|
export interface CliRuntime {
|
|
63
67
|
readonly log: (message: string) => void;
|
|
64
68
|
readonly error: (message: string) => void;
|
|
65
|
-
readonly codegen: (cwd: string, target?: string) => Promise<number>;
|
|
69
|
+
readonly codegen: (cwd: string, target?: string, options?: CodegenOptions) => Promise<number>;
|
|
70
|
+
/** Terminal outputs for colour and spinners; without them `log`/`error` get plain lines. */
|
|
71
|
+
readonly stdout?: ScreenOutput;
|
|
72
|
+
readonly stderr?: ScreenOutput;
|
|
73
|
+
/** Output language when `--lang` is not given; detected from the environment otherwise. */
|
|
74
|
+
readonly language?: Language;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
const defaultRuntime: CliRuntime = {
|
|
69
78
|
log: (message) => console.log(message),
|
|
70
79
|
error: (message) => console.error(message),
|
|
71
80
|
codegen: runCodegen,
|
|
81
|
+
stdout: process.stdout,
|
|
82
|
+
stderr: process.stderr,
|
|
72
83
|
};
|
|
73
84
|
|
|
85
|
+
/** `--lang ru` / `--lang=ru` anywhere in the arguments; the rest goes to the parser. */
|
|
86
|
+
export function extractLanguage(argv: readonly string[]): { readonly argv: readonly string[]; readonly lang?: string } {
|
|
87
|
+
const rest: string[] = [];
|
|
88
|
+
let lang: string | undefined;
|
|
89
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
90
|
+
const arg = argv[index]!;
|
|
91
|
+
// After `test`, arguments belong to bun test.
|
|
92
|
+
if (rest[0] === "test") { rest.push(arg); continue; }
|
|
93
|
+
if (arg === "--lang") { lang = argv[++index]; continue; }
|
|
94
|
+
if (arg.startsWith("--lang=")) { lang = arg.slice("--lang=".length); continue; }
|
|
95
|
+
rest.push(arg);
|
|
96
|
+
}
|
|
97
|
+
return { argv: rest, ...(lang === undefined ? {} : { lang }) };
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
export async function runCli(argv: readonly string[], runtime: CliRuntime = defaultRuntime): Promise<number> {
|
|
75
|
-
const
|
|
101
|
+
const extracted = extractLanguage(argv);
|
|
102
|
+
const language = detectLanguage({ flag: extracted.lang ?? runtime.language });
|
|
103
|
+
const ui = createScreens(runtime.stdout ?? lineOutput(runtime.log), runtime.stderr ?? lineOutput(runtime.error), language);
|
|
104
|
+
const codegen = (cwd: string, target?: string, options?: CodegenOptions) => runtime.codegen(cwd, target, { ui, ...options });
|
|
105
|
+
const parsed = parseCliArgs(extracted.argv);
|
|
76
106
|
if (parsed.kind === "help") {
|
|
77
107
|
runtime.log(USAGE.trim());
|
|
78
108
|
return parsed.help ? 0 : 1;
|
|
79
109
|
}
|
|
80
110
|
if (parsed.kind === "error") {
|
|
81
|
-
|
|
82
|
-
runtime.error("Use bazis --help to list commands and options.");
|
|
83
|
-
return 1;
|
|
111
|
+
return commandFailed(ui.err, ui.err.t.failure.command, [{ tag: "CLI", where: "bazis", cause: parsed.message, fixes: [ui.err.t.errors.unknown] }], 1);
|
|
84
112
|
}
|
|
113
|
+
const cwd = process.cwd();
|
|
85
114
|
try {
|
|
86
|
-
if (parsed.kind === "codegen") return await
|
|
87
|
-
if (parsed.kind === "dev") return await runDev(
|
|
88
|
-
if (parsed.kind === "test") return await runTest(
|
|
89
|
-
if (parsed.kind === "build") return await runBuild(
|
|
90
|
-
if (parsed.kind === "new")
|
|
91
|
-
const result = await generateProject({ name: parsed.name, outputPath: parsed.outputPath, frameworkPath: parsed.frameworkPath, linkFramework: parsed.linkFramework, vendor: parsed.vendor, dryRun: parsed.dryRun });
|
|
92
|
-
runtime.log(`[bazis] ${result.dryRun ? "planned" : "created"} project: ${result.projectDir}`);
|
|
93
|
-
for (const file of result.files) runtime.log(` + ${file}`);
|
|
94
|
-
runtime.log(result.frameworkMode === "registry"
|
|
95
|
-
? `[bazis] Framework dependency: "bazis": "${result.dependency}" from npm.`
|
|
96
|
-
: result.frameworkMode === "snapshot"
|
|
97
|
-
? `[bazis] vendor/bazis: ${result.frameworkFileCount} package files${result.dryRun ? " planned" : " copied"}. Keep this directory in version control.`
|
|
98
|
-
: "[bazis] Framework is linked to an external checkout (--link-framework).");
|
|
99
|
-
if (!result.dryRun) runtime.log(`[bazis] Next: cd ${result.projectDir} && bun install && bunx bazis dev`);
|
|
100
|
-
return 0;
|
|
101
|
-
}
|
|
102
|
-
const args = parsed.args;
|
|
103
|
-
const options = {
|
|
104
|
-
name: args.name, modulesRoot: args.modulesRoot, appModulePath: args.appModule,
|
|
105
|
-
register: args.register, force: args.force, dryRun: args.dryRun,
|
|
106
|
-
};
|
|
115
|
+
if (parsed.kind === "codegen") return await runCodegenCommand(cwd, parsed.target, codegen, ui);
|
|
116
|
+
if (parsed.kind === "dev") return await runDev(cwd, codegen, ui, { watch: parsed.watch });
|
|
117
|
+
if (parsed.kind === "test") return await runTest(cwd, parsed.args, codegen, ui);
|
|
118
|
+
if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv }, codegen, ui);
|
|
119
|
+
if (parsed.kind === "new") return await runNew(parsed, ui);
|
|
107
120
|
// Module registration parses the host module with the TypeScript API: load it
|
|
108
121
|
// only after switching TypeScript 7 to @typescript/typescript6.
|
|
109
|
-
useTypeScriptCompilerApi(
|
|
110
|
-
|
|
111
|
-
const result = args.generator === "pack"
|
|
112
|
-
? await generateModulePack({ ...options, parts: args.parts })
|
|
113
|
-
: await generateModule({ ...options, profile: args.profile, pack: args.pack });
|
|
114
|
-
runtime.log(`[bazis] ${result.dryRun ? "planned" : "generated"} ${args.generator}: ${result.moduleDir}`);
|
|
115
|
-
for (const change of result.changes) runtime.log(` ${change.action === "create" ? "+" : "~"} ${change.path}`);
|
|
116
|
-
for (const warning of result.warnings) runtime.log(`[bazis] ${warning}`);
|
|
117
|
-
if (result.dryRun) {
|
|
118
|
-
runtime.log("[bazis] dry-run: no files written; codegen not run.");
|
|
119
|
-
return 0;
|
|
120
|
-
}
|
|
121
|
-
if (result.registered) runtime.log("[bazis] connected in host module.");
|
|
122
|
-
if (!args.codegen || !result.registered) {
|
|
123
|
-
runtime.log(`[bazis] codegen skipped: ${!args.codegen ? "--no-codegen" : "module is not connected by this command"}.`);
|
|
124
|
-
return 0;
|
|
125
|
-
}
|
|
126
|
-
runtime.log("[bazis] running codegen...");
|
|
127
|
-
const exitCode = await runtime.codegen(process.cwd(), args.target);
|
|
128
|
-
if (exitCode !== 0) runtime.error("[bazis] codegen failed; scaffold files were kept. Fix the error and run bazis codegen.");
|
|
129
|
-
return exitCode;
|
|
122
|
+
useTypeScriptCompilerApi(cwd);
|
|
123
|
+
return await runGenerate(cwd, parsed.args, codegen, ui);
|
|
130
124
|
} catch (error) {
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
return commandFailed(ui.err, ui.err.t.failure.command, [{ tag: "CLI", where: parsed.kind === "generate" ? `g ${parsed.args.generator}` : parsed.kind, cause: error instanceof Error ? error.message : String(error) }], 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runCodegenCommand(cwd: string, target: string | undefined, codegen: CliRuntime["codegen"], ui: CliOutput): Promise<number> {
|
|
130
|
+
const s = ui.out;
|
|
131
|
+
const t = s.t;
|
|
132
|
+
const startedAt = performance.now();
|
|
133
|
+
commandHeader(s, "codegen", cwd, [s.kv(t.meta.target, s.text(target ?? "default"))]);
|
|
134
|
+
s.section(t.section.build);
|
|
135
|
+
const exitCode = await codegen(cwd, target, { ui });
|
|
136
|
+
if (exitCode !== 0) return exitCode;
|
|
137
|
+
s.line();
|
|
138
|
+
s.banner("success", t.codegen.done(formatDuration(performance.now() - startedAt)));
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function runNew(parsed: Extract<ReturnType<typeof parseCliArgs>, { kind: "new" }>, ui: CliOutput): Promise<number> {
|
|
143
|
+
const s = ui.out;
|
|
144
|
+
const t = s.t;
|
|
145
|
+
const result = await generateProject({ name: parsed.name, outputPath: parsed.outputPath, frameworkPath: parsed.frameworkPath, linkFramework: parsed.linkFramework, vendor: parsed.vendor, dryRun: parsed.dryRun });
|
|
146
|
+
const relative = path.relative(process.cwd(), result.projectDir) || ".";
|
|
147
|
+
commandHeader(s, "new", process.cwd(), [s.kv(t.meta.path, s.text(relative)), ...(result.dryRun ? [s.fg(PALETTE.yellow, t.common.dryRun)] : [])], { name: parsed.name, version: "0.1.0" });
|
|
148
|
+
s.section(t.section.project, s.muted(relative));
|
|
149
|
+
s.step("ok", t.step.files, s.num(result.files.length, t.noun("file", result.files.length)));
|
|
150
|
+
s.tree(result.files.map((file) => ({ text: `${s.strong("+", PALETTE.lime)} ${s.text(path.relative(result.projectDir, path.resolve(result.projectDir, file)))}` })), s.style.color ? 9 : 4);
|
|
151
|
+
s.line();
|
|
152
|
+
s.line(` ${s.strong("◆", PALETTE.service)} ${s.text(result.frameworkMode === "registry"
|
|
153
|
+
? t.project.dependency(result.dependency)
|
|
154
|
+
: result.frameworkMode === "snapshot" ? t.project.vendor(result.frameworkFileCount) : t.project.linked)}`);
|
|
155
|
+
s.line();
|
|
156
|
+
s.banner("success", result.dryRun ? t.project.planned(parsed.name) : t.project.created(parsed.name));
|
|
157
|
+
if (!result.dryRun) {
|
|
158
|
+
const step = (number: number, command: string, note: string) => `${s.strong(String(number), PALETTE.lime)} ${padEnd(command, 32)}${s.muted(note)}`;
|
|
159
|
+
s.panel([
|
|
160
|
+
step(1, s.code(`cd ${relative}`), ""),
|
|
161
|
+
step(2, s.code("bun install"), t.project.install),
|
|
162
|
+
step(3, s.code("bunx bazis dev"), t.project.dev),
|
|
163
|
+
step(4, s.link("http://127.0.0.1:3000/health"), t.project.open),
|
|
164
|
+
]);
|
|
165
|
+
s.line();
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runGenerate(cwd: string, args: ParsedGenerateArgs, codegen: CliRuntime["codegen"], ui: CliOutput): Promise<number> {
|
|
171
|
+
const s = ui.out;
|
|
172
|
+
const t = s.t;
|
|
173
|
+
const { generateModule, generateModulePack } = await import("./generateModule");
|
|
174
|
+
const options = {
|
|
175
|
+
name: args.name, modulesRoot: args.modulesRoot, appModulePath: args.appModule,
|
|
176
|
+
register: args.register, force: args.force, dryRun: args.dryRun,
|
|
177
|
+
};
|
|
178
|
+
const kind = t.module.kind[args.generator];
|
|
179
|
+
commandHeader(s, "gen", cwd, [
|
|
180
|
+
s.kv(kind, s.strong(args.name)),
|
|
181
|
+
...(args.generator === "module" ? [s.kv("profile", s.text(args.profile))] : [s.kv("parts", s.text(args.parts.join(", ")))]),
|
|
182
|
+
...(args.dryRun ? [s.fg(PALETTE.yellow, t.common.dryRun)] : []),
|
|
183
|
+
]);
|
|
184
|
+
const result = args.generator === "pack"
|
|
185
|
+
? await generateModulePack({ ...options, parts: args.parts })
|
|
186
|
+
: await generateModule({ ...options, profile: args.profile, pack: args.pack });
|
|
187
|
+
s.section(t.section.files, s.muted(path.relative(cwd, result.moduleDir) || result.moduleDir));
|
|
188
|
+
s.step("ok", t.step.files, s.num(result.changes.length, t.noun("change", result.changes.length)));
|
|
189
|
+
s.tree(result.changes.map((change) => ({ text: `${change.action === "create" ? s.strong("+", PALETTE.lime) : s.strong("~", PALETTE.yellow)} ${s.text(change.path)}` })), s.style.color ? 9 : 4);
|
|
190
|
+
for (const warning of result.warnings) s.warning(warning);
|
|
191
|
+
s.line();
|
|
192
|
+
const host = result.changes.find((change) => change.action === "update")?.path;
|
|
193
|
+
if (result.registered) s.line(` ${s.strong("↗", PALETTE.export)} ${s.text(t.module.registered(host ?? "App.module.ts"))}`);
|
|
194
|
+
else s.warning(t.module.notRegistered);
|
|
195
|
+
// What the module needs from the application to run.
|
|
196
|
+
if (args.generator === "module" && args.profile !== "empty") s.line(` ${s.strong("◆", PALETTE.service)} ${s.muted(t.module.needsDb)}`);
|
|
197
|
+
if (args.generator === "module" && args.profile === "full") s.line(` ${s.strong("◆", PALETTE.service)} ${s.muted(t.module.needsCache)}`);
|
|
198
|
+
s.line();
|
|
199
|
+
if (result.dryRun) {
|
|
200
|
+
s.banner("success", t.module.planned(kind, args.name));
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
if (result.registered && args.codegen) {
|
|
204
|
+
s.section(t.section.build);
|
|
205
|
+
const exitCode = await codegen(cwd, args.target);
|
|
206
|
+
if (exitCode !== 0) {
|
|
207
|
+
ui.err.footer(t.module.keptOnFailure);
|
|
208
|
+
return exitCode;
|
|
209
|
+
}
|
|
210
|
+
s.line();
|
|
211
|
+
} else if (result.registered) {
|
|
212
|
+
s.line(` ${s.muted(t.module.codegenSkipped)}`);
|
|
213
|
+
s.line();
|
|
133
214
|
}
|
|
215
|
+
s.banner("success", t.module.created(kind, args.name));
|
|
216
|
+
return 0;
|
|
134
217
|
}
|
|
135
218
|
|
|
136
219
|
if (import.meta.main) process.exitCode = await runCli(Bun.argv.slice(2));
|
package/cli/naming.ts
CHANGED
|
@@ -43,6 +43,9 @@ export function parseModuleName(raw: string): ModuleNaming {
|
|
|
43
43
|
if (input.length === 0) {
|
|
44
44
|
throw new Error("Module name is required.");
|
|
45
45
|
}
|
|
46
|
+
if (!/^[a-zA-Z]/.test(input)) {
|
|
47
|
+
throw new Error(`Invalid module name "${input}": it must start with a letter (then letters, digits and hyphens).`);
|
|
48
|
+
}
|
|
46
49
|
if (!/^[a-zA-Z][a-zA-Z0-9]*(?:-[a-zA-Z0-9]+)*$/.test(input)) {
|
|
47
50
|
throw new Error(`Invalid module name "${input}". Use letters, digits, and hyphens only.`);
|
|
48
51
|
}
|
package/cli/ui.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { MODE, PALETTE, Screen, formatDuration, type Language, type Problem, type ScreenOutput } from "../library/terminal";
|
|
5
|
+
|
|
6
|
+
/** Where the CLI writes: the terminal, or a test buffer of lines. */
|
|
7
|
+
export interface CliOutput {
|
|
8
|
+
readonly out: Screen;
|
|
9
|
+
readonly err: Screen;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A line sink (`console.log`, a test array) as a screen output without colour. */
|
|
13
|
+
export function lineOutput(sink: (line: string) => void): ScreenOutput {
|
|
14
|
+
let pending = "";
|
|
15
|
+
return {
|
|
16
|
+
isTTY: false,
|
|
17
|
+
write(text: string) {
|
|
18
|
+
const lines = (pending + text).split("\n");
|
|
19
|
+
pending = lines.pop() ?? "";
|
|
20
|
+
for (const line of lines) sink(line.trimEnd());
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createScreens(stdout: ScreenOutput, stderr: ScreenOutput, language?: Language): CliOutput {
|
|
26
|
+
const out = new Screen(stdout, { language });
|
|
27
|
+
return { out, err: new Screen(stderr, { language: out.language, style: stderr === stdout ? out.style : undefined }) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The project of the working directory: name and version from `package.json`. */
|
|
31
|
+
export function projectInfo(cwd: string): { name?: string; version?: string } {
|
|
32
|
+
try {
|
|
33
|
+
const json = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf8")) as { name?: unknown; version?: unknown };
|
|
34
|
+
return { ...(typeof json.name === "string" ? { name: json.name } : {}), ...(typeof json.version === "string" ? { version: json.version } : {}) };
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Version of the bazis package this CLI belongs to. */
|
|
41
|
+
export function bazisVersion(): string {
|
|
42
|
+
try {
|
|
43
|
+
const file = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
44
|
+
if (!existsSync(file)) return "?";
|
|
45
|
+
const version = (JSON.parse(readFileSync(file, "utf8")) as { version?: unknown }).version;
|
|
46
|
+
return typeof version === "string" ? version : "?";
|
|
47
|
+
} catch {
|
|
48
|
+
return "?";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The logo with the command badge: project, versions, and the command's own facts. */
|
|
53
|
+
export function commandHeader(screen: Screen, mode: keyof typeof MODE, cwd: string, facts: readonly string[], project = projectInfo(cwd)): void {
|
|
54
|
+
screen.header(mode, MODE[mode], [
|
|
55
|
+
project.name ? `${screen.strong(project.name)}${project.version ? ` ${screen.muted(project.version)}` : ""}` : screen.muted(path.basename(cwd)),
|
|
56
|
+
[screen.kv("bazis", screen.text(bazisVersion())), screen.kv("bun", screen.text(Bun.version))].join(screen.dot),
|
|
57
|
+
facts.join(screen.dot),
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A failed command: the banner, its problems and the exit code. */
|
|
62
|
+
export function commandFailed(screen: Screen, title: string, problems: readonly Problem[], exitCode: number, footer?: string): number {
|
|
63
|
+
screen.banner("failed", title);
|
|
64
|
+
screen.problems(problems);
|
|
65
|
+
screen.footer(footer ?? screen.t.failure.exit(exitCode));
|
|
66
|
+
return exitCode;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Lines `from..to` of a file around `line` (1-based) for a code frame. */
|
|
70
|
+
export function readFrameLines(file: string, line: number, around = 2): (readonly [number, string])[] {
|
|
71
|
+
try {
|
|
72
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
73
|
+
const from = Math.max(1, line - around), to = Math.min(lines.length, line + around);
|
|
74
|
+
const result: (readonly [number, string])[] = [];
|
|
75
|
+
for (let number = from; number <= to; number += 1) result.push([number, lines[number - 1] ?? ""]);
|
|
76
|
+
return result;
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function took(screen: Screen, ms: number): string {
|
|
83
|
+
return screen.muted(formatDuration(ms));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { PALETTE };
|
package/core/agent/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { Class } from "../di";
|
|
2
|
+
import { registerModuleDescriber } from "../kernel/report/moduleTree";
|
|
3
|
+
import { toolMetadataOf } from "./metadata";
|
|
4
|
+
|
|
5
|
+
// The module tree of the startup screen: `orders.summary · read`.
|
|
6
|
+
registerModuleDescriber((metadata) => (metadata.tools ?? []).map((tool) => {
|
|
7
|
+
const meta = toolMetadataOf(tool);
|
|
8
|
+
return { kind: "tool" as const, text: meta === undefined ? tool.name : `${meta.name} · ${meta.sideEffect}` };
|
|
9
|
+
}));
|
|
2
10
|
|
|
3
11
|
export { Tool } from "./decorators/Tool";
|
|
4
12
|
export { describeTool } from "./Tool.contract";
|
package/core/app/runApp.ts
CHANGED
|
@@ -259,7 +259,10 @@ function normalizeAbsolutePath(path: string): string {
|
|
|
259
259
|
*/
|
|
260
260
|
export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}): Promise<number> {
|
|
261
261
|
await loadBazisGeneratedRuntime();
|
|
262
|
-
return Bazis.run(composeApp(root, options), options.kernel,
|
|
262
|
+
return Bazis.run(composeApp(root, options), options.kernel, (builder) => {
|
|
263
|
+
builder.useApplicationRoot(root);
|
|
264
|
+
options.configure?.(builder);
|
|
265
|
+
});
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
/**
|
|
@@ -40,6 +40,12 @@ export interface HostedService {
|
|
|
40
40
|
useDiagnostics?(diagnostics: HostedServiceDiagnostics): void;
|
|
41
41
|
/** Name in startup diagnostics, such as `infra:db`; the class name by default. */
|
|
42
42
|
readonly diagnosticName?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Starts together with neighbouring `concurrentStart` services of the same
|
|
45
|
+
* phase instead of one by one, and their failures are reported together.
|
|
46
|
+
* For independent work such as connections of the infrastructure manifest.
|
|
47
|
+
*/
|
|
48
|
+
readonly concurrentStart?: boolean;
|
|
43
49
|
start(signal?: AbortSignal): void | Promise<void>;
|
|
44
50
|
stop(): void | Promise<void>;
|
|
45
51
|
/**
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Class, HostedService, ServiceProvider, ServiceResolver, ServiceScope } from "../di";
|
|
2
2
|
import { loadBazisGeneratedRuntime } from "../generatedRuntime";
|
|
3
|
-
import { Environment, HealthService, LOGGER, type HealthReport } from "../kernel";
|
|
3
|
+
import { Environment, HealthService, LOGGER, StartupReport, type HealthReport } from "../kernel";
|
|
4
4
|
import { redactSensitive } from "../../library/redaction";
|
|
5
5
|
import { HttpContext } from "./HttpContext/HttpContext";
|
|
6
6
|
import { holdResponseScope } from "./HttpContext/responseLifetime";
|
|
@@ -182,6 +182,7 @@ export class HttpServer implements HostedService {
|
|
|
182
182
|
this.collectRouteComposer(),
|
|
183
183
|
);
|
|
184
184
|
this.router = builder.build(this.options.controllers);
|
|
185
|
+
const routes = builder.routes;
|
|
185
186
|
this.readVersion = createVersionReader(this.options.versioning);
|
|
186
187
|
this.docs = prepareOpenApiDocs({
|
|
187
188
|
option: this.options.docs,
|
|
@@ -230,7 +231,37 @@ export class HttpServer implements HostedService {
|
|
|
230
231
|
error: (error) => this.transportError(error),
|
|
231
232
|
}),
|
|
232
233
|
);
|
|
233
|
-
}
|
|
234
|
+
} this.reportStarted(routes);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Routes and the address for the startup screen. */
|
|
238
|
+
private reportStarted(routes: readonly import("./Routing/RouterBuilder").RegisteredRoute[]): void {
|
|
239
|
+
const report = this.resolver.tryResolve(StartupReport);
|
|
240
|
+
const server = this.server;
|
|
241
|
+
if (report === undefined || server === undefined) return;
|
|
242
|
+
report.routes(routes.map((route) => ({
|
|
243
|
+
method: route.method,
|
|
244
|
+
path: route.path.startsWith("/") ? route.path : `/${route.path}`,
|
|
245
|
+
handler: route.handler,
|
|
246
|
+
open: route.open,
|
|
247
|
+
...(route.anonymous ? { access: "anonymous" } : {}),
|
|
248
|
+
})));
|
|
249
|
+
const builtIn = [
|
|
250
|
+
...(this.healthPath === undefined ? [] : [{ method: "GET", path: this.healthPath }]),
|
|
251
|
+
...(this.docs === undefined ? [] : [{ method: "GET", path: this.docs.uiPath }, { method: "GET", path: this.docs.specPath }]),
|
|
252
|
+
];
|
|
253
|
+
report.routes(builtIn.map((route) => ({ ...route, handler: "", open: true, builtIn: true })));
|
|
254
|
+
const hostname = server.hostname ?? "0.0.0.0";
|
|
255
|
+
const shown = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname.includes(":") ? `[${hostname}]` : hostname;
|
|
256
|
+
const url = `http://${shown}:${server.port}`;
|
|
257
|
+
report.listener({
|
|
258
|
+
protocol: "http",
|
|
259
|
+
url,
|
|
260
|
+
hostname,
|
|
261
|
+
port: server.port ?? 0,
|
|
262
|
+
...(this.healthPath === undefined ? {} : { health: `${url}${this.healthPath}` }),
|
|
263
|
+
...(this.docs === undefined ? {} : { docs: `${url}${this.docs.uiPath}` }),
|
|
264
|
+
});
|
|
234
265
|
}
|
|
235
266
|
|
|
236
267
|
private async startBunServer(create: (listen: ListenOptions) => ReturnType<typeof Bun.serve>): Promise<ReturnType<typeof Bun.serve>> {
|
|
@@ -10,6 +10,17 @@ import { corsOptionsOf } from "../Middleware/cors";
|
|
|
10
10
|
import { normalizeResult } from "../Results/normalize";
|
|
11
11
|
import type { ApiVersioningOptions, RouteMiddlewareComposer } from "../options";
|
|
12
12
|
import { createAuthorizeComposer } from "../Authorization/authorizeComposer";
|
|
13
|
+
import { resolveAuthorizeMeta } from "../Authorization/metadata";
|
|
14
|
+
|
|
15
|
+
/** A registered route as the startup screen lists it. */
|
|
16
|
+
export interface RegisteredRoute {
|
|
17
|
+
readonly method: string;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly handler: string;
|
|
20
|
+
/** No `@Authorize`, or `@AllowAnonymous`. */
|
|
21
|
+
readonly open: boolean;
|
|
22
|
+
readonly anonymous: boolean;
|
|
23
|
+
}
|
|
13
24
|
import { Router, type RouteAction } from "./Router";
|
|
14
25
|
import { joinPaths, parseTemplate } from "./template";
|
|
15
26
|
|
|
@@ -29,6 +40,8 @@ interface ActionPipeline {
|
|
|
29
40
|
*/
|
|
30
41
|
export class RouterBuilder {
|
|
31
42
|
private readonly authorizeComposer = createAuthorizeComposer();
|
|
43
|
+
/** Every route of the last {@link build}, in registration order. */
|
|
44
|
+
readonly routes: RegisteredRoute[] = [];
|
|
32
45
|
|
|
33
46
|
constructor(
|
|
34
47
|
/** Middleware prepended to every route: [logger?, cors?, errorHandler, ...global]. */
|
|
@@ -92,6 +105,14 @@ export class RouterBuilder {
|
|
|
92
105
|
const fullPath = joinPaths(this.globalPrefix, versionSegment, meta.prefix, declaration.template);
|
|
93
106
|
const versionKey = useUrlVersioning ? "" : (version ?? "");
|
|
94
107
|
router.register(parseTemplate(fullPath), declaration.httpMethod, versionKey, routeAction);
|
|
108
|
+
const access = resolveAuthorizeMeta(controllerClass, methodName);
|
|
109
|
+
this.routes.push(Object.freeze({
|
|
110
|
+
method: declaration.httpMethod,
|
|
111
|
+
path: fullPath,
|
|
112
|
+
handler: routeAction.name,
|
|
113
|
+
open: access.allowAnonymous === true || access.authorize === undefined,
|
|
114
|
+
anonymous: access.allowAnonymous === true,
|
|
115
|
+
}));
|
|
95
116
|
}
|
|
96
117
|
}
|
|
97
118
|
}
|
package/core/infra/Infra.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type BazisModuleRef,
|
|
12
12
|
type ProviderDefinition,
|
|
13
13
|
} from "../di";
|
|
14
|
-
import { ConfigRegistry, HEALTH_CHECK } from "../kernel";
|
|
14
|
+
import { ConfigRegistry, HEALTH_CHECK, StartupReport } from "../kernel";
|
|
15
15
|
import { InfraError, type InfraConnector } from "./InfraConnector";
|
|
16
16
|
import { InfraLifecycle } from "./InfraLifecycle";
|
|
17
17
|
|
|
@@ -83,7 +83,13 @@ function buildInfraDefinitions(manifest: InfraManifest): {
|
|
|
83
83
|
// a failing factory elsewhere cannot leave a created resource without cleanup.
|
|
84
84
|
const ownerToken = createToken<InfraLifecycle<unknown>>(`infra:${name}:lifetime`);
|
|
85
85
|
const owner = DI.singleton(DI.factoryProviderWithResolver(ownerToken, [], (resolver) =>
|
|
86
|
-
new InfraLifecycle(
|
|
86
|
+
new InfraLifecycle(
|
|
87
|
+
name,
|
|
88
|
+
connector,
|
|
89
|
+
undefined,
|
|
90
|
+
resolver.has(ConfigRegistry) ? resolver.resolve(ConfigRegistry) : undefined,
|
|
91
|
+
resolver.has(StartupReport) ? resolver.resolve(StartupReport) : undefined,
|
|
92
|
+
)));
|
|
87
93
|
instances.set(owner, { name, token: connector.token });
|
|
88
94
|
providers.push(owner);
|
|
89
95
|
providers.push(
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { InjectionToken, ModuleConfig, ModuleExport, ProviderDefinition } from "../di";
|
|
2
2
|
import { redactSensitiveText } from "../../library/redaction";
|
|
3
3
|
import type { ConfigRegistry } from "../kernel/config/ConfigRegistry";
|
|
4
|
+
import type { ConnectionDescription } from "../kernel/report/StartupReport";
|
|
5
|
+
|
|
6
|
+
export type { ConnectionDescription };
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Infrastructure connector contract: one shape for "a connection to something
|
|
@@ -60,6 +63,16 @@ export interface InfraConnector<TClient = unknown> {
|
|
|
60
63
|
*/
|
|
61
64
|
healthCheck?(client: TClient, signal?: AbortSignal): Promise<boolean> | boolean;
|
|
62
65
|
|
|
66
|
+
/**
|
|
67
|
+
* What the startup screen and a startup failure show about the connection:
|
|
68
|
+
* product and version, the address without credentials, short facts, and
|
|
69
|
+
* the settings that change the address. Called after a successful
|
|
70
|
+
* {@link connect} with `connected: true` (it may ask the server for its
|
|
71
|
+
* version) and after a failed one with `connected: false` (from the config
|
|
72
|
+
* only). Its errors are ignored, and it has one second.
|
|
73
|
+
*/
|
|
74
|
+
describe?(client: TClient, state: { readonly connected: boolean; readonly signal?: AbortSignal }): ConnectionDescription | Promise<ConnectionDescription>;
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* Extra providers the connector adds to the infrastructure module besides
|
|
65
78
|
* the client itself (for example `redisConnect(cfg, { cache: "distributed" })`
|
|
@@ -3,6 +3,9 @@ import { InfraError, type InfraConnector } from "./InfraConnector";
|
|
|
3
3
|
import { bindConnectorLifecycle } from "./connectorIdentity";
|
|
4
4
|
import { awaitAbortable } from "../kernel/internal/awaitAbortable";
|
|
5
5
|
import type { ConfigRegistry } from "../kernel/config/ConfigRegistry";
|
|
6
|
+
import type { ConnectionDescription, StartupReport } from "../kernel/report/StartupReport";
|
|
7
|
+
|
|
8
|
+
const DESCRIBE_TIMEOUT_MS = 1_000;
|
|
6
9
|
|
|
7
10
|
const DEFAULT_INFRA_PHASE = -100;
|
|
8
11
|
|
|
@@ -19,6 +22,8 @@ export class InfraLifecycle<TClient> implements HostedService {
|
|
|
19
22
|
public readonly phase: number;
|
|
20
23
|
/** The manifest key, as in `/health`: `infra:db`. */
|
|
21
24
|
public readonly diagnosticName: string;
|
|
25
|
+
/** Connections of one manifest are independent: they start together and fail together. */
|
|
26
|
+
public readonly concurrentStart = true;
|
|
22
27
|
private client?: TClient;
|
|
23
28
|
private created = false;
|
|
24
29
|
private disposed = false;
|
|
@@ -31,6 +36,7 @@ export class InfraLifecycle<TClient> implements HostedService {
|
|
|
31
36
|
private readonly connector: InfraConnector<TClient>,
|
|
32
37
|
client?: TClient,
|
|
33
38
|
private readonly configs?: ConfigRegistry,
|
|
39
|
+
private readonly report?: StartupReport,
|
|
34
40
|
) {
|
|
35
41
|
this.client = client;
|
|
36
42
|
this.created = client !== undefined;
|
|
@@ -66,11 +72,21 @@ export class InfraLifecycle<TClient> implements HostedService {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
private async connect(signal?: AbortSignal): Promise<void> {
|
|
75
|
+
const startedAt = performance.now();
|
|
69
76
|
try {
|
|
70
77
|
signal?.throwIfAborted();
|
|
71
78
|
const client = this.getClient();
|
|
79
|
+
this.report?.connection({ name: this.name, status: "wait" });
|
|
72
80
|
await awaitAbortable(Promise.resolve(this.connector.connect(client, signal)), signal);
|
|
81
|
+
const ms = performance.now() - startedAt;
|
|
82
|
+
this.report?.connection({ name: this.name, status: "up", ms, ...(await this.describe(client, true, signal)) });
|
|
73
83
|
} catch (error) {
|
|
84
|
+
const ms = performance.now() - startedAt;
|
|
85
|
+
if (this.report !== undefined) {
|
|
86
|
+
const description = this.created ? await this.describe(this.client as TClient, false) : {};
|
|
87
|
+
// A start cut short by the timeout is still waiting, not refused.
|
|
88
|
+
this.report.connection({ name: this.name, status: signal?.aborted ? "wait" : "down", ms, error, ...description });
|
|
89
|
+
}
|
|
74
90
|
try {
|
|
75
91
|
await this.dispose();
|
|
76
92
|
} catch (cleanupError) {
|
|
@@ -83,6 +99,17 @@ export class InfraLifecycle<TClient> implements HostedService {
|
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
|
|
102
|
+
private async describe(client: TClient, connected: boolean, signal?: AbortSignal): Promise<ConnectionDescription> {
|
|
103
|
+
if (this.connector.describe === undefined) return {};
|
|
104
|
+
const timeout = AbortSignal.timeout(DESCRIBE_TIMEOUT_MS);
|
|
105
|
+
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
106
|
+
try {
|
|
107
|
+
return await awaitAbortable(Promise.resolve(this.connector.describe(client, { connected, signal: combined })), combined);
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
public stop(): Promise<void> {
|
|
87
114
|
return this.dispose();
|
|
88
115
|
}
|
|
@@ -27,3 +27,13 @@ export function requireValue(value: unknown, field: string, kind: string): strin
|
|
|
27
27
|
}
|
|
28
28
|
return value;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Environment variables that set the given keys of a config, for the fix
|
|
33
|
+
* hints of a connection failure: `BAZIS_DB__HOST`.
|
|
34
|
+
*/
|
|
35
|
+
export function settingNames(config: object, configs: ConfigRegistry | undefined, keys: readonly string[]): string[] {
|
|
36
|
+
const view = configs?.get(config as AppConfig<object>) as { inspect?: () => readonly { key: string; env: readonly string[] }[] } | undefined;
|
|
37
|
+
const rows = view?.inspect?.() ?? [];
|
|
38
|
+
return keys.flatMap((key) => rows.find((row) => row.key === key || row.key.endsWith(`.${key}`))?.env.slice(0, 1) ?? []);
|
|
39
|
+
}
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.100.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|