diff --git a/.changeset/cli-honor-log-level.md b/.changeset/cli-honor-log-level.md new file mode 100644 index 000000000..cfc796dfc --- /dev/null +++ b/.changeset/cli-honor-log-level.md @@ -0,0 +1,9 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): honor OS_LOG_LEVEL / --log-level instead of hardcoding the kernel logger to `silent` (#1533) + +`os serve` / `os start` built the runtime kernel with a hardcoded `{ level: 'silent' }` logger, suppressing every plugin `logger.warn` / `logger.error`. A record-change flow whose condition or node faulted (surfaced via `logger.warn` in `plugin-trigger-record-change`) produced zero operator-visible output — the flow simply had no effect — undercutting ADR-0032's "fail loudly" promise when run via the CLI. + +The kernel logger level is now resolved from `--verbose` (→ `debug`) → `--log-level ` → `$OS_LOG_LEVEL` / `$LOG_LEVEL` → default `warn`. Defaulting to `warn` surfaces flow/hook execution-failure warnings and automation-engine errors out of the box, while the existing boot-quiet window still suppresses info-level startup chatter. Pass `--log-level silent` (or `OS_LOG_LEVEL=silent`) to restore the previous fully-quiet behavior. `start` and `dev` gain a matching `--log-level` flag and forward it (plus the existing `--verbose`) to the spawned `serve`. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index f91ccc5a4..4b53672cf 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -20,7 +20,11 @@ export default class Dev extends Command { static override flags = { watch: Flags.boolean({ char: 'w', description: 'Enable watch mode (default)', default: true }), ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/' }), - verbose: Flags.boolean({ char: 'v', description: 'Verbose output' }), + verbose: Flags.boolean({ char: 'v', description: 'Verbose output (shortcut for --log-level debug)' }), + 'log-level': Flags.string({ + description: 'Kernel logger level forwarded to `serve` (overrides $OS_LOG_LEVEL / $LOG_LEVEL; default `warn`). One of: debug | info | warn | error | fatal | silent.', + options: ['debug', 'info', 'warn', 'error', 'fatal', 'silent'], + }), port: Flags.string({ char: 'p', description: 'Server port (overrides $PORT)' }), preset: Flags.string({ description: 'Plugin tier preset forwarded to `serve`: minimal | default | full', @@ -212,6 +216,7 @@ export default class Dev extends Command { ...(port ? ['--port', port] : []), ...(flags.ui ? ['--ui'] : []), ...(flags.verbose ? ['--verbose'] : []), + ...(flags['log-level'] ? ['--log-level', flags['log-level']] : []), ...(flags.preset ? ['--preset', flags.preset] : []), ], // 'ipc' adds a message channel so the serve child can report the diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4410e9bed..3c1830edf 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -10,6 +10,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { readEnvWithDeprecation } from '@objectstack/types'; import { resolveObjectStackHome } from '@objectstack/runtime'; +import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; import { printHeader, printKV, @@ -158,6 +159,11 @@ export default class Serve extends Command { description: 'Plugin tier preset: minimal | default | full (overridden by config.tiers if set)', options: ['minimal', 'default', 'full'], }), + 'log-level': Flags.string({ + description: 'Kernel logger level. Defaults to $OS_LOG_LEVEL / $LOG_LEVEL, else `warn` so flow/hook execution failures surface (ADR-0032). Use `silent` to fully quiet the runtime.', + options: [...LOG_LEVELS], + }), + verbose: Flags.boolean({ char: 'v', description: 'Verbose output — shortcut for --log-level debug.' }), }; /** @@ -481,8 +487,18 @@ export default class Serve extends Command { // Import ObjectStack runtime const { Runtime } = await import('@objectstack/runtime'); - // Set kernel logger to 'silent' — the CLI manages its own output - const loggerConfig = { level: 'silent' as const }; + // Resolve the kernel logger level. Honors --verbose / --log-level and + // $OS_LOG_LEVEL / $LOG_LEVEL, defaulting to `warn` so flow/hook + // execution failures surface even when the CLI manages its own output + // (ADR-0032 "fail loudly"; see #1533). `--log-level silent` restores the + // old fully-quiet behavior. + const loggerConfig = { + level: resolveLogLevel({ + verbose: flags.verbose, + flag: flags['log-level'], + envLevel: readLogLevelEnv(), + }), + }; const runtime = new Runtime({ kernel: { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 4033d6b6f..805399fbf 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -62,7 +62,11 @@ export default class Start extends Command { default: true, allowNo: true, }), - verbose: Flags.boolean({ char: 'v', description: 'Verbose output' }), + verbose: Flags.boolean({ char: 'v', description: 'Verbose output (shortcut for --log-level debug)' }), + 'log-level': Flags.string({ + description: 'Kernel logger level forwarded to `serve` (overrides $OS_LOG_LEVEL / $LOG_LEVEL; default `warn`). One of: debug | info | warn | error | fatal | silent.', + options: ['debug', 'info', 'warn', 'error', 'fatal', 'silent'], + }), // Home directory — where persistent runtime state lives. home: Flags.string({ @@ -241,6 +245,7 @@ export default class Start extends Command { 'serve', flags.ui ? '--ui' : '--no-ui', ...(flags.verbose ? ['--verbose'] : []), + ...(flags['log-level'] ? ['--log-level', flags['log-level']] : []), ], { stdio: 'inherit', env: localEnv }, ); diff --git a/packages/cli/src/utils/log-level.ts b/packages/cli/src/utils/log-level.ts new file mode 100644 index 000000000..fcb29547a --- /dev/null +++ b/packages/cli/src/utils/log-level.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { readEnvWithDeprecation } from '@objectstack/types'; + +// --------------------------------------------------------------------------- +// Kernel logger level resolution (shared by `serve`, and forwarded by +// `start` / `dev`). +// +// `serve` used to hard-pin the kernel logger to `silent` so the CLI could own +// a clean startup banner. That silenced every runtime fault plugins surface +// through `logger.warn` / `logger.error` — most importantly the record-change +// trigger's flow-execution-failure warnings and the automation engine's +// internal errors — making a faulting flow fail completely silently and +// defeating ADR-0032's "fail loudly" promise (see #1533). +// +// The level is resolved from (in precedence order): +// 1. `--verbose` → `debug` +// 2. `--log-level ` → explicit level +// 3. `$OS_LOG_LEVEL` / `$LOG_LEVEL` +// 4. default → `warn` +// +// The default is `warn` rather than `silent`: warnings + errors reach the +// operator out of the box while the boot-quiet window in `serve` still +// suppresses the noisier info-level startup chatter. Pass `--log-level silent` +// (or `OS_LOG_LEVEL=silent`) to restore the fully-quiet behavior. +// --------------------------------------------------------------------------- +export const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'fatal', 'silent'] as const; +export type CliLogLevel = (typeof LOG_LEVELS)[number]; + +/** Default kernel logger level when nothing is configured. */ +export const DEFAULT_LOG_LEVEL: CliLogLevel = 'warn'; + +/** + * Resolve the kernel logger level from CLI flags and an explicit env value. + * Unknown / malformed levels fall back to {@link DEFAULT_LOG_LEVEL} rather + * than throwing, so a typo never crashes the server boot. + */ +export function resolveLogLevel(opts: { + verbose?: boolean; + flag?: string; + envLevel?: string; +}): CliLogLevel { + if (opts.verbose) return 'debug'; + const raw = (opts.flag ?? opts.envLevel ?? DEFAULT_LOG_LEVEL).toLowerCase().trim(); + return (LOG_LEVELS as readonly string[]).includes(raw) ? (raw as CliLogLevel) : DEFAULT_LOG_LEVEL; +} + +/** + * Read `$OS_LOG_LEVEL` (preferred) / `$LOG_LEVEL` (legacy) from the + * environment, emitting the standard deprecation warning for the legacy name. + */ +export function readLogLevelEnv(): string | undefined { + return readEnvWithDeprecation('OS_LOG_LEVEL', 'LOG_LEVEL'); +} diff --git a/packages/cli/test/serve-log-level.test.ts b/packages/cli/test/serve-log-level.test.ts new file mode 100644 index 000000000..280f84414 --- /dev/null +++ b/packages/cli/test/serve-log-level.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { resolveLogLevel, LOG_LEVELS, DEFAULT_LOG_LEVEL } from '../src/utils/log-level.js'; + +describe('serve: resolveLogLevel', () => { + it('defaults to warn (not silent) — the #1533 regression guard', () => { + expect(DEFAULT_LOG_LEVEL).toBe('warn'); + }); + + // Regression guard for #1533: the CLI must NOT silently default to + // 'silent', or runtime flow/hook execution failures (logged at warn+) + // are invisible, defeating ADR-0032's "fail loudly" promise. + it('defaults to `warn` when nothing is set', () => { + expect(resolveLogLevel({})).toBe('warn'); + }); + + it('honors $OS_LOG_LEVEL / $LOG_LEVEL value', () => { + expect(resolveLogLevel({ envLevel: 'info' })).toBe('info'); + expect(resolveLogLevel({ envLevel: 'silent' })).toBe('silent'); + }); + + it('--log-level flag wins over the env value', () => { + expect(resolveLogLevel({ flag: 'error', envLevel: 'info' })).toBe('error'); + }); + + it('--verbose wins over everything and maps to `debug`', () => { + expect(resolveLogLevel({ verbose: true, flag: 'error', envLevel: 'silent' })).toBe('debug'); + }); + + it('is case-insensitive and trims surrounding whitespace', () => { + expect(resolveLogLevel({ flag: ' DEBUG ' })).toBe('debug'); + }); + + it('falls back to `warn` for an unrecognized level rather than throwing', () => { + expect(resolveLogLevel({ envLevel: 'verbose' })).toBe('warn'); + expect(resolveLogLevel({ flag: 'loud' })).toBe('warn'); + }); + + it('accepts every documented level verbatim', () => { + for (const level of LOG_LEVELS) { + expect(resolveLogLevel({ flag: level })).toBe(level); + } + }); +});