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.
Files changed (48) hide show
  1. package/cli/build.ts +171 -36
  2. package/cli/codegen.ts +155 -4
  3. package/cli/generateProject.ts +8 -2
  4. package/cli/main.ts +134 -51
  5. package/cli/naming.ts +3 -0
  6. package/cli/ui.ts +86 -0
  7. package/core/agent/index.ts +8 -0
  8. package/core/app/runApp.ts +4 -1
  9. package/core/di/extensions/hosted-service.ts +6 -0
  10. package/core/http/HttpServer.ts +33 -2
  11. package/core/http/Routing/RouterBuilder.ts +21 -0
  12. package/core/infra/Infra.ts +8 -2
  13. package/core/infra/InfraConnector.ts +13 -0
  14. package/core/infra/InfraLifecycle.ts +27 -0
  15. package/core/infra/connectorConfig.ts +10 -0
  16. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  17. package/core/infra/connectors/llm.ts +23 -2
  18. package/core/infra/connectors/openaiCompatible.ts +26 -1
  19. package/core/infra/connectors/opensearch.ts +33 -6
  20. package/core/infra/connectors/postgres.ts +26 -2
  21. package/core/infra/connectors/redis.ts +23 -1
  22. package/core/infra/index.ts +2 -2
  23. package/core/kernel/Bazis.ts +21 -1
  24. package/core/kernel/Kernel.ts +54 -0
  25. package/core/kernel/KernelBuilder.ts +39 -1
  26. package/core/kernel/LifecycleCoordinator.ts +101 -9
  27. package/core/kernel/config/ConfigRegistry.ts +5 -0
  28. package/core/kernel/config/ConfigValidationError.ts +37 -0
  29. package/core/kernel/config/callerFile.ts +23 -0
  30. package/core/kernel/config/defineConfig.ts +21 -17
  31. package/core/kernel/errors/StartupFailuresError.ts +17 -0
  32. package/core/kernel/errors/index.ts +1 -0
  33. package/core/kernel/index.ts +12 -0
  34. package/core/kernel/report/StartupReport.ts +134 -0
  35. package/core/kernel/report/StartupScreen.ts +459 -0
  36. package/core/kernel/report/moduleTree.ts +97 -0
  37. package/core/kernel/report/startupProblems.ts +186 -0
  38. package/core/orm/OrmLifecycle.ts +29 -7
  39. package/core/orm/buildOrmProviders.ts +7 -3
  40. package/core/orm/databaseConnector.ts +17 -1
  41. package/core/orm/index.ts +11 -0
  42. package/core/scripts/di-generate.ts +58 -9
  43. package/library/terminal/index.ts +4 -0
  44. package/library/terminal/language.ts +60 -0
  45. package/library/terminal/messages.ts +400 -0
  46. package/library/terminal/screen.ts +358 -0
  47. package/library/terminal/style.ts +143 -0
  48. package/package.json +2 -1
@@ -2,7 +2,7 @@ import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
2
2
  import { AGENT_MODEL_PROVIDER, AGENT_RUNTIME_OPTIONS, AgentRuntime, type AgentModelProvider } from "../../agent";
3
3
  import { DI, DiError, HOSTED_SERVICE, SERVICE_PROVIDER, type ProviderDefinition } from "../../di";
4
4
  import { LOGGER } from "../../kernel/logging/Logger";
5
- import { reader, requireValue } from "../connectorConfig";
5
+ import { reader, requireValue, settingNames } from "../connectorConfig";
6
6
  import { InfraError, type InfraConnector } from "../InfraConnector";
7
7
  import { identifyConnector } from "../connectorIdentity";
8
8
 
@@ -28,6 +28,12 @@ export interface LlmConnectionOptions {
28
28
  export interface LlmConnectorOptions {
29
29
  /** Request timeout. Defaults to 60s; `0` explicitly disables it. */
30
30
  readonly timeoutMs?: number;
31
+ /**
32
+ * Check the provider at start (the adapter's `connect`: `GET /models` for an
33
+ * OpenAI-compatible API, five seconds), so a wrong key or address stops the
34
+ * start instead of the first agent request. Default `true`.
35
+ */
36
+ readonly checkOnStart?: boolean;
31
37
  }
32
38
 
33
39
  export interface LlmProviderAdapter {
@@ -86,17 +92,32 @@ export function llmConnect<T extends LlmConfigShape>(
86
92
  adapter: LlmProviderAdapter,
87
93
  options: LlmConnectorOptions = {},
88
94
  ): InfraConnector<AgentModelProvider> {
95
+ const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[] }>();
89
96
  return identifyConnector({
90
97
  token: AGENT_MODEL_PROVIDER,
91
98
  config,
92
99
  providers: agentRuntimeProviders,
93
100
  exports: [AgentRuntime],
94
101
  create(configs) {
95
- return adapter.create(resolveLlmConnectionOptions(config, options, configs));
102
+ const connection = resolveLlmConnectionOptions(config, options, configs);
103
+ const provider = adapter.create(connection);
104
+ described.set(provider, { connection, settings: settingNames(config, configs, ["baseUrl", "apiKey"]) });
105
+ return provider;
96
106
  },
97
107
  connect(provider, signal) {
108
+ if (options.checkOnStart === false) return;
98
109
  return adapter.connect?.(provider, signal);
99
110
  },
111
+ describe(provider, { connected }) {
112
+ const known = described.get(provider);
113
+ if (known === undefined) return {};
114
+ return {
115
+ kind: `${known.connection.provider} · ${known.connection.model}`,
116
+ address: known.connection.baseUrl,
117
+ details: connected && options.checkOnStart !== false && adapter.connect !== undefined ? ["checked"] : [],
118
+ settings: known.settings,
119
+ };
120
+ },
100
121
  dispose(provider) {
101
122
  return adapter.dispose?.(provider);
102
123
  },
@@ -22,10 +22,14 @@ import {
22
22
  type LlmProviderAdapter,
23
23
  } from "./llm";
24
24
  import { readOpenAiTextStream } from "./openaiTextStream";
25
+ import { InfraError } from "../InfraConnector";
26
+ import { redactSensitiveText } from "../../../library/redaction";
25
27
 
26
28
  const DEFAULT_CHAT_COMPLETIONS_PATH = "/chat/completions";
27
29
  const DEFAULT_HEALTH_PATH = "/models";
28
30
  const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
31
+ /** The start check is short: a provider that does not answer in five seconds is not ready. */
32
+ const START_CHECK_TIMEOUT_MS = 5_000;
29
33
 
30
34
  export interface OpenAiCompatibleAdapterOptions {
31
35
  readonly fetch?: OpenAiCompatibleFetch;
@@ -390,13 +394,23 @@ export class OpenAiCompatibleModelProvider implements AgentModelProvider {
390
394
 
391
395
  public async ping(signal?: AbortSignal): Promise<boolean> {
392
396
  try {
393
- await this.request(this.healthPath, "GET", undefined, signal);
397
+ await this.probe(signal);
394
398
  return true;
395
399
  } catch {
396
400
  return false;
397
401
  }
398
402
  }
399
403
 
404
+ /** Base URL of the provider, for diagnostics. */
405
+ public get address(): string {
406
+ return this.baseUrl;
407
+ }
408
+
409
+ /** The health request that rejects with the reason: wrong key, address, no answer. */
410
+ public async probe(signal?: AbortSignal): Promise<void> {
411
+ await this.request(this.healthPath, "GET", undefined, signal);
412
+ }
413
+
400
414
  /** End active requests and reject subsequent calls through this provider. */
401
415
  public dispose(): void {
402
416
  this.lifetime.abort(new OpenAiCompatibleProviderError("OpenAI-compatible provider is disposed."));
@@ -581,6 +595,17 @@ export function openAiCompatibleAdapter(options: OpenAiCompatibleAdapterOptions
581
595
  create(connection) {
582
596
  return new OpenAiCompatibleModelProvider(connection, options);
583
597
  },
598
+ async connect(provider, signal) {
599
+ if (!healthCheckEnabled || !(provider instanceof OpenAiCompatibleModelProvider)) return;
600
+ const deadline = AbortSignal.timeout(START_CHECK_TIMEOUT_MS);
601
+ try {
602
+ await provider.probe(signal === undefined ? deadline : AbortSignal.any([signal, deadline]));
603
+ } catch (error) {
604
+ if (signal?.aborted) throw error;
605
+ const reason = deadline.aborted ? `no answer within ${START_CHECK_TIMEOUT_MS} ms` : error instanceof Error ? error.message : String(error);
606
+ throw new InfraError(`Infra connector "llm": ${provider.address} is not available — ${redactSensitiveText(reason)}`);
607
+ }
608
+ },
584
609
  dispose(provider) {
585
610
  if (provider instanceof OpenAiCompatibleModelProvider) provider.dispose();
586
611
  },
@@ -1,7 +1,7 @@
1
1
  import { createToken, type InjectionToken } from "../../di";
2
2
  import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
3
- import { reader, requireValue } from "../connectorConfig";
4
- import { InfraError, type InfraConnector } from "../InfraConnector";
3
+ import { reader, requireValue, settingNames } from "../connectorConfig";
4
+ import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
5
5
  import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
6
6
 
7
7
  /** Default cluster request timeout (ms): fail fast instead of hanging. */
@@ -124,6 +124,11 @@ export class OpenSearchClient {
124
124
  }
125
125
 
126
126
  /** Healthy only for explicitly accepted cluster states. */
127
+ /** Cluster address without credentials. */
128
+ public get url(): string {
129
+ return this.base;
130
+ }
131
+
127
132
  public async ping(signal?: AbortSignal): Promise<boolean> {
128
133
  try {
129
134
  const health = await this.clusterHealth(signal);
@@ -289,17 +294,39 @@ export function openSearchConnect<T extends OpenSearchConfigShape>(
289
294
  config: AppConfig<T>,
290
295
  options: OpenSearchConnectorOptions = {},
291
296
  ): InfraConnector<OpenSearchClient> {
297
+ const settings = new WeakMap<OpenSearchClient, string[]>();
292
298
  return {
293
299
  token: options.token ?? IOpenSearch,
294
300
  config,
295
301
  create(configs) {
296
- return new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
302
+ const client = new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
303
+ settings.set(client, settingNames(config, configs, ["url", "username", "password"]));
304
+ return client;
297
305
  },
298
306
  async connect(client, signal) {
299
- const alive = await client.ping(signal);
300
- if (!alive) {
301
- throw new InfraError(`Infra connector "opensearch": cluster is not reachable or unhealthy.`);
307
+ let status: string | undefined;
308
+ try {
309
+ status = (await client.clusterHealth(signal))?.status;
310
+ } catch (error) {
311
+ throw new InfraError(`Infra connector "opensearch": cannot reach ${client.url} — ${errorMessage(error)}`);
302
312
  }
313
+ if (status !== "green" && status !== "yellow") {
314
+ throw new InfraError(`Infra connector "opensearch": cluster ${client.url} is unhealthy (status ${status ?? "unknown"}).`);
315
+ }
316
+ },
317
+ async describe(client, { connected, signal }) {
318
+ if (!connected) return { kind: "OpenSearch", address: client.url, settings: settings.get(client) ?? [] };
319
+ const [info, health] = await Promise.all([
320
+ client.request<{ version?: { number?: string; distribution?: string } }>("GET", "/", undefined, signal),
321
+ client.clusterHealth(signal),
322
+ ]);
323
+ const product = info.version?.distribution === "opensearch" || info.version?.distribution === undefined ? "OpenSearch" : info.version.distribution;
324
+ return {
325
+ kind: info.version?.number ? `${product} ${info.version.number}` : product,
326
+ address: client.url,
327
+ details: [health.status],
328
+ settings: settings.get(client) ?? [],
329
+ };
303
330
  },
304
331
  dispose(client) {
305
332
  client.dispose();
@@ -1,7 +1,7 @@
1
1
  import { SQL } from "bun";
2
2
  import { createToken, type InjectionToken } from "../../di";
3
3
  import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
4
- import { reader, requireValue } from "../connectorConfig";
4
+ import { reader, requireValue, settingNames } from "../connectorConfig";
5
5
  import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
6
6
 
7
7
  /**
@@ -82,11 +82,23 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
82
82
  if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0 || shutdownTimeoutMs > 2147483647) {
83
83
  throw new InfraError("postgres shutdownTimeoutMs must be an integer 0..2147483647");
84
84
  }
85
+ const described = new WeakMap<SQL, { options: { hostname?: string; port?: number; database?: string; username?: string; max?: number }; settings: string[] }>();
85
86
  return {
86
87
  token: options.token ?? IPostgres,
87
88
  config,
88
89
  create(configs) {
89
- return new SQL(postgresConnectionOptions(config, configs));
90
+ const connection = postgresConnectionOptions(config, configs);
91
+ const client = new SQL(connection);
92
+ described.set(client, { options: connection as { hostname?: string; port?: number; database?: string; username?: string; max?: number }, settings: settingNames(config, configs, ["host", "port", "database", "username", "password"]) });
93
+ return client;
94
+ },
95
+ async describe(client, { connected }) {
96
+ const known = described.get(client);
97
+ const version = connected ? await serverVersion((sql) => client.unsafe(sql)) : undefined;
98
+ return {
99
+ kind: version === undefined ? "PostgreSQL" : `PostgreSQL ${version}`,
100
+ ...(known === undefined ? {} : { address: postgresAddress(known.options), details: [`pool ${known.options.max ?? 10}`], settings: known.settings }),
101
+ };
90
102
  },
91
103
  async connect(client) {
92
104
  try {
@@ -108,3 +120,15 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
108
120
  },
109
121
  };
110
122
  }
123
+
124
+ /** `postgres@127.0.0.1:5432/app`: user, host, port and database, never the password. */
125
+ export function postgresAddress(options: { readonly hostname?: string; readonly port?: number | string; readonly database?: string; readonly username?: string }): string {
126
+ return `${options.username ?? ""}${options.username ? "@" : ""}${options.hostname ?? "localhost"}:${options.port ?? 5432}/${options.database ?? ""}`;
127
+ }
128
+
129
+ /** `17.6` from `SHOW server_version`; undefined when the server does not say. */
130
+ export async function serverVersion(query: (sql: string) => Promise<unknown>): Promise<string | undefined> {
131
+ const rows = await query("SHOW server_version") as readonly Record<string, unknown>[];
132
+ const value = rows[0]?.server_version;
133
+ return typeof value === "string" ? value.split(" ")[0] : undefined;
134
+ }
@@ -9,7 +9,7 @@ import {
9
9
  import { createToken, DI, type InjectionToken, type ProviderDefinition } from "../../di";
10
10
  import type { AppConfig, Secret } from "../../kernel";
11
11
  import { RedisDistributedCacheBackend, type RedisDistributedCacheTuning } from "../cache/RedisDistributedCacheBackend";
12
- import { reader, requireValue } from "../connectorConfig";
12
+ import { reader, requireValue, settingNames } from "../connectorConfig";
13
13
  import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
14
14
  import { redactSensitiveText } from "../../../library/redaction";
15
15
 
@@ -109,6 +109,7 @@ export function redisConnect<T extends RedisConfigShape>(
109
109
  throw new InfraError("redisConnect connectTimeoutMs must be a positive integer.");
110
110
  }
111
111
  const urls = new WeakMap<RedisClient, string>();
112
+ const settings = new WeakMap<RedisClient, string[]>();
112
113
  const cacheProviders =
113
114
  cache === undefined
114
115
  ? undefined
@@ -123,8 +124,19 @@ export function redisConnect<T extends RedisConfigShape>(
123
124
  const url = redisUrl(reader(config, configs).get("url"));
124
125
  const client = new RedisClient(url);
125
126
  urls.set(client, url);
127
+ settings.set(client, settingNames(config, configs, ["url"]));
126
128
  return client;
127
129
  },
130
+ async describe(client, { connected }) {
131
+ const info = connected ? String(await client.send("INFO", ["server"])) : "";
132
+ const valkey = /valkey_version:([^\r\n]+)/.exec(info)?.[1];
133
+ const redis = /redis_version:([^\r\n]+)/.exec(info)?.[1];
134
+ return {
135
+ kind: valkey ? `Valkey ${valkey}` : redis ? `Redis ${redis}` : "Redis",
136
+ address: redisAddress(urls.get(client) ?? ""),
137
+ settings: settings.get(client) ?? [],
138
+ };
139
+ },
128
140
  async connect(client) {
129
141
  // The address without a password: it names the server in the error.
130
142
  const target = redactSensitiveText(urls.get(client) ?? "redis");
@@ -172,3 +184,13 @@ function withoutMode(cache: { readonly mode: "distributed" } & RedisDistributedC
172
184
  const { mode: _mode, ...tuning } = cache;
173
185
  return tuning;
174
186
  }
187
+
188
+ /** `127.0.0.1:6379/0` without the scheme, user and password. */
189
+ function redisAddress(url: string): string {
190
+ try {
191
+ const parsed = new URL(url);
192
+ return `${parsed.hostname}:${parsed.port || "6379"}${parsed.pathname === "/" ? "" : parsed.pathname}`;
193
+ } catch {
194
+ return redactSensitiveText(url);
195
+ }
196
+ }
@@ -4,9 +4,9 @@
4
4
  // (`SQL`, `RedisClient`) and `fetch`, with zero external dependencies.
5
5
 
6
6
  export { Infra, infraModule, type InfraManifest } from "./Infra";
7
- export { InfraError, errorMessage, type InfraConnector } from "./InfraConnector";
7
+ export { InfraError, errorMessage, type ConnectionDescription, type InfraConnector } from "./InfraConnector";
8
8
  export { InfraLifecycle } from "./InfraLifecycle";
9
- export { reader, requireValue, type ConfigReader } from "./connectorConfig";
9
+ export { reader, requireValue, settingNames, type ConfigReader } from "./connectorConfig";
10
10
  export { codexAppServerConnect, CODEX_APP_SERVER } from "./connectors/codex";
11
11
  export { CodexError, RU_CODEX_MESSAGES, type CodexErrorCode, CODEX_MODEL_ID, CODEX_REASONING_EFFORT, type CodexClient, type CodexConfigShape, type CodexLogin, type CodexLoginMethod, type CodexModel, type CodexStatus, type CodexRunInput, type CodexChatMessage, type CodexTool, type CodexToolCall, type CodexToolResult } from "./connectors/codex/contracts";
12
12
 
@@ -5,6 +5,7 @@ import { KernelError, ShutdownTimeoutError } from "./errors";
5
5
  import type { KernelOptions } from "./types";
6
6
  import { redactSensitiveText } from "../../library/redaction";
7
7
  import { formatStartupFailure } from "./startupFailure";
8
+ import { StartupScreen, startupScreenEnabled } from "./report/StartupScreen";
8
9
 
9
10
  /**
10
11
  * Entry-point facade.
@@ -31,12 +32,26 @@ export class Bazis {
31
32
  if (options) {
32
33
  builder.useOptions(options);
33
34
  }
35
+ // On a terminal the start is a screen: what started, or what failed and why.
36
+ const screen = startupScreenEnabled() ? new StartupScreen() : undefined;
37
+ let stopWaiting: (() => void) | undefined;
38
+ if (screen !== undefined && options?.startupReport !== false) {
39
+ builder.useStartupScreen((started) => {
40
+ stopWaiting?.();
41
+ screen.started(started);
42
+ });
43
+ }
34
44
  configure?.(builder);
35
45
 
36
46
  let kernel: Kernel;
37
47
  try {
38
48
  kernel = await builder.build();
39
49
  } catch (error) {
50
+ if (screen !== undefined && !(error instanceof ShutdownTimeoutError)) {
51
+ screen.failed(error, undefined, builder.report);
52
+ process.exitCode = 1;
53
+ return 1;
54
+ }
40
55
  if (error instanceof DiError || error instanceof KernelError) {
41
56
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
42
57
  process.exitCode = 1;
@@ -46,10 +61,13 @@ export class Bazis {
46
61
  }
47
62
 
48
63
  try {
64
+ if (kernel.report.screen) stopWaiting = screen?.waiting(kernel);
49
65
  const exitCode = await kernel.run();
66
+ stopWaiting?.();
50
67
  process.exitCode = exitCode;
51
68
  return exitCode;
52
69
  } catch (error) {
70
+ stopWaiting?.();
53
71
  if (error instanceof ShutdownTimeoutError) {
54
72
  // Graceful shutdown is stuck: hanging handles would keep the process
55
73
  // alive forever, so this is the one place a hard exit is correct.
@@ -58,7 +76,9 @@ export class Bazis {
58
76
  }
59
77
  // Configuration problems surfacing at start (e.g. options fail-fast)
60
78
  // deserve the same friendly output as build-time ones.
61
- if (error instanceof DiError || error instanceof KernelError) {
79
+ if (screen !== undefined) {
80
+ screen.failed(error, kernel);
81
+ } else if (error instanceof DiError || error instanceof KernelError) {
62
82
  console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
63
83
  } else {
64
84
  console.error(`[bazis] application failed: ${formatStartupFailure(error)}`);
@@ -15,6 +15,9 @@ import { validateTimeout } from "./internal/validateTimeout";
15
15
  import { awaitShutdown } from "./internal/awaitShutdown";
16
16
  import { awaitAbortable } from "./internal/awaitAbortable";
17
17
  import { reportDiagnosticFailure } from "./internal/reportDiagnosticFailure";
18
+ import { StartupReport } from "./report/StartupReport";
19
+ import type { BazisModuleRef } from "../di";
20
+ import type { StartupBatchTiming } from "./LifecycleCoordinator";
18
21
 
19
22
  export interface KernelTimings {
20
23
  readonly configMs: number;
@@ -30,6 +33,12 @@ export interface KernelRuntimeOptions {
30
33
  readonly startupReport: boolean;
31
34
  /** Start the infrastructure and the schema only, then stop (BAZIS_SCHEMA_ONLY). */
32
35
  readonly schemaOnly?: boolean;
36
+ /** Facts for the startup screen; a fresh one when absent. */
37
+ readonly report?: StartupReport;
38
+ /** The application root module, for the module tree of the startup screen. */
39
+ readonly root?: BazisModuleRef;
40
+ /** Renders the startup screen in place of the `bazis started` log line. */
41
+ readonly onStarted?: (kernel: Kernel) => void;
33
42
  }
34
43
 
35
44
  interface StopRequest {
@@ -56,6 +65,7 @@ export class Kernel {
56
65
  private shutdownPromise?: Promise<void>;
57
66
  private startupCleanupFailure?: unknown;
58
67
  private startupAbort?: AbortController;
68
+ private ownReport?: StartupReport;
59
69
 
60
70
  public constructor(
61
71
  public readonly container: DiContainer,
@@ -76,6 +86,42 @@ export class Kernel {
76
86
  return this.state === "started";
77
87
  }
78
88
 
89
+ /** What subsystems recorded while starting: connections, schema, routes. */
90
+ public get report(): StartupReport {
91
+ this.ownReport ??= this.options.report ?? new StartupReport();
92
+ return this.ownReport;
93
+ }
94
+
95
+ /** The application root module, when the builder knows it. */
96
+ public get rootModule(): BazisModuleRef | undefined {
97
+ return this.options.root;
98
+ }
99
+
100
+ /** Configuration and container build time, and the moment the build began. */
101
+ public get buildTimings(): KernelTimings {
102
+ return this.timings;
103
+ }
104
+
105
+ /** Wall time of every started group of hosted services. */
106
+ public get startTimings(): readonly StartupBatchTiming[] {
107
+ return this.coordinator.startTimings;
108
+ }
109
+
110
+ /** Hosted services whose start failed. */
111
+ public get failedServices(): readonly string[] {
112
+ return this.coordinator.failedServiceNames;
113
+ }
114
+
115
+ /** Hosted services whose start is running now. */
116
+ public get startingServices(): readonly string[] {
117
+ return this.coordinator.startingServiceNames;
118
+ }
119
+
120
+ /** From the start of the build to a started application, ms; 0 before. */
121
+ public get startupDurationMs(): number {
122
+ return this.startupMs;
123
+ }
124
+
79
125
  public start(): Promise<void> {
80
126
  if (this.startPromise !== undefined) {
81
127
  return this.startPromise;
@@ -396,6 +442,14 @@ export class Kernel {
396
442
  }
397
443
 
398
444
  private printStartupReport(): void {
445
+ if (this.options.onStarted !== undefined) {
446
+ try {
447
+ this.options.onStarted(this);
448
+ return;
449
+ } catch {
450
+ // A broken screen falls back to the log line below.
451
+ }
452
+ }
399
453
  const logger = this.container.tryResolve(LOGGER);
400
454
  const fields = {
401
455
  environment: this.environment.name,
@@ -13,6 +13,7 @@ import { ConsoleLogger } from "./logging/ConsoleLogger";
13
13
  import { LOGGER, type Logger } from "./logging/Logger";
14
14
  import type { ConfigSource, EnvironmentName, KernelOptions, UnhandledErrorPolicy } from "./types";
15
15
  import { validateTimeout } from "./internal/validateTimeout";
16
+ import { StartupReport } from "./report/StartupReport";
16
17
 
17
18
  /** Root module or a profile factory (Spring profiles, but explicit). */
18
19
  export type RootModuleInput = BazisModuleRef | ((environment: Environment) => BazisModuleRef);
@@ -49,6 +50,14 @@ export class KernelBuilder {
49
50
  private startupReport?: boolean;
50
51
  private schemaOnly?: boolean;
51
52
  private logger?: Logger;
53
+ private startupScreen?: (kernel: Kernel) => void;
54
+ private applicationRoot?: BazisModuleRef;
55
+ private builtReport?: StartupReport;
56
+
57
+ /** Facts of the last {@link build}, also when it failed after the configuration was read. */
58
+ public get report(): StartupReport | undefined {
59
+ return this.builtReport;
60
+ }
52
61
 
53
62
  public constructor(private readonly rootModule: RootModuleInput) {}
54
63
 
@@ -104,6 +113,25 @@ export class KernelBuilder {
104
113
  return this;
105
114
  }
106
115
 
116
+ /**
117
+ * Renders the startup screen instead of the `bazis started` log line, and
118
+ * makes subsystems record their facts for it rather than print them.
119
+ * `Bazis.run` sets it on an interactive terminal.
120
+ */
121
+ public useStartupScreen(render: (kernel: Kernel) => void): this {
122
+ this.startupScreen = render;
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * @internal The application's own root module, inside the hosts `runApp`
128
+ * wraps it in: the module tree of the startup screen starts from it.
129
+ */
130
+ public useApplicationRoot(root: BazisModuleRef): this {
131
+ this.applicationRoot = root;
132
+ return this;
133
+ }
134
+
107
135
  /**
108
136
  * Replacements for registered providers, for tests (see `createContainer`
109
137
  * `overrides`): registered after the whole graph and visible to every module.
@@ -138,6 +166,12 @@ export class KernelBuilder {
138
166
  const userRoot = resolveUserRoot(this.rootModule, environment);
139
167
  const configs = collectModuleConfigs([userRoot]);
140
168
  const configRegistry = new ConfigRegistry(configs, environment.name, configuration);
169
+ const startupReport = this.startupReport ?? !environment.isProduction;
170
+ // The screen replaces the report line, so it follows the same switch.
171
+ const screen = this.startupScreen !== undefined && startupReport;
172
+ const report = new StartupReport(screen);
173
+ report.configuration(configRegistry.inspect());
174
+ this.builtReport = report;
141
175
 
142
176
  // Kernel infrastructure is a global module: any user module can depend on
143
177
  // Environment / Configuration / ApplicationLifetime / EventBus without
@@ -146,6 +180,7 @@ export class KernelBuilder {
146
180
  @Module({
147
181
  providers: [
148
182
  DI.singleton(DI.valueProvider(ConfigRegistry, configRegistry)),
183
+ DI.singleton(DI.valueProvider(StartupReport, report)),
149
184
  ...configRegistry.providers,
150
185
  DI.singleton(DI.valueProvider(Environment, environment)),
151
186
  DI.singleton(DI.valueProvider(Configuration, configuration)),
@@ -179,8 +214,11 @@ export class KernelBuilder {
179
214
  shutdownTimeoutMs: this.shutdownTimeoutMs,
180
215
  signals: this.signals,
181
216
  unhandledErrorPolicy: this.unhandledErrorPolicy,
182
- startupReport: this.startupReport ?? !environment.isProduction,
217
+ startupReport,
183
218
  schemaOnly: this.schemaOnly ?? schemaOnlyFromProcess(),
219
+ report,
220
+ root: this.applicationRoot ?? userRoot,
221
+ ...(screen ? { onStarted: this.startupScreen } : {}),
184
222
  });
185
223
  }
186
224
  }