Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/cli-honor-log-level.md
Original file line number Diff line number Diff line change
@@ -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 <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`.
7 changes: 6 additions & 1 deletion packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.' }),
};

/**
Expand Down Expand Up @@ -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: {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 },
);
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/utils/log-level.ts
Original file line number Diff line number Diff line change
@@ -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 <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');
}
43 changes: 43 additions & 0 deletions packages/cli/test/serve-log-level.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});