bazis 0.98.28 → 0.98.29

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 CHANGED
@@ -10,6 +10,13 @@ export interface BuildOptions {
10
10
  readonly bin: boolean;
11
11
  /** Executable path relative to the project; default `bin/<package name>`. */
12
12
  readonly outfile?: string;
13
+ /** Let the executable read `.env` files from its working directory (off by default). */
14
+ readonly dotenv?: boolean;
15
+ }
16
+
17
+ export interface CompileOptions {
18
+ /** Let the executable read `.env`, `.env.local`, `.env.<NODE_ENV>` from its working directory. */
19
+ readonly dotenv?: boolean;
13
20
  }
14
21
 
15
22
  /**
@@ -103,7 +110,7 @@ export async function runBuild(cwd: string, options: BuildOptions, codegen: Code
103
110
  if (typecheck !== 0 || !options.bin) return typecheck;
104
111
  const outfile = path.resolve(cwd, options.outfile ?? path.join("bin", await binaryName(cwd)));
105
112
  log(`[bazis] compile ${path.relative(cwd, outfile)}...`);
106
- return compileBinary(bun, await projectEntry(cwd), outfile);
113
+ return compileBinary(bun, await projectEntry(cwd), outfile, { dotenv: options.dotenv });
107
114
  }
108
115
 
109
116
  /**
@@ -111,11 +118,18 @@ export async function runBuild(cwd: string, options: BuildOptions, codegen: Code
111
118
  * `.<hash>.bun-build` file in its working directory and cannot remove it when
112
119
  * that executable is read-only or carries `uchg` (scripts/bazis-bun). Compile
113
120
  * from a private directory and remove it afterwards.
121
+ *
122
+ * By default a Bun executable reads `bunfig.toml` and `.env` files from the
123
+ * directory it is started in: a `preload` in such a bunfig runs foreign code
124
+ * inside the application, and a stray `.env` silently changes its settings.
125
+ * The executable gets its settings from the real environment instead;
126
+ * `dotenv: true` restores reading `.env`. A bunfig is never read.
114
127
  */
115
- export function compileBinary(bun: string, entry: string, outfile: string): number {
128
+ export function compileBinary(bun: string, entry: string, outfile: string, options: CompileOptions = {}): number {
116
129
  const workDir = mkdtempSync(path.join(tmpdir(), "bazis-compile-"));
130
+ const autoload = ["--no-compile-autoload-bunfig", options.dotenv === true ? "--compile-autoload-dotenv" : "--no-compile-autoload-dotenv"];
117
131
  try {
118
- const result = Bun.spawnSync([bun, "build", "--compile", path.resolve(entry), "--outfile", path.resolve(outfile)],
132
+ const result = Bun.spawnSync([bun, "build", "--compile", ...autoload, path.resolve(entry), "--outfile", path.resolve(outfile)],
119
133
  { cwd: workDir, stdout: "inherit", stderr: "inherit" });
120
134
  return result.exitCode ?? 1;
121
135
  } finally {
@@ -164,7 +164,8 @@ function buildProjectFiles(name: string, dependency: string, frameworkMode: Fram
164
164
  ["package.json", `${JSON.stringify(manifest, null, 2)}\n`],
165
165
  ["tsconfig.json", `${JSON.stringify(tsconfig, null, 2)}\n`],
166
166
  ["bazis.config.json", `${JSON.stringify(codegen, null, 2)}\n`],
167
- [".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n"],
167
+ // Bun reads .env, .env.local and .env.<NODE_ENV>: none of them belongs in git.
168
+ [".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n.env.*\n!.env.example\n"],
168
169
  [".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\nHOST=127.0.0.1\nPORT=3000\n"],
169
170
  ["src/app/test/health.test.ts", HEALTH_TEST],
170
171
  ["AGENTS.md", "# Working on this bazis project\n\nBefore changing the application, read the [module architecture](docs/architecture/MODULE_ARCHITECTURE.md). Create new modules only with `bunx bazis g module` or `bunx bazis g pack`; fill in the generated `MODULE.md` afterwards. Only codegen updates `src/generated/`.\n"],
package/cli/main.ts CHANGED
@@ -16,6 +16,7 @@ export const USAGE = `Usage:
16
16
  bazis test [<bun test args>] Codegen, then bun test
17
17
  bazis build Codegen and typecheck
18
18
  bazis build --bin [--outfile <path>] Also compile a standalone executable (default bin/<name>)
19
+ bazis build --bin --dotenv The executable also reads .env files from its working directory
19
20
  bazis --help
20
21
  g can also be written as generate.
21
22
 
@@ -85,7 +86,7 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
85
86
  if (parsed.kind === "codegen") return await runtime.codegen(process.cwd(), parsed.target);
86
87
  if (parsed.kind === "dev") return await runDev(process.cwd(), runtime.codegen, runtime.log, { watch: parsed.watch });
87
88
  if (parsed.kind === "test") return await runTest(process.cwd(), parsed.args, runtime.codegen);
88
- if (parsed.kind === "build") return await runBuild(process.cwd(), { bin: parsed.bin, outfile: parsed.outfile }, runtime.codegen, runtime.log);
89
+ if (parsed.kind === "build") return await runBuild(process.cwd(), { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv }, runtime.codegen, runtime.log);
89
90
  if (parsed.kind === "new") {
90
91
  const result = await generateProject({ name: parsed.name, outputPath: parsed.outputPath, frameworkPath: parsed.frameworkPath, linkFramework: parsed.linkFramework, vendor: parsed.vendor, dryRun: parsed.dryRun });
91
92
  runtime.log(`[bazis] ${result.dryRun ? "planned" : "created"} project: ${result.projectDir}`);
package/cli/parseCli.ts CHANGED
@@ -25,7 +25,7 @@ export type ParseCliResult =
25
25
  | { readonly kind: "codegen"; readonly target?: string }
26
26
  | { readonly kind: "dev"; readonly watch: boolean }
27
27
  | { readonly kind: "test"; readonly args: readonly string[] }
28
- | { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string }
28
+ | { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean }
29
29
  | { readonly kind: "error"; readonly message: string };
30
30
 
31
31
  const GENERATOR_ALIASES = new Map<string, "module" | "pack">([
@@ -33,7 +33,7 @@ const GENERATOR_ALIASES = new Map<string, "module" | "pack">([
33
33
  ["pack", "pack"], ["p", "pack"], ["module-pack", "pack"],
34
34
  ]);
35
35
  const VALUE_OPTIONS = new Set(["--modules-root", "--app-module", "--pack", "--parts", "--target", "--path", "--framework", "--outfile"]);
36
- const FLAG_OPTIONS = new Set(["--no-register", "--force", "--full", "--enterprise", "--minimal", "--empty", "--dry-run", "--no-codegen", "--link-framework", "--vendor", "--bin", "--watch"]);
36
+ const FLAG_OPTIONS = new Set(["--no-register", "--force", "--full", "--enterprise", "--minimal", "--empty", "--dry-run", "--no-codegen", "--link-framework", "--vendor", "--bin", "--watch", "--dotenv"]);
37
37
 
38
38
  /** Parsing is pure: help and invalid input can never start generation. */
39
39
  export function parseCliArgs(argv: readonly string[]): ParseCliResult {
@@ -89,14 +89,15 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
89
89
  }
90
90
  if (options.has("--watch")) return error("--watch is only supported by dev.");
91
91
  if (command === "build") {
92
- if (positional.length !== 1) return error("Use: bazis build [--bin [--outfile <path>]]");
92
+ if (positional.length !== 1) return error("Use: bazis build [--bin [--outfile <path>] [--dotenv]]");
93
93
  for (const option of options.keys()) {
94
- if (option !== "--bin" && option !== "--outfile") return error(`Option ${option} is not supported by build.`);
94
+ if (option !== "--bin" && option !== "--outfile" && option !== "--dotenv") return error(`Option ${option} is not supported by build.`);
95
95
  }
96
96
  if (options.has("--outfile") && !options.has("--bin")) return error("--outfile requires --bin.");
97
- return { kind: "build", bin: options.has("--bin"), outfile: options.get("--outfile") };
97
+ if (options.has("--dotenv") && !options.has("--bin")) return error("--dotenv requires --bin.");
98
+ return { kind: "build", bin: options.has("--bin"), outfile: options.get("--outfile"), dotenv: options.has("--dotenv") };
98
99
  }
99
- if (options.has("--bin") || options.has("--outfile")) return error("--bin and --outfile are only supported by build.");
100
+ if (options.has("--bin") || options.has("--outfile") || options.has("--dotenv")) return error("--bin, --outfile and --dotenv are only supported by build.");
100
101
  if (command === "codegen") {
101
102
  if (positional.length !== 1) return error("Use: bazis codegen [--target <name|all>]");
102
103
  for (const option of options.keys()) {
@@ -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.98.28" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.29" }, 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");
@@ -26,6 +26,16 @@ Flat Configuration key names are case-insensitive. A later source also wins
26
26
  between the canonical name and an explicit env alias.
27
27
  Numbers are finite; an empty string never becomes zero; a Secret does not allow an
28
28
  empty or whitespace value. TypeScript forbids unsupported object types.
29
+ In `production` a Secret whose value would come from the `default` section (no source
30
+ supplied it and the `production` section does not declare it) is an error: a secret
31
+ default in the code is a development value. `production: { key: secret("…") }`
32
+ declares a production value explicitly.
33
+
34
+ `secretFilesSource(directory, { optional })` reads secret files as Docker and Kubernetes
35
+ mount them (`/run/secrets`): one file per value, the file name is the key (`db.password`)
36
+ or its variable (`BAZIS_DB__PASSWORD`), trailing line breaks are removed, hidden entries
37
+ (Kubernetes `..data`) and directories are skipped, a file over 64 KiB is an error.
38
+ Errors name the file, never its content. A missing directory is an error unless `optional`.
29
39
 
30
40
  A declaration stores no chosen environment, values or cache. `get/has/ensureValid`
31
41
  stay for standalone reading of the process env, but `ensureValid` no longer
@@ -156,7 +156,8 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
156
156
  const issues: string[] = [];
157
157
  // A wrong value names what came and where to change it: the variable that
158
158
  // supplied it, or the variables that can override the value from the code.
159
- const got = (raw: unknown) => raw === undefined ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
159
+ // A key with a sensitive name never shows its value.
160
+ const got = (key: string, raw: unknown) => raw === undefined || isSensitiveKey(fullKey(key)) ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
160
161
  const source = (key: string, selected: { readonly name: string } | undefined) => ` (${selected ? selected.name : names.get(key)!.join(", ")})`;
161
162
  for (const key of keys) {
162
163
  const base = schema.default[key]!;
@@ -179,23 +180,31 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
179
180
  let value: ConfigValueType | undefined;
180
181
  if (type === "secret") {
181
182
  if (typeof raw !== "string" || !raw.trim()) issues.push(`${fullKey(key)} — required non-empty secret is not set (${names.get(key)!.join(", ")})`);
183
+ // A secret default in the code is a development value; production must
184
+ // get the secret from a source or declare its own value explicitly.
185
+ // No "apiKey: …" in the hint: the console redaction would hide it.
186
+ else if (environment === "production" && selected === undefined && override === undefined) issues.push(`${fullKey(key)} — production would use the development default secret from the code; set ${names.get(key)!.join(" or ")}, or declare ${key} in the production section`);
182
187
  else value = new Secret(raw);
183
188
  } else if (type === "number") {
184
189
  const parsed = Number(raw);
185
- if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number${got(raw)}${source(key, selected)}`);
190
+ if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number${got(key, raw)}${source(key, selected)}`);
186
191
  else value = parsed;
187
192
  } else if (type === "boolean") {
188
193
  const normalized = String(raw).toLowerCase();
189
194
  if (normalized === "true" || normalized === "1") value = true;
190
195
  else if (normalized === "false" || normalized === "0") value = false;
191
- else issues.push(`${fullKey(key)} — expected a boolean (true, false, 1, 0)${got(raw)}${source(key, selected)}`);
196
+ else issues.push(`${fullKey(key)} — expected a boolean (true, false, 1, 0)${got(key, raw)}${source(key, selected)}`);
192
197
  } else if (typeof raw === "string") value = raw;
193
198
  else issues.push(`${fullKey(key)} — expected a string`);
194
199
  if (value === undefined) continue;
195
- if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — ${JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`);
200
+ if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — ${isSensitiveKey(fullKey(key)) ? "the value" : JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`);
196
201
  try {
197
202
  const issue = validators[key]?.(value);
198
- if (issue) issues.push(`${fullKey(key)} — ${issue}`);
203
+ // A validator may quote its own value; hide it in that message only.
204
+ // Replacing secrets across the whole text turned a one-letter secret
205
+ // into "m***il.***piKey".
206
+ const sensitive = value instanceof Secret ? value.reveal() : typeof value === "string" && isSensitiveKey(fullKey(key)) ? value : "";
207
+ if (issue) issues.push(`${fullKey(key)} — ${sensitive.trim() ? issue.replaceAll(sensitive, "***") : issue}`);
199
208
  } catch { issues.push(`${fullKey(key)} — validator failed`); }
200
209
  values.set(key, value);
201
210
  inspection.push(Object.freeze({ key: fullKey(key), type: type as ConfigInspection["type"], env: names.get(key)!, source: selected?.source ?? (override === undefined ? "default" : environment), value: safeValue(fullKey(key), value) }));
@@ -203,11 +212,7 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
203
212
  if (issues.length) {
204
213
  // "key — text", not "key: text": the console redaction would read
205
214
  // "db.password: required" as a secret value and hide the word "required".
206
- let message = issues.join("; ");
207
- for (const [key, value] of values) {
208
- const sensitive = value instanceof Secret ? value.reveal() : typeof value === "string" && isSensitiveKey(key) ? value : undefined;
209
- if (sensitive) message = message.replaceAll(sensitive, "***");
210
- }
215
+ const message = issues.join("; ");
211
216
  throw new KernelError(`Invalid configuration (environment "${environment}"): ${message}.`);
212
217
  }
213
218
  const rows = Object.freeze(inspection);
@@ -1,3 +1,5 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { KernelError } from "../errors";
2
4
  import type { ConfigSource } from "../types";
3
5
 
@@ -96,6 +98,62 @@ export function jsonFileSource(path: string, options?: { readonly optional?: boo
96
98
  };
97
99
  }
98
100
 
101
+ /** Largest secret file read by {@link secretFilesSource}: keys and certificates are a few KiB. */
102
+ const SECRET_FILE_MAX_BYTES = 64 * 1024;
103
+
104
+ /**
105
+ * Secret files of a directory, as Docker and Kubernetes mount them
106
+ * (`/run/secrets`). Each file is one value; its name is the key, written as
107
+ * the key (`db.password`) or as its variable (`BAZIS_DB__PASSWORD`). Trailing
108
+ * line breaks are removed. Hidden entries (Kubernetes `..data`) and
109
+ * directories are skipped. Errors name the file, never its content.
110
+ */
111
+ export function secretFilesSource(directory: string, options?: { readonly optional?: boolean }): ConfigSource {
112
+ return {
113
+ description: `files(${directory})`,
114
+ load: async () => {
115
+ let names: string[];
116
+ try {
117
+ names = await readdir(directory);
118
+ } catch (error) {
119
+ if (options?.optional === true && (error as NodeJS.ErrnoException).code === "ENOENT") {
120
+ return {};
121
+ }
122
+ throw new KernelError(`Secret directory "${directory}" cannot be read (${(error as NodeJS.ErrnoException).code ?? "error"}).`);
123
+ }
124
+ const result: Record<string, string> = {};
125
+ for (const name of names.sort()) {
126
+ if (name.startsWith(".")) {
127
+ continue;
128
+ }
129
+ const file = path.join(directory, name);
130
+ let text: string;
131
+ try {
132
+ // stat follows the symlinks Kubernetes uses for each key.
133
+ const info = await stat(file);
134
+ if (!info.isFile()) {
135
+ continue;
136
+ }
137
+ if (info.size > SECRET_FILE_MAX_BYTES) {
138
+ throw new KernelError(`Secret file "${file}" is larger than ${SECRET_FILE_MAX_BYTES} bytes.`);
139
+ }
140
+ text = await Bun.file(file).text();
141
+ } catch (error) {
142
+ if (error instanceof KernelError) throw error;
143
+ throw new KernelError(`Secret file "${file}" cannot be read (${(error as NodeJS.ErrnoException).code ?? "error"}).`);
144
+ }
145
+ const key = name.toUpperCase().startsWith(ENV_PREFIX)
146
+ ? name.slice(ENV_PREFIX.length).toLowerCase().replaceAll("__", ".")
147
+ : name.toLowerCase();
148
+ if (key.length > 0) {
149
+ result[key] = text.replace(/[\r\n]+$/, "");
150
+ }
151
+ }
152
+ return result;
153
+ },
154
+ };
155
+ }
156
+
99
157
  function flatten(tree: ConfigTree, prefix = "", into: Record<string, string> = {}): Record<string, string> {
100
158
  for (const [key, value] of Object.entries(tree)) {
101
159
  const fullKey = prefix.length > 0 ? `${prefix}.${key}` : key;
@@ -29,7 +29,7 @@ export {
29
29
  type ValidatableConfig,
30
30
  } from "./config/defineConfig";
31
31
  export { addConfigOptions, configOptions, type ConfigOptionsBinding } from "./config/addConfigOptions";
32
- export { argsSource, envSource, jsonFileSource, memorySource } from "./config/sources";
32
+ export { argsSource, envSource, jsonFileSource, memorySource, secretFilesSource } from "./config/sources";
33
33
 
34
34
  export {
35
35
  EVENT_HANDLER,
@@ -1,4 +1,4 @@
1
- import { redactSensitive, type SensitiveRedactionOptions } from "../../../library/redaction";
1
+ import { redactSensitive, redactSensitiveText, type SensitiveRedactionOptions } from "../../../library/redaction";
2
2
  import type { LogFields, LogLevel, Logger } from "./Logger";
3
3
  import { getRequestId, getTraceparent } from "../correlation/requestContext";
4
4
 
@@ -16,7 +16,10 @@ export interface ConsoleLoggerOptions {
16
16
  readonly minLevel?: LogLevel;
17
17
  /** Prefix tag, e.g. the app name. */
18
18
  readonly name?: string;
19
- /** Structured fields are redacted by default; pass false only for trusted local debugging. */
19
+ /**
20
+ * The message and structured fields are redacted by default (secret keys, `password=…`,
21
+ * bearer tokens, URL credentials); pass false only for trusted local debugging.
22
+ */
20
23
  readonly redaction?: SensitiveRedactionOptions | false;
21
24
  /**
22
25
  * Inside a request, add `requestId` (and `traceparent`, when the request
@@ -83,6 +86,7 @@ export class ConsoleLogger implements Logger {
83
86
  return;
84
87
  }
85
88
  const line = this.requestContext ? withRequestContext(fields) : fields;
86
- LEVEL_SINK[level](`${this.prefix}${level}: ${message}${formatFields(line, this.redaction)}`);
89
+ const text = this.redaction === false ? message : redactSensitiveText(message, this.redaction);
90
+ LEVEL_SINK[level](`${this.prefix}${level}: ${text}${formatFields(line, this.redaction)}`);
87
91
  }
88
92
  }
@@ -19,6 +19,8 @@ const DEFAULT_SENSITIVE_FRAGMENTS = [
19
19
  const SECRET_TEXT_PATTERN =
20
20
  /\b(password|passwd|passphrase|secret|token|api[-_]?key|cookie|set-cookie|private[-_]?key|client[-_]?secret)\b(\s*[:=]\s*)("[^"]*"|'[^']*'|[^,\s}]+)/gi;
21
21
  const BEARER_TEXT_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
22
+ // Credentials in a URL: postgres://user:password@host, https://token@host.
23
+ const URL_USERINFO_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/?#@]+)@/gi;
22
24
 
23
25
  export interface SensitiveRedactionOptions {
24
26
  readonly replacement?: string;
@@ -162,5 +164,7 @@ function normalizeKey(key: string): string {
162
164
  function redactText(value: string, replacement: string): string {
163
165
  return value
164
166
  .replace(SECRET_TEXT_PATTERN, (_match, name: string, separator: string) => `${name}${separator}${replacement}`)
165
- .replace(BEARER_TEXT_PATTERN, (_match, scheme: string) => `${scheme} ${replacement}`);
167
+ .replace(BEARER_TEXT_PATTERN, (_match, scheme: string) => `${scheme} ${replacement}`)
168
+ .replace(URL_USERINFO_PATTERN, (_match, scheme: string, userinfo: string) =>
169
+ `${scheme}${userinfo.includes(":") ? `${replacement}:${replacement}` : replacement}@`);
166
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.28",
3
+ "version": "0.98.29",
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",