@cenk1cenk2/oclif-common
v8.0.1
Published
Oclif common package for oclif2 projects.
Readme
@cenk1cenk2/oclif-common
Oclif common package for oclif2 projects.
Shared building blocks for oclif-based CLIs: base commands, hooks, interfaces and utility helpers that the CLIs in this organisation compose instead of re-implementing.
Install
pnpm add @cenk1cenk2/oclif-commonThe package is ESM-only. Node.js >= 24 is required.
Usage
Extend the exported base command and run it through an injectable handler. The commands/ and lib/ exports cover the shared command and the DI container setup used across the CLIs built on this package.
Plugin
The package is also an oclif plugin that registers a command_not_found hook, which reports the unknown command and prints the help. Enable it by listing the package in the plugins of your CLI's package.json; oclif loads a plugin only when it is a dependency of the CLI as well:
{
"dependencies": {
"@cenk1cenk2/oclif-common": "^8.0.0"
},
"oclif": {
"plugins": ["@cenk1cenk2/oclif-common"]
}
}Commands
A command is an oclif command, a NestJS module and an injectable handler:
src/commands/build.ts # the oclif command: description, flags and args, with @UseHandler
src/modules/build/build.module.ts # the root module of the command, which provides the handler
src/modules/build/build.handler.ts # the @Injectable() handler that runs the command
src/modules/shared/shared.module.ts # optional, the modules every command importsA single-command CLI keeps its command in src/commands/index.ts and names its module directory after the CLI. Modules and handlers stay out of src/commands: oclif discovers commands by globbing every file in that directory and turns each file name into a command id, so a module or a handler there would show up as a broken command.
- The command extends
Commandand declares the oclif side as statics, which oclif reads: its description,static flagsandstatic args.@UseHandler({ module, handler })registers its root module and its handler. The decorator only accepts a handler that takes the flags and args of the command it decorates, so a handler written for another command is a type error. - The module imports the optional modules the command needs and provides the handler.
- The handler implements
CommandHandler<typeof MyCommand>, gets its dependencies through its constructor and theCommandContextof the command inrun(context).
CommandContext is the one way to the running command: its validated flags and args, the abort signal, the oclif Config as oclif, the command id and the command class. The handler receives it in run, and any provider, factory or handler can inject the same instance, constructor(private readonly context: CommandContext<typeof Print>) or inject: [CommandContext] in a forRootAsync. Its type argument, the command or its schema, only casts the input: nest injects the one instance whatever the type says, and only run is checked against the command by @UseHandler.
The base command parses the flags and args, boots the module into an application context, resolves the handler from it and runs it, and closes the context once the command ends, whether it failed or not.
The first SIGINT or SIGTERM does not tear the context down under a running handler: it aborts the signal of the command context and logs a warning, the handler finishes or stops early by watching that signal, for example by passing it to child processes, then the context closes and the command exits with the code of the signal, 130 for SIGINT and 143 for SIGTERM. A second signal exits at once. TaskManager leaves signals to the command, so a running listr task is not cut off either.
// src/commands/print.ts
import { Command, Flags, UseHandler } from '@cenk1cenk2/oclif-common'
import { PrintHandler } from '../modules/print/print.handler.js'
import { PrintModule } from '../modules/print/print.module.js'
@UseHandler({ module: PrintModule, handler: PrintHandler })
export default class Print extends Command {
static description = 'Print a document.'
static flags = {
browser: Flags.string()
}
}// src/modules/shared/shared.module.ts
import { LogLevels, LoggerModule, LogoModule, TaskModule } from '@cenk1cenk2/oclif-common'
import { Module } from '@nestjs/common'
@Module({
imports: [LoggerModule.forRoot({ stderr: Object.values(LogLevels) }), LogoModule.forRoot(), TaskModule.forRoot()],
exports: [TaskModule]
})
export class SharedModule {}// src/modules/print/print.module.ts
import { CommandContext, JsonParser, ParserModule, YamlParser } from '@cenk1cenk2/oclif-common'
import { Module } from '@nestjs/common'
import { PrintHandler } from './print.handler.js'
import { SharedModule } from '../shared/shared.module.js'
@Module({
imports: [
SharedModule,
ParserModule.forRoot({ parsers: [JsonParser, YamlParser] }),
PrinterModule.forRootAsync({ inject: [CommandContext], useFactory: (context: CommandContext<typeof Print>) => ({ browser: context.flags.browser }) })
],
providers: [PrintHandler]
})
export class PrintModule {}// src/modules/print/print.handler.ts
import type { CommandContext, CommandHandler } from '@cenk1cenk2/oclif-common'
import { LoggerService, TaskManager } from '@cenk1cenk2/oclif-common'
import { Injectable } from '@nestjs/common'
import type Print from '../../commands/print.js'
@Injectable()
export class PrintHandler implements CommandHandler<typeof Print> {
constructor(
private readonly logger: LoggerService,
private readonly tasks: TaskManager,
private readonly printer: PrinterService
) {}
public async run({ flags }: CommandContext<typeof Print>): Promise<void> {
this.logger.info('Printing with %s.', flags.browser)
this.tasks.add([{ title: 'Print', task: () => this.printer.print() }])
await this.tasks.runAll()
}
}Constructor injection reads the parameter types that TypeScript emits, so build with experimentalDecorators and emitDecoratorMetadata, and import injected services as values: an import type, or an interface as the parameter type, leaves nothing to resolve at runtime. Inject interfaces through a token with @Inject(token).
A handler needs no oclif to be tested: compile it in a Test.createTestingModule of @nestjs/testing with its dependencies, and call run with the flags and args.
Validation
oclif parses the flags and args. A schema in @UseHandler({ module, handler, schema }), any Standard Schema such as zod, valibot or arktype, then validates the parsed { flags, args } as a whole before the command boots, so it can check rules across flags and coerce or transform values. Its output replaces the input: the CommandContext, and with it the module factories, the providers and the handler, sees only checked values, while the log level and JSON flags are read from what oclif parsed. A rejected input fails like a flag oclif can not parse, once and with exit code 2. The handler takes CommandContext<typeof schema>, and the decorator rejects a handler that does not take the context of the schema, or a schema that reads a flag or arg the command does not declare.
// src/modules/deploy/deploy.schema.ts
export const deploySchema = z.object({
flags: z
.object({
target: z.enum(['staging', 'production']).describe('Where to deploy.'),
force: z.boolean().default(false),
tag: z.string().optional()
})
.refine((flags) => flags.target !== 'production' || flags.force || flags.tag, { message: 'production needs --force or --tag' }),
args: z.object({})
})
// src/commands/deploy.ts
@UseHandler({ module: DeployModule, handler: DeployHandler, schema: deploySchema })
export default class Deploy extends Command {
static flags = {
target: Flags.string({ char: 't' }),
force: Flags.boolean(),
tag: Flags.string()
}
}
// src/modules/deploy/deploy.handler.ts
@Injectable()
export class DeployHandler {
public async run({ flags }: CommandContext<typeof deploySchema>): Promise<void> {}
}The oclif options of a flag describe how to read argv, while the schema holds the rules for its value. When the schema also converts to JSON Schema through Standard JSON Schema, as zod 4 does, @UseHandler reads its input JSON Schema and describes the flags and args from it for --help, the manifest and shell completion: required, options from a string enum, default and description, wherever a flag or arg does not declare its own. The command still parses with the flags and args as declared, so oclif shows these rules without enforcing them, a rejected value fails once with the message of the schema, and a default applies once, from the schema. A schema that only validates leaves the flags and args as they are.
| oclif option | In the schema | In --help and completion |
| --- | --- | --- |
| required | yes | derived from the required of the JSON Schema |
| options (enum) | yes (z.enum) | derived from a string enum |
| default | yes (.default()) | derived from default |
| description | yes (.describe()) | derived when the flag has none |
| dependsOn, exclusive, exactlyOne, relationships | yes (refine) | not shown |
| min / max | yes | not shown |
| integer and number parsing | coerce in the schema, though oclif still needs a flag that takes a value | the help type label comes from the flag |
| char, env, multiple, allowNo, helpGroup, hidden | no, they read argv and shape the help | declared on the flag |
validate(schema, input) validates anything else without an application, resolving to the output of the schema and rejecting with a ValidationError with the issues. In services, ValidatorModule.forRoot() provides ValidatorService with the same method, which logs every issue by its path before it rejects.
Modules and services
Every command boots with the runtime modules only:
ConfigModulewithConfigService, the runtime config of the command: its log level and whether it answers in JSON.CommandModulewith theCommandContextof the running command, for module factories and services alike.LoggerModulewithLoggerService. It writesFATALandERRORto stderr and every other level to stdout; importLoggerModule.forRoot({ stderr })orLoggerModule.forRootAsync(...)anywhere in the module of the command, such as a shared module, to list the levels written to stderr instead. It only provides the options to the logger of the runtime, so configure it in one module and share that module. A command whose module fails to boot, or whose flags fail to parse, writes every level to stderr.
Everything else is imported by the module of the command, each module with its own options. These modules are visible only to the module that imports them: a shared module re-exports them with exports, or isGlobal: true in the options of forRoot registers one globally. Each of them has a forRootAsync as well.
| Module | Provides |
| --- | --- |
| TaskModule.forRoot(options?) | TaskManager, the listr2 task manager, which renders plainly at debug level and stays silent for silent or JSON output; options override the defaults. |
| ParserModule.forRoot({ parsers, json?, yaml? }) | ParserService, which reads, writes, parses and stringifies files by their extension with the given parsers, [JsonParser] by default. json takes the reviver, replacer and space (2 by default) of JSON.parse and JSON.stringify, and yaml the parse and stringify options of the yaml package. |
| FilesystemModule.forRoot() | FileSystemService. |
| LogoModule.forRoot({ generator }) | LogoService, which prints the logo when the command starts, at INFO, so it follows the stderr levels of the logger. |
| ValidatorModule.forRoot() | ValidatorService, an injectable wrapper over validate that logs every issue before it rejects. |
| RcModule.forRoot({ name, defaults?, schema?, merge?, cosmiconfig? }) | The configuration of the CLI, the rc file of the user merged over the bundled defaults, as TOKEN_RC, and RcService with the filepath of the rc file. |
RcModule looks for .namerc (YAML or JSON), .namerc.{json,yaml,yml,js,ts,cjs,mjs}, the same under .config/, name.config.{js,ts,cjs,mjs} and the name key of a package.json, from the working directory up to the home directory and then in the global config directory of the user. defaults is the base configuration the CLI ships: a file relative to the root of the CLI, read with the same loaders, or an object. The rc file is merged over it deeply: objects merge key by key, while arrays and every other value of the rc file replace those of the defaults; merge: (defaults, rc) => config replaces that merge, for example with the mergeWith of es-toolkit. cosmiconfig takes the options of cosmiconfig over ours, such as searchStrategy, stopDir, searchPlaces or loaders, the loaders merged with the YAML ones. Without an rc file the configuration is the defaults alone, or {}. The merged configuration is validated with schema, a Standard Schema, when given. It does not touch the flags, so the handler decides the precedence, for example flags over the rc file over the defaults:
@Injectable()
export class BuildHandler implements CommandHandler<typeof Build> {
constructor(@Inject(TOKEN_RC) private readonly rc: Partial<BuildOptions>) {}
public async run({ flags }: CommandContext<typeof Build>): Promise<void> {
const options = { ...DEFAULTS, ...this.rc, ...Object.fromEntries(Object.entries(flags).filter(([, value]) => value !== undefined)) }
}
}Nest reports its bootstrap, the resolution of its dependencies and the errors its exception handler catches only when the NEST_LOGS environment variable is 1 or true. They go through the command logger then, in its format and filtered by --log-level like every other line: on stderr while the application boots, then with the stderr levels of the command. NEST_DEBUG, which Nest reads for the debug output of its injector, stays separate.
pipeProcessToLogger(logger, process) and pipeProcessThroughListr(task, process) pipe a child process from any service.
Migration
Everything this major changes for a CLI built on the previous one.
Runtime and dependencies
- Node.js 24 or newer is required.
- The peer dependencies start at
@oclif/core5,listr211,@listr2/manager4,@nestjs/commonand@nestjs/core12, andyaml2, which is no longer optional. - The
enquirer,source-map-support,class-validator,class-transformer,fs-extraandupdate-notifierpeers are gone. setup()and its--inspect/--source-maphandling are removed: drop the call from yourbin/run, and usenode --inspectand--enable-source-maps, directly or throughNODE_OPTIONS.winstonis gone:WinstonServiceand theWinstontype are removed, resolveLoggerServicefrom the application instead of constructing it or reaching intoWinstonService.instance.InjectandDynamicModuleare no longer re-exported, import them from@nestjs/common;ClassTypeis replaced by theTypeof Nest.DeepPartialis removed, use thePartialDeepoftype-fest.
Commands
- Move each command to
src/commands/<command>.ts, and its module and handler tosrc/modules/<command>/. Move the body ofrun()into therun(context)of an@Injectable()handler implementingCommandHandler<typeof MyCommand>, provide it in the module and register both on the command with@UseHandler({ module, handler }); a command without it fails to start. A command no longer implementsrun(), has no default module, andCommandtakes no type parameters. register,shouldRunBefore,shouldRunAfter, their hook types and theisHookedWith*guards are removed; move setup and teardown into provider lifecycle hooks.- Replace
@Inject()properties with constructor parameters of the handler.@Optional()stays available on constructor parameters. - Replace
this.loggerwith an injectedLoggerService,this.taskswith an injectedTaskManagerfromTaskModule, andthis.flags/this.argswith theCommandContextthatrunreceives, or that module factories and services inject. Tasks no longer run on their own after the command: callawait tasks.runAll()in the handler. - Replace
this.pipeProcessToLogger(...)/this.pipeProcessThroughListr(...)withpipeProcessToLogger(logger, ...)/pipeProcessThroughListr(task, ...).this.setCtxDefaults/this.setCtxAssignare removed without a replacement helper; set the context of theTaskManagerdirectly. - The flags are typed from the base flags of the command: a flag the command does not declare is a type error,
jsonandciarebooleanandlog-levelisLogLevels. SIGINTandSIGTERMno longer close the application under a running handler: the first one aborts thesignalof the command context, the application closes once the handler returns, and the command exits with130or143.- A failed command prints its error once, through its logger; oclif no longer prints it a second time.
Modules
CliModuleis renamed toCliFactory, withCliFactory.create(module, options)andCliFactory.createFallback(options);CliModule.forRoot,forMinimumandforFallbackare gone, andCliModuleOptionsno longer exists.- Only
ConfigModule,CommandModuleandLoggerModuleare provided by default. Import the rest where the command uses it:TaskModule.forRoot(),ParserModule.forRoot({ parsers }),FilesystemModule.forRoot(),LogoModule.forRoot({ generator })andValidatorModule.forRoot(). None of them is global unless registered withisGlobal: true, so a shared module exports the ones other modules use, andParserModuleno longer makesFileSystemServiceavailable. - Parsers default to
[JsonParser];ParserService.registerandinjectare removed.TOKEN_LOGO_GENERATORis replaced byLogoModule.forRoot({ generator }), and a command withoutLogoModuleprints no logo. CommandModuleprovides theCommandContextof the command, which holds itsflags,args,signal, oclif config asoclif,idandcommandclass; there are noTOKEN_COMMAND_FLAGS,TOKEN_COMMAND_ARGS,TOKEN_OCLIF_CONFIGorTOKEN_COMMAND. Replaceinject: [TOKEN_COMMAND_FLAGS]withinject: [CommandContext]and readcontext.flags, and a handler'srun({ flags, args, signal })withrun(context);CommandInputandCommandSchemaInputare replaced byCommandContext<typeof MyCommand>andCommandContext<typeof schema>.ConfigModuleOptionsholds onlyconfig.ConfigServicekeeps only the runtime config. Replace itsoclif,command,rootanddefaultswith theoclif,commandandoclif.rootof theCommandContext;FileConstantsis removed. The free functionsisVerbose,isDebug,isSilentandisNestLoggingare no longer exported: use the getters ofConfigServiceandConfigService.isNestLogging(). Replace itsread/writewithParserService.read/writefromParserModule, and itsextend/mergeover bundled defaults withRcModule.forRoot({ name, defaults }). Itsenvand the environment variable definitions with__name,__formatand__elementare removed, together withConfigEnvKeys,ConfigIteratorandclonePath.
Logger
LoggerModule.forRoot({ stderr })takes the list of levels written to stderr instead of a boolean, by default[LogLevels.FATAL, LogLevels.ERROR]; replace{ stderr: true }with{ stderr: Object.values(LogLevels) }. The logo followsINFO.LoggerModule.forRootandforRootAsynconly provide the options of the logger of the runtime: import them once in the graph of the command module, sharing the module that imports them. A command that fails before its module boots writes every level to stderr.- The own logs of Nest, such as its bootstrap and its exception handler, are off whatever the log level; set
NEST_LOGS=1to see them.--log-level traceno longer turns them on or setsNEST_DEBUG.
Hooks
- The not-found hook is registered by the package as an oclif plugin, and
notFoundHookis no longer exported. Remove thecommand_not_foundentry and its file from your CLI, and add@cenk1cenk2/oclif-commontooclif.plugins; oclif only loads plugins the CLI also lists in itsdependencies. - The update-notifier hook and
updateNotifierHookare removed. Use@oclif/plugin-warn-if-update-availableinoclif.pluginsinstead.
Utilities and services
- The locker is removed:
LockerModule,LockerService(includingop) and their typesLockerServiceOptions,LockerModuleAsyncOptions,LockData,UnlockData,CommonLockerDataandLockableDataare gone. Read and write the file withParserService.read/write, merge withes-toolkit, and check or remove files withFileSystemService. ValidatorServicevalidates with any Standard Schema instead of class-validator classes:validate(schema, input)takes aStandardSchemaV1, resolves to its output and rejects withValidationErrorwith itsissues. There is no synchronous variant, since a Standard Schema may validate asynchronously: replacevalidateSync(...)withawait validate(...)orawait validatorService.validate(...).ValidatorModuletakes no options, andTOKEN_VALIDATOR_SERVICE_OPTIONSandValidatorServiceOptionsare removed.FileSystemService.extraand the exportedfstype are removed: usenode:fsorfs/promisesfor anything outside the service, whosestats,writeandremovetake theStats,WriteFileOptionsandRmOptionsof Node.js. File operations no longer go through graceful-fs.uniqueArrayFilteris removed, use theuniqof es-toolkit.mergeandMergeStrategyare removed, merge with themergeWithofes-toolkit, which writes into its first argument, so pass a copy:MergeStrategy.OVERWRITEismergeWith(cloneDeep(target), source, (_, value) => (Array.isArray(value) ? value : undefined)), andMergeStrategy.EXTENDismergeWith(cloneDeep(target), source, (current, value) => (Array.isArray(current) && Array.isArray(value) ? uniq([...current, ...value]) : undefined)).
Development
The repository is the package. pnpm lint typechecks the sources and the tests before linting them, and pnpm test builds the package and the fixture cli under test/fixtures/cli before running the tests:
pnpm install
pnpm lint
pnpm build
pnpm testReleases are cut by semantic-release from main, tagged as @cenk1cenk2/oclif-common@<version> like the releases from the former workspace, and published through npm staged publishing - the npm stage publish step parks the version for maintainer approval with 2FA before it reaches the registry.
