Files
gh-vanman2024-cli-builder-p…/skills/oclif-patterns/examples/enterprise-cli-example.md
2025-11-30 09:04:14 +08:00

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

  1. Base Command: Shared functionality across all commands
  2. Configuration: Centralized config management
  3. Logging: Structured logging with levels
  4. Error Handling: Consistent error handling
  5. Confirmation Prompts: Interactive confirmations
  6. Rollback: Automatic rollback on failure
  7. Pre-deployment Checks: Validation before operations
  8. CI Detection: Different behavior in CI environments
  9. JSON Output: Machine-readable output option
  10. Environment Variables: Config via env vars