Dependency Injection - Logger with Log Level

Registers a logger provider whose factory inspects args.logLevel. Any command handler can inject('logger') via getCommandContext() — no prop drilling, and the logger's level filtering automatically follows whatever the user passed on the command line.

The handler lives in its own module and imports the CLI instance. That way typeof app is fully resolved where getCommandContext(app) is called, so inject('logger') is properly typed as Logger instead of unknown.

The Logger

A standalone module that defines the logger and its level-filtering logic. Keeping this out of the CLI module makes it trivial to unit test and reuse.

1export const LEVELS = ['debug', 'info', 'warn', 'error'] as const;
2export type Level = (typeof LEVELS)[number];
3
4export interface Logger {
5  debug(msg: string): void;
6  info(msg: string): void;
7  warn(msg: string): void;
8  error(msg: string): void;
9  /** How many messages have been suppressed below the configured level. */
10  suppressed(): number;
11}
12
13export function makeLogger(level: Level): Logger {
14  const threshold = LEVELS.indexOf(level);
15  let suppressed = 0;
16  const emit = (lvl: Level, msg: string) => {
17    if (LEVELS.indexOf(lvl) < threshold) {
18      suppressed++;
19      return;
20    }
21    console.log(`[${lvl.toUpperCase()}] ${msg}`);
22  };
23  return {
24    debug: (m) => emit('debug', m),
25    info: (m) => emit('info', m),
26    warn: (m) => emit('warn', m),
27    error: (m) => emit('error', m),
28    suppressed: () => suppressed,
29  };
30}

Registering the Provider

.provide('logger', { factory }) registers a factory that receives the finalized, fully-parsed args. Because the factory is only called the first time inject('logger') runs — during the handler phase, after all parsing, middleware, and validation have completed — args.logLevel already reflects whatever the user passed on the command line (or via environment variables, config files, defaults, etc.).

1import { cli } from 'cli-forge';
2import { LEVELS, Level, makeLogger } from './logger';
3import { runBuild } from './build';
4
5// A CLI that registers a logger as a DI provider. The factory reads
6// `args.logLevel` when the provider is first injected, so the logger
7// automatically obeys whatever level the user passed on the command line —
8// no middleware, no prop drilling, no module-level singletons.
9export const app = cli('builder')
10  .option('logLevel', {
11    type: 'string',
12    choices: LEVELS,
13    default: 'info' as Level,
14    description: 'Minimum log level to emit',
15  })
16  .provide('logger', {
17    // Factory receives the finalized args, so logLevel is the one the user
18    // actually passed (after defaults, env vars, and config files resolve).
19    factory: (args) => makeLogger(args.logLevel as Level),
20  })
21  .command('build', {
22    description: 'Build a target',
23    builder: (cmd) =>
24      cmd.option('target', {
25        type: 'string',
26        required: true,
27        description: 'Build target (e.g. web, node)',
28      }),
29    handler: runBuild,
30  });
31
32if (require.main === module) {
33  app.forge();
34}

Using the Logger in a Handler

The handler lives in its own file and imports the CLI instance. This matters: getCommandContext(app) uses app as a type witness — TypeScript infers providers and args types from typeof app. If the handler were defined inline inside the .command() call, typeof app would be circular and TypeScript would fall back to unknown. Splitting the handler sidesteps that.

The live context is read from AsyncLocalStorage set up by forge(), but the app argument is not purely compile-time. It's also used as a runtime identity check: every InternalCLI is stamped with a unique commandId, and getCommandContext(cli) throws if cli.commandId isn't on the active chain (root → running command). Passing the wrong CLI as the witness fails loudly instead of silently returning the wrong providers.

1import { getCommandContext } from 'cli-forge/context';
2import { app } from './cli';
3
4// The handler lives in its own file so that `typeof app` is fully resolved
5// by the time this module is type-checked. `getCommandContext(app)` uses
6// the imported CLI as both a type witness (for inject/args typing) and a
7// runtime identity check — at runtime the stored commandIdChain is walked
8// to confirm `app` is part of the active execution.
9export function runBuild() {
10  const ctx = getCommandContext(app);
11  const build = ctx.getChildContext('build');
12
13  const log = ctx.inject('logger');
14
15  log.debug('Resolving toolchain');
16  log.debug('Loading config');
17  log.info(`Building target: ${build.args.target}`);
18  log.warn('No cache configured');
19
20  // Surface how many messages the logger filtered out so tests can verify
21  // that log level filtering was applied.
22  console.log(`DONE (suppressed=${log.suppressed()})`);
23}

Why This Pattern

Without DI, a logger typically becomes a module-level singleton that's imported everywhere:

ts
1// logger.ts
2export const logger = makeLogger(process.env.LOG_LEVEL ?? 'info');

That works until you want to:

  • Override the level per-command invocation (tests, SDK usage, REPL)
  • Make the level depend on parsed CLI args (which don't exist at import time)
  • Mock the logger in unit tests without module-level surgery

With .provide(), the logger is scoped to each execution, reads args from the actual parse, and can be swapped via TestHarness.mockContext() in tests.


All Example Files

FILE EXPLORER
build.ts
1import { getCommandContext } from 'cli-forge/context';
2import { app } from './cli';
3
4// The handler lives in its own file so that `typeof app` is fully resolved
5// by the time this module is type-checked. `getCommandContext(app)` uses
6// the imported CLI as both a type witness (for inject/args typing) and a
7// runtime identity check — at runtime the stored commandIdChain is walked
8// to confirm `app` is part of the active execution.
9export function runBuild() {
10  const ctx = getCommandContext(app);
11  const build = ctx.getChildContext('build');
12
13  const log = ctx.inject('logger');
14
15  log.debug('Resolving toolchain');
16  log.debug('Loading config');
17  log.info(`Building target: ${build.args.target}`);
18  log.warn('No cache configured');
19
20  // Surface how many messages the logger filtered out so tests can verify
21  // that log level filtering was applied.
22  console.log(`DONE (suppressed=${log.suppressed()})`);
23}
24