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:
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
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