10 KiB
10 KiB
Enterprise CLI Example
Complete production-ready oclif CLI with all best practices.
Overview
This example demonstrates:
- Custom base command with common functionality
- Configuration management
- Logging system
- Error handling
- Plugin support
- Comprehensive testing
- CI/CD integration
- Auto-update capability
Project Structure
enterprise-cli/
├── package.json
├── tsconfig.json
├── .eslintrc.json
├── .github/
│ └── workflows/
│ ├── test.yml
│ └── release.yml
├── src/
│ ├── base-command.ts
│ ├── config/
│ │ ├── manager.ts
│ │ └── schema.ts
│ ├── commands/
│ │ ├── deploy.ts
│ │ ├── status.ts
│ │ └── config/
│ │ ├── get.ts
│ │ └── set.ts
│ ├── hooks/
│ │ ├── init.ts
│ │ └── prerun.ts
│ ├── utils/
│ │ ├── logger.ts
│ │ └── error-handler.ts
│ └── index.ts
├── test/
│ ├── commands/
│ ├── helpers/
│ └── fixtures/
└── docs/
└── commands/
Base Command Implementation
File: src/base-command.ts
import { Command, Flags } from '@oclif/core'
import { ConfigManager } from './config/manager'
import { Logger } from './utils/logger'
import { ErrorHandler } from './utils/error-handler'
export default abstract class BaseCommand extends Command {
protected configManager!: ConfigManager
protected logger!: Logger
protected errorHandler!: ErrorHandler
static baseFlags = {
config: Flags.string({
char: 'c',
description: 'Path to config file',
env: 'CLI_CONFIG',
}),
'log-level': Flags.string({
description: 'Set log level',
options: ['error', 'warn', 'info', 'debug'],
default: 'info',
env: 'LOG_LEVEL',
}),
json: Flags.boolean({
description: 'Output as JSON',
default: false,
}),
'no-color': Flags.boolean({
description: 'Disable colors',
default: false,
}),
}
async init(): Promise<void> {
await super.init()
// Initialize logger
const { flags } = await this.parse(this.constructor as typeof BaseCommand)
this.logger = new Logger(flags['log-level'], !flags['no-color'])
// Initialize config manager
this.configManager = new ConfigManager(flags.config)
await this.configManager.load()
// Initialize error handler
this.errorHandler = new ErrorHandler(this.logger)
// Log initialization
this.logger.debug(`Initialized ${this.id}`)
}
protected async catch(err: Error & { exitCode?: number }): Promise<any> {
return this.errorHandler.handle(err)
}
protected output(data: any, humanMessage?: string): void {
const { flags } = this.parse(this.constructor as typeof BaseCommand)
if (flags.json) {
this.log(JSON.stringify(data, null, 2))
} else if (humanMessage) {
this.log(humanMessage)
} else {
this.log(JSON.stringify(data, null, 2))
}
}
}
Configuration Manager
File: src/config/manager.ts
import * as fs from 'fs-extra'
import * as path from 'path'
import * as os from 'os'
export class ConfigManager {
private config: any = {}
private configPath: string
constructor(customPath?: string) {
this.configPath = customPath || this.getDefaultConfigPath()
}
async load(): Promise<void> {
if (await fs.pathExists(this.configPath)) {
this.config = await fs.readJson(this.configPath)
} else {
// Create default config
this.config = this.getDefaultConfig()
await this.save()
}
}
async save(): Promise<void> {
await fs.ensureDir(path.dirname(this.configPath))
await fs.writeJson(this.configPath, this.config, { spaces: 2 })
}
get(key: string, defaultValue?: any): any {
const keys = key.split('.')
let value = this.config
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k]
} else {
return defaultValue
}
}
return value
}
set(key: string, value: any): void {
const keys = key.split('.')
const lastKey = keys.pop()!
let current = this.config
for (const k of keys) {
if (!(k in current) || typeof current[k] !== 'object') {
current[k] = {}
}
current = current[k]
}
current[lastKey] = value
}
private getDefaultConfigPath(): string {
return path.join(os.homedir(), '.config', 'mycli', 'config.json')
}
private getDefaultConfig(): any {
return {
version: '1.0.0',
defaults: {
region: 'us-east-1',
timeout: 30000,
},
}
}
}
Logger Implementation
File: src/utils/logger.ts
import chalk from 'chalk'
export class Logger {
constructor(
private level: string = 'info',
private color: boolean = true
) {}
debug(message: string): void {
if (this.shouldLog('debug')) {
this.output('DEBUG', message, chalk.gray)
}
}
info(message: string): void {
if (this.shouldLog('info')) {
this.output('INFO', message, chalk.blue)
}
}
warn(message: string): void {
if (this.shouldLog('warn')) {
this.output('WARN', message, chalk.yellow)
}
}
error(message: string, error?: Error): void {
if (this.shouldLog('error')) {
this.output('ERROR', message, chalk.red)
if (error && error.stack) {
console.error(chalk.red(error.stack))
}
}
}
success(message: string): void {
if (this.shouldLog('info')) {
this.output('SUCCESS', message, chalk.green)
}
}
private shouldLog(level: string): boolean {
const levels = ['error', 'warn', 'info', 'debug']
const currentIndex = levels.indexOf(this.level)
const messageIndex = levels.indexOf(level)
return messageIndex <= currentIndex
}
private output(level: string, message: string, colorFn: any): void {
const timestamp = new Date().toISOString()
const prefix = this.color ? colorFn(`[${level}]`) : `[${level}]`
console.log(`${timestamp} ${prefix} ${message}`)
}
}
Enterprise Deploy Command
File: src/commands/deploy.ts
import BaseCommand from '../base-command'
import { Flags, Args, ux } from '@oclif/core'
export default class Deploy extends BaseCommand {
static description = 'Deploy application to environment'
static examples = [
'<%= config.bin %> deploy myapp --env production',
'<%= config.bin %> deploy myapp --env staging --auto-approve',
]
static flags = {
...BaseCommand.baseFlags,
env: Flags.string({
char: 'e',
description: 'Environment to deploy to',
options: ['development', 'staging', 'production'],
required: true,
}),
'auto-approve': Flags.boolean({
description: 'Skip confirmation prompt',
default: false,
}),
'rollback-on-failure': Flags.boolean({
description: 'Automatically rollback on failure',
default: true,
}),
}
static args = {
app: Args.string({
description: 'Application name',
required: true,
}),
}
async run(): Promise<void> {
const { args, flags } = await this.parse(Deploy)
this.logger.info(`Starting deployment of ${args.app} to ${flags.env}`)
// Confirmation prompt (skip in CI or with auto-approve)
if (!flags['auto-approve'] && !process.env.CI) {
const confirmed = await ux.confirm(
`Deploy ${args.app} to ${flags.env}? (y/n)`
)
if (!confirmed) {
this.logger.info('Deployment cancelled')
return
}
}
// Pre-deployment checks
ux.action.start('Running pre-deployment checks')
await this.runPreDeploymentChecks(args.app, flags.env)
ux.action.stop('passed')
// Deploy
ux.action.start(`Deploying ${args.app}`)
try {
const result = await this.deploy(args.app, flags.env)
ux.action.stop('done')
this.logger.success(`Deployed ${args.app} to ${flags.env}`)
this.output(result, `Deployment URL: ${result.url}`)
} catch (error) {
ux.action.stop('failed')
if (flags['rollback-on-failure']) {
this.logger.warn('Deployment failed, rolling back...')
await this.rollback(args.app, flags.env)
}
throw error
}
}
private async runPreDeploymentChecks(
app: string,
env: string
): Promise<void> {
// Check credentials
// Validate app exists
// Check environment health
// Verify dependencies
await new Promise(resolve => setTimeout(resolve, 1000))
}
private async deploy(app: string, env: string): Promise<any> {
// Actual deployment logic
await new Promise(resolve => setTimeout(resolve, 3000))
return {
app,
env,
version: '1.2.3',
url: `https://${app}.${env}.example.com`,
deployedAt: new Date().toISOString(),
}
}
private async rollback(app: string, env: string): Promise<void> {
// Rollback logic
await new Promise(resolve => setTimeout(resolve, 2000))
this.logger.info('Rollback complete')
}
}
Testing Setup
File: test/helpers/test-context.ts
import * as path from 'path'
import * as fs from 'fs-extra'
import { Config } from '@oclif/core'
export class TestContext {
testDir: string
config: Config
constructor() {
this.testDir = path.join(__dirname, '../fixtures/test-run')
}
async setup(): Promise<void> {
await fs.ensureDir(this.testDir)
this.config = await Config.load()
}
async teardown(): Promise<void> {
await fs.remove(this.testDir)
}
async createConfigFile(config: any): Promise<string> {
const configPath = path.join(this.testDir, 'config.json')
await fs.writeJson(configPath, config)
return configPath
}
}
Key Enterprise Features
- Base Command: Shared functionality across all commands
- Configuration: Centralized config management
- Logging: Structured logging with levels
- Error Handling: Consistent error handling
- Confirmation Prompts: Interactive confirmations
- Rollback: Automatic rollback on failure
- Pre-deployment Checks: Validation before operations
- CI Detection: Different behavior in CI environments
- JSON Output: Machine-readable output option
- Environment Variables: Config via env vars